Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 31 additions & 9 deletions cycode/cli/apps/ai_guardrails/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ class PolicyMode(str, Enum):
WARN = 'warn'


class InstallMode(str, Enum):
"""Installation mode for ai-guardrails install command."""

REPORT = 'report'
BLOCK = 'block'


class IDEConfig(NamedTuple):
"""Configuration for an AI IDE."""

Expand Down Expand Up @@ -76,20 +83,23 @@ def _get_claude_code_hooks_dir() -> Path:

# Command used in hooks
CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan'
CYCODE_AUTH_CHECK_COMMAND = "if cycode status 2>&1 | grep -q 'Is authenticated: False'; then cycode auth 2>&1; fi"


def _get_cursor_hooks_config() -> dict:
def _get_cursor_hooks_config(async_mode: bool = False) -> dict:
"""Get Cursor-specific hooks configuration."""
config = IDE_CONFIGS[AIIDEType.CURSOR]
hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events}
command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND
hooks = {event: [{'command': command}] for event in config.hook_events}
hooks['sessionStart'] = [{'command': CYCODE_AUTH_CHECK_COMMAND}]

return {
'version': 1,
'hooks': hooks,
}


def _get_claude_code_hooks_config() -> dict:
def _get_claude_code_hooks_config(async_mode: bool = False) -> dict:
"""Get Claude Code-specific hooks configuration.

Claude Code uses a different hook format with nested structure:
Expand All @@ -98,36 +108,48 @@ def _get_claude_code_hooks_config() -> dict:
"""
command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code'

hook_entry = {'type': 'command', 'command': command}
if async_mode:
hook_entry['async'] = True
hook_entry['timeout'] = 20

return {
'hooks': {
'SessionStart': [
{
'matcher': '',
'hooks': [{'type': 'command', 'command': CYCODE_AUTH_CHECK_COMMAND}],
}
],
'UserPromptSubmit': [
{
'hooks': [{'type': 'command', 'command': command}],
'hooks': [hook_entry.copy()],
}
],
'PreToolUse': [
{
'matcher': 'Read',
'hooks': [{'type': 'command', 'command': command}],
'hooks': [hook_entry.copy()],
},
{
'matcher': 'mcp__.*',
'hooks': [{'type': 'command', 'command': command}],
'hooks': [hook_entry.copy()],
},
],
},
}


def get_hooks_config(ide: AIIDEType) -> dict:
def get_hooks_config(ide: AIIDEType, async_mode: bool = False) -> dict:
"""Get the hooks configuration for a specific IDE.

Args:
ide: The AI IDE type
async_mode: If True, hooks run asynchronously (non-blocking)

Returns:
Dict with hooks configuration for the specified IDE
"""
if ide == AIIDEType.CLAUDE_CODE:
return _get_claude_code_hooks_config()
return _get_cursor_hooks_config()
return _get_claude_code_hooks_config(async_mode=async_mode)
return _get_cursor_hooks_config(async_mode=async_mode)
66 changes: 61 additions & 5 deletions cycode/cli/apps/ai_guardrails/hooks_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@
Supports multiple IDEs: Cursor, Claude Code (future).
"""

import copy
import json
from pathlib import Path
from typing import Optional

import yaml

from cycode.cli.apps.ai_guardrails.consts import (
CYCODE_SCAN_PROMPT_COMMAND,
DEFAULT_IDE,
IDE_CONFIGS,
AIIDEType,
PolicyMode,
get_hooks_config,
)
from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME
from cycode.logger import get_logger

logger = get_logger('AI Guardrails Hooks')
Expand Down Expand Up @@ -58,6 +62,13 @@ def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool:
return False


_CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails', 'cycode auth')


def _is_cycode_command(command: str) -> bool:
return any(marker in command for marker in _CYCODE_COMMAND_MARKERS)


def is_cycode_hook_entry(entry: dict) -> bool:
"""Check if a hook entry is from cycode-cli.

Expand All @@ -68,22 +79,66 @@ def is_cycode_hook_entry(entry: dict) -> bool:
"""
# Check Cursor format (flat command)
command = entry.get('command', '')
if CYCODE_SCAN_PROMPT_COMMAND in command:
if _is_cycode_command(command):
return True

# Check Claude Code format (nested hooks array)
hooks = entry.get('hooks', [])
for hook in hooks:
if isinstance(hook, dict):
hook_command = hook.get('command', '')
if CYCODE_SCAN_PROMPT_COMMAND in hook_command:
if _is_cycode_command(hook_command):
return True

return False


def _load_policy(policy_path: Path) -> dict:
"""Load existing policy file merged with defaults, or return defaults if not found."""
if not policy_path.exists():
return copy.deepcopy(DEFAULT_POLICY)
try:
existing = yaml.safe_load(policy_path.read_text(encoding='utf-8')) or {}
except Exception:
existing = {}
return {**copy.deepcopy(DEFAULT_POLICY), **existing}


def create_policy_file(scope: str, mode: PolicyMode, repo_path: Optional[Path] = None) -> tuple[bool, str]:
"""Create or update the ai-guardrails.yaml policy file.

If the file already exists, only the mode field is updated.
If it doesn't exist, a new file is created from the default policy.

Args:
scope: 'user' for user-level, 'repo' for repository-level
mode: The policy mode to set
repo_path: Repository path (required if scope is 'repo')

Returns:
Tuple of (success, message)
"""
config_dir = repo_path / '.cycode' if scope == 'repo' and repo_path else Path.home() / '.cycode'
policy_path = config_dir / POLICY_FILE_NAME

policy = _load_policy(policy_path)

policy['mode'] = mode.value

try:
config_dir.mkdir(parents=True, exist_ok=True)
policy_path.write_text(yaml.dump(policy, default_flow_style=False, sort_keys=False), encoding='utf-8')
return True, f'AI guardrails policy ({mode.value} mode) set: {policy_path}'
except Exception as e:
logger.error('Failed to create policy file', exc_info=e)
return False, f'Failed to create policy file: {policy_path}'


def install_hooks(
scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE
scope: str = 'user',
repo_path: Optional[Path] = None,
ide: AIIDEType = DEFAULT_IDE,
report_mode: bool = False,
) -> tuple[bool, str]:
"""
Install Cycode AI guardrails hooks.
Expand All @@ -92,6 +147,7 @@ def install_hooks(
scope: 'user' for user-level hooks, 'repo' for repository-level hooks
repo_path: Repository path (required if scope is 'repo')
ide: The AI IDE type (default: Cursor)
report_mode: If True, install hooks in async mode (non-blocking)

Returns:
Tuple of (success, message)
Expand All @@ -104,7 +160,7 @@ def install_hooks(
existing.setdefault('hooks', {})

# Get IDE-specific hooks configuration
hooks_config = get_hooks_config(ide)
hooks_config = get_hooks_config(ide, async_mode=report_mode)

# Add/update Cycode hooks
for event, entries in hooks_config['hooks'].items():
Expand Down
52 changes: 40 additions & 12 deletions cycode/cli/apps/ai_guardrails/install_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
validate_and_parse_ide,
validate_scope,
)
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType
from cycode.cli.apps.ai_guardrails.hooks_manager import install_hooks
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType, InstallMode, PolicyMode
from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, install_hooks


def install_command(
Expand Down Expand Up @@ -43,14 +43,24 @@ def install_command(
resolve_path=True,
),
] = None,
mode: Annotated[
InstallMode,
typer.Option(
'--mode',
'-m',
help='Installation mode: "report" for async non-blocking hooks with warn policy, '
'"block" for sync blocking hooks.',
),
] = InstallMode.REPORT,
) -> None:
"""Install AI guardrails hooks for supported IDEs.

This command configures the specified IDE to use Cycode for scanning prompts, file reads,
and MCP tool calls for secrets before they are sent to AI models.

Examples:
cycode ai-guardrails install # Install for all projects (user scope)
cycode ai-guardrails install # Install in report mode (default)
cycode ai-guardrails install --mode block # Install in block mode
cycode ai-guardrails install --scope repo # Install for current repo only
cycode ai-guardrails install --ide cursor # Install for Cursor IDE
cycode ai-guardrails install --ide all # Install for all supported IDEs
Expand All @@ -66,7 +76,8 @@ def install_command(
results: list[tuple[str, bool, str]] = []
for current_ide in ides_to_install:
ide_name = IDE_CONFIGS[current_ide].name
success, message = install_hooks(scope, repo_path, ide=current_ide)
report_mode = mode == InstallMode.REPORT
success, message = install_hooks(scope, repo_path, ide=current_ide, report_mode=report_mode)
results.append((ide_name, success, message))

# Report results for each IDE
Expand All @@ -81,14 +92,31 @@ def install_command(
all_success = False

if any_success:
console.print()
console.print('[bold]Next steps:[/]')
successful_ides = [name for name, success, _ in results if success]
ide_list = ', '.join(successful_ides)
console.print(f'1. Restart {ide_list} to activate the hooks')
console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml')
console.print()
console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]')
policy_mode = PolicyMode.WARN if mode == InstallMode.REPORT else PolicyMode.BLOCK
_install_policy(scope, repo_path, policy_mode)
_print_next_steps(results, mode)

if not all_success:
raise typer.Exit(1)


def _install_policy(scope: str, repo_path: Optional[Path], policy_mode: PolicyMode) -> None:
policy_success, policy_message = create_policy_file(scope, policy_mode, repo_path)
if policy_success:
console.print(f'[green]✓[/] {policy_message}')
else:
console.print(f'[red]✗[/] {policy_message}', style='bold red')


def _print_next_steps(results: list[tuple[str, bool, str]], mode: InstallMode) -> None:
console.print()
console.print('[bold]Next steps:[/]')
successful_ides = [name for name, success, _ in results if success]
ide_list = ', '.join(successful_ides)
console.print(f'1. Restart {ide_list} to activate the hooks')
console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml')
console.print()
if mode == InstallMode.REPORT:
console.print('[dim]Report mode: hooks run async (non-blocking) and policy is set to warn.[/]')
else:
console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]')
2 changes: 2 additions & 0 deletions tests/cli/commands/ai_guardrails/scan/test_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def test_from_cursor_payload_prompt_event() -> None:
assert unified.ide_provider == 'cursor'
assert unified.ide_version == '0.42.0'
assert unified.prompt == 'Test prompt'
assert type(unified.ide_provider) is str


def test_from_cursor_payload_file_read_event() -> None:
Expand Down Expand Up @@ -153,6 +154,7 @@ def test_from_claude_code_payload_prompt_event() -> None:
assert unified.conversation_id == 'session-123'
assert unified.ide_provider == 'claude-code'
assert unified.prompt == 'Test prompt for Claude Code'
assert type(unified.ide_provider) is str


def test_from_claude_code_payload_file_read_event() -> None:
Expand Down
Loading
Loading