From bd250a4abff65ffbf8ae15dac114ffbd9e0c65eb Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Mon, 26 Jan 2026 17:06:34 +0200 Subject: [PATCH 01/22] CM-58022-cycode-guardrails-support-cursor-scan-via-hooks --- cycode/cli/app.py | 3 +- cycode/cli/apps/ai_guardrails/__init__.py | 11 + .../cli/apps/ai_guardrails/command_utils.py | 66 ++++ cycode/cli/apps/ai_guardrails/consts.py | 80 ++++ .../cli/apps/ai_guardrails/hooks_manager.py | 208 +++++++++++ .../cli/apps/ai_guardrails/install_command.py | 78 ++++ .../cli/apps/ai_guardrails/status_command.py | 92 +++++ .../apps/ai_guardrails/uninstall_command.py | 73 ++++ cycode/cli/apps/scan/__init__.py | 9 + cycode/cli/apps/scan/code_scanner.py | 2 +- cycode/cli/apps/scan/prompt/__init__.py | 1 + cycode/cli/apps/scan/prompt/consts.py | 48 +++ cycode/cli/apps/scan/prompt/handlers.py | 338 +++++++++++++++++ cycode/cli/apps/scan/prompt/payload.py | 71 ++++ cycode/cli/apps/scan/prompt/policy.py | 85 +++++ cycode/cli/apps/scan/prompt/prompt_command.py | 113 ++++++ .../cli/apps/scan/prompt/response_builders.py | 91 +++++ cycode/cli/apps/scan/prompt/types.py | 45 +++ cycode/cli/apps/scan/prompt/utils.py | 72 ++++ cycode/cli/cli_types.py | 13 + cycode/cli/utils/get_api_client.py | 17 +- cycode/cli/utils/scan_utils.py | 24 ++ cycode/cyclient/ai_security_manager_client.py | 81 ++++ .../ai_security_manager_service_config.py | 27 ++ cycode/cyclient/client_creator.py | 20 + tests/cli/commands/ai_guardrails/__init__.py | 0 .../ai_guardrails/test_command_utils.py | 57 +++ tests/cli/commands/scan/prompt/__init__.py | 0 .../cli/commands/scan/prompt/test_handlers.py | 352 ++++++++++++++++++ .../cli/commands/scan/prompt/test_payload.py | 129 +++++++ tests/cli/commands/scan/prompt/test_policy.py | 215 +++++++++++ .../scan/prompt/test_response_builders.py | 79 ++++ tests/cli/commands/scan/prompt/test_utils.py | 129 +++++++ 33 files changed, 2625 insertions(+), 4 deletions(-) create mode 100644 cycode/cli/apps/ai_guardrails/__init__.py create mode 100644 cycode/cli/apps/ai_guardrails/command_utils.py create mode 100644 cycode/cli/apps/ai_guardrails/consts.py create mode 100644 cycode/cli/apps/ai_guardrails/hooks_manager.py create mode 100644 cycode/cli/apps/ai_guardrails/install_command.py create mode 100644 cycode/cli/apps/ai_guardrails/status_command.py create mode 100644 cycode/cli/apps/ai_guardrails/uninstall_command.py create mode 100644 cycode/cli/apps/scan/prompt/__init__.py create mode 100644 cycode/cli/apps/scan/prompt/consts.py create mode 100644 cycode/cli/apps/scan/prompt/handlers.py create mode 100644 cycode/cli/apps/scan/prompt/payload.py create mode 100644 cycode/cli/apps/scan/prompt/policy.py create mode 100644 cycode/cli/apps/scan/prompt/prompt_command.py create mode 100644 cycode/cli/apps/scan/prompt/response_builders.py create mode 100644 cycode/cli/apps/scan/prompt/types.py create mode 100644 cycode/cli/apps/scan/prompt/utils.py create mode 100644 cycode/cyclient/ai_security_manager_client.py create mode 100644 cycode/cyclient/ai_security_manager_service_config.py create mode 100644 tests/cli/commands/ai_guardrails/__init__.py create mode 100644 tests/cli/commands/ai_guardrails/test_command_utils.py create mode 100644 tests/cli/commands/scan/prompt/__init__.py create mode 100644 tests/cli/commands/scan/prompt/test_handlers.py create mode 100644 tests/cli/commands/scan/prompt/test_payload.py create mode 100644 tests/cli/commands/scan/prompt/test_policy.py create mode 100644 tests/cli/commands/scan/prompt/test_response_builders.py create mode 100644 tests/cli/commands/scan/prompt/test_utils.py diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 3ef0b322..e838519e 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -9,7 +9,7 @@ from typer.completion import install_callback, show_callback from cycode import __version__ -from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, report_import, scan, status +from cycode.cli.apps import ai_guardrails, ai_remediation, auth, configure, ignore, report, report_import, scan, status if sys.version_info >= (3, 10): from cycode.cli.apps import mcp @@ -45,6 +45,7 @@ add_completion=False, # we add it manually to control the rich help panel ) +app.add_typer(ai_guardrails.app) app.add_typer(ai_remediation.app) app.add_typer(auth.app) app.add_typer(configure.app) diff --git a/cycode/cli/apps/ai_guardrails/__init__.py b/cycode/cli/apps/ai_guardrails/__init__.py new file mode 100644 index 00000000..d8fe88e0 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/__init__.py @@ -0,0 +1,11 @@ +import typer + +from cycode.cli.apps.ai_guardrails.install_command import install_command +from cycode.cli.apps.ai_guardrails.status_command import status_command +from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command + +app = typer.Typer(name='ai-guardrails', no_args_is_help=True) + +app.command(name='install', short_help='Install AI guardrails hooks for supported IDEs.')(install_command) +app.command(name='uninstall', short_help='Remove AI guardrails hooks from supported IDEs.')(uninstall_command) +app.command(name='status', short_help='Show AI guardrails hook installation status.')(status_command) diff --git a/cycode/cli/apps/ai_guardrails/command_utils.py b/cycode/cli/apps/ai_guardrails/command_utils.py new file mode 100644 index 00000000..92c48edc --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/command_utils.py @@ -0,0 +1,66 @@ +"""Common utilities for AI guardrails commands.""" + +import os +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console + +from cycode.cli.apps.ai_guardrails.consts import AIIDEType + +console = Console() + + +def validate_and_parse_ide(ide: str) -> AIIDEType: + """Validate IDE parameter and convert to AIIDEType enum. + + Args: + ide: IDE name string (e.g., 'cursor') + + Returns: + AIIDEType enum value + + Raises: + typer.Exit: If IDE is invalid + """ + try: + return AIIDEType(ide.lower()) + except ValueError: + valid_ides = ', '.join([ide_type.value for ide_type in AIIDEType]) + console.print( + f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}', + style='bold red', + ) + raise typer.Exit(1) + + +def validate_scope(scope: str, allowed_scopes: tuple[str, ...] = ('user', 'repo')) -> None: + """Validate scope parameter. + + Args: + scope: Scope string to validate + allowed_scopes: Tuple of allowed scope values + + Raises: + typer.Exit: If scope is invalid + """ + if scope not in allowed_scopes: + scopes_list = ', '.join(f'"{s}"' for s in allowed_scopes) + console.print(f'[red]Error:[/] Invalid scope. Use {scopes_list}.', style='bold red') + raise typer.Exit(1) + + +def resolve_repo_path(scope: str, repo_path: Optional[Path]) -> Optional[Path]: + """Resolve repository path, defaulting to current directory for repo scope. + + Args: + scope: The command scope ('user' or 'repo') + repo_path: Provided repo path or None + + Returns: + Resolved Path for repo scope, None for user scope + """ + if scope == 'repo' and repo_path is None: + return Path(os.getcwd()) + return repo_path diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py new file mode 100644 index 00000000..7cda0408 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -0,0 +1,80 @@ +"""Constants for AI guardrails hooks management. + +Currently supports: +- Cursor + +To add a new IDE (e.g., Claude Code): +1. Add new value to AIIDEType enum +2. Create _get__hooks_dir() function with platform-specific paths +3. Add entry to IDE_CONFIGS dict with IDE-specific hook event names +4. Unhide --ide option in commands (install, uninstall, status) +""" + +import platform +from enum import Enum +from pathlib import Path +from typing import NamedTuple + + +class AIIDEType(str, Enum): + """Supported AI IDE types.""" + CURSOR = 'cursor' + + +class IDEConfig(NamedTuple): + """Configuration for an AI IDE.""" + name: str + hooks_dir: Path + repo_hooks_subdir: str # Subdirectory in repo for hooks (e.g., '.cursor') + hooks_file_name: str + hook_events: list[str] # List of supported hook event names for this IDE + + +def _get_cursor_hooks_dir() -> Path: + """Get Cursor hooks directory based on platform.""" + if platform.system() == 'Darwin': + return Path.home() / '.cursor' + elif platform.system() == 'Windows': + return Path.home() / 'AppData' / 'Roaming' / 'Cursor' + else: # Linux + return Path.home() / '.config' / 'Cursor' + + +# IDE-specific configurations +IDE_CONFIGS: dict[AIIDEType, IDEConfig] = { + AIIDEType.CURSOR: IDEConfig( + name='Cursor', + hooks_dir=_get_cursor_hooks_dir(), + repo_hooks_subdir='.cursor', + hooks_file_name='hooks.json', + hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'], + ), +} + +# Default IDE +DEFAULT_IDE = AIIDEType.CURSOR + +# Marker to identify Cycode hooks +CYCODE_MARKER = 'cycode_guardrails' + +# Command used in hooks +CYCODE_SCAN_PROMPT_COMMAND = 'cycode scan prompt' + + +def get_hooks_config(ide: AIIDEType) -> dict: + """Get the hooks configuration for a specific IDE. + + Args: + ide: The AI IDE type + + Returns: + Dict with hooks configuration for the specified IDE + """ + config = IDE_CONFIGS[ide] + hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events} + + return { + 'version': 1, + 'hooks': hooks, + CYCODE_MARKER: True, + } diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py new file mode 100644 index 00000000..438705d1 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -0,0 +1,208 @@ +""" +Hooks manager for AI guardrails. + +Handles installation, removal, and status checking of AI IDE hooks. +Supports multiple IDEs: Cursor, Claude Code (future). +""" + +import json +from pathlib import Path +from typing import Optional + +from cycode.cli.apps.ai_guardrails.consts import ( + AIIDEType, + CYCODE_MARKER, + CYCODE_SCAN_PROMPT_COMMAND, + DEFAULT_IDE, + IDE_CONFIGS, + get_hooks_config, +) +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Hooks') + + +def get_hooks_path(scope: str, repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE) -> Path: + """Get the hooks.json path for the given scope and IDE. + + Args: + 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) + """ + config = IDE_CONFIGS[ide] + if scope == 'repo' and repo_path: + return repo_path / config.repo_hooks_subdir / config.hooks_file_name + return config.hooks_dir / config.hooks_file_name + + +def load_hooks_file(hooks_path: Path) -> Optional[dict]: + """Load existing hooks.json file.""" + if not hooks_path.exists(): + return None + try: + content = hooks_path.read_text(encoding='utf-8') + return json.loads(content) + except Exception as e: + logger.debug('Failed to load hooks file', exc_info=e) + return None + + +def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: + """Save hooks.json file.""" + try: + hooks_path.parent.mkdir(parents=True, exist_ok=True) + hooks_path.write_text(json.dumps(hooks_config, indent=2), encoding='utf-8') + return True + except Exception as e: + logger.error('Failed to save hooks file', exc_info=e) + return False + + +def is_cycode_hook_entry(entry: dict) -> bool: + """Check if a hook entry is from cycode-cli.""" + command = entry.get('command', '') + return CYCODE_SCAN_PROMPT_COMMAND in command + + +def install_hooks( + scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE +) -> tuple[bool, str]: + """ + Install Cycode AI guardrails hooks. + + Args: + 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) + + Returns: + Tuple of (success, message) + """ + hooks_path = get_hooks_path(scope, repo_path, ide) + + # Load existing hooks or create new + existing = load_hooks_file(hooks_path) or {'version': 1, 'hooks': {}} + existing.setdefault('version', 1) + existing.setdefault('hooks', {}) + + # Get IDE-specific hooks configuration + hooks_config = get_hooks_config(ide) + + # Add/update Cycode hooks + for event, entries in hooks_config['hooks'].items(): + existing['hooks'].setdefault(event, []) + + # Remove any existing Cycode entries for this event + existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)] + + # Add new Cycode entries + for entry in entries: + existing['hooks'][event].append(entry) + + # Add marker + existing[CYCODE_MARKER] = True + + # Save + if save_hooks_file(hooks_path, existing): + return True, f'AI guardrails hooks installed: {hooks_path}' + return False, f'Failed to install hooks to {hooks_path}' + + +def uninstall_hooks( + scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE +) -> tuple[bool, str]: + """ + Remove Cycode AI guardrails hooks. + + Args: + 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) + + Returns: + Tuple of (success, message) + """ + hooks_path = get_hooks_path(scope, repo_path, ide) + + existing = load_hooks_file(hooks_path) + if existing is None: + return True, f'No hooks file found at {hooks_path}' + + # Remove Cycode entries from all events + modified = False + for event in list(existing.get('hooks', {}).keys()): + original_count = len(existing['hooks'][event]) + existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)] + if len(existing['hooks'][event]) != original_count: + modified = True + # Remove empty event lists + if not existing['hooks'][event]: + del existing['hooks'][event] + + # Remove marker + if CYCODE_MARKER in existing: + del existing[CYCODE_MARKER] + modified = True + + if not modified: + return True, 'No Cycode hooks found to remove' + + # Save or delete if empty + if not existing.get('hooks'): + try: + hooks_path.unlink() + return True, f'Removed hooks file: {hooks_path}' + except Exception as e: + logger.debug('Failed to delete hooks file', exc_info=e) + return False, f'Failed to remove hooks file: {hooks_path}' + + if save_hooks_file(hooks_path, existing): + return True, f'Cycode hooks removed from: {hooks_path}' + return False, f'Failed to update hooks file: {hooks_path}' + + +def get_hooks_status( + scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE +) -> dict: + """ + Get the status of AI guardrails hooks. + + Args: + 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) + + Returns: + Dict with status information + """ + hooks_path = get_hooks_path(scope, repo_path, ide) + + status = { + 'scope': scope, + 'ide': ide.value, + 'ide_name': IDE_CONFIGS[ide].name, + 'hooks_path': str(hooks_path), + 'file_exists': hooks_path.exists(), + 'cycode_installed': False, + 'hooks': {}, + } + + existing = load_hooks_file(hooks_path) + if existing is None: + return status + + status['cycode_installed'] = existing.get(CYCODE_MARKER, False) + + # Check each hook event for this IDE + ide_config = IDE_CONFIGS[ide] + for event in ide_config.hook_events: + entries = existing.get('hooks', {}).get(event, []) + cycode_entries = [e for e in entries if is_cycode_hook_entry(e)] + status['hooks'][event] = { + 'total_entries': len(entries), + 'cycode_entries': len(cycode_entries), + 'enabled': len(cycode_entries) > 0, + } + + return status diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py new file mode 100644 index 00000000..4da2eeea --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -0,0 +1,78 @@ +"""Install command for AI guardrails hooks.""" + +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from cycode.cli.apps.ai_guardrails.command_utils import ( + console, + resolve_repo_path, + validate_and_parse_ide, + validate_scope, +) +from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS +from cycode.cli.apps.ai_guardrails.hooks_manager import install_hooks +from cycode.cli.utils.sentry import add_breadcrumb + + +def install_command( + ctx: typer.Context, + scope: Annotated[ + str, + typer.Option( + '--scope', + '-s', + help='Installation scope: "user" for all projects, "repo" for current repository only.', + ), + ] = 'user', + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE to install hooks for (e.g., "cursor"). Defaults to cursor.', + ), + ] = 'cursor', + repo_path: Annotated[ + Optional[Path], + typer.Option( + '--repo-path', + help='Repository path for repo-scoped installation (defaults to current directory).', + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, +) -> 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 --scope repo # Install for current repo only + cycode ai-guardrails install --ide cursor # Install for Cursor IDE + cycode ai-guardrails install --scope repo --repo-path /path/to/repo + """ + add_breadcrumb('ai-guardrails-install') + + # Validate inputs + validate_scope(scope) + repo_path = resolve_repo_path(scope, repo_path) + ide_type = validate_and_parse_ide(ide) + ide_name = IDE_CONFIGS[ide_type].name + success, message = install_hooks(scope, repo_path, ide=ide_type) + + if success: + console.print(f'[green]✓[/] {message}') + console.print() + console.print('[bold]Next steps:[/]') + console.print(f'1. Restart {ide_name} 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.[/]') + else: + console.print(f'[red]✗[/] {message}', style='bold red') + raise typer.Exit(1) diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py new file mode 100644 index 00000000..ff520de0 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -0,0 +1,92 @@ +"""Status command for AI guardrails hooks.""" + +import os +from pathlib import Path +from typing import Annotated, Optional + +import typer +from rich.table import Table + +from cycode.cli.apps.ai_guardrails.command_utils import console, validate_and_parse_ide, validate_scope +from cycode.cli.apps.ai_guardrails.hooks_manager import get_hooks_status +from cycode.cli.utils.sentry import add_breadcrumb + + +def status_command( + ctx: typer.Context, + scope: Annotated[ + str, + typer.Option( + '--scope', + '-s', + help='Check scope: "user", "repo", or "all" for both.', + ), + ] = 'all', + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE to check status for (e.g., "cursor"). Defaults to cursor.', + ), + ] = 'cursor', + repo_path: Annotated[ + Optional[Path], + typer.Option( + '--repo-path', + help='Repository path for repo-scoped status (defaults to current directory).', + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, +) -> None: + """Show AI guardrails hook installation status. + + Displays the current status of Cycode AI guardrails hooks for the specified IDE. + + Examples: + cycode ai-guardrails status # Show both user and repo status + cycode ai-guardrails status --scope user # Show only user-level status + cycode ai-guardrails status --scope repo # Show only repo-level status + cycode ai-guardrails status --ide cursor # Check status for Cursor IDE + """ + add_breadcrumb('ai-guardrails-status') + + # Validate inputs (status allows 'all' scope) + validate_scope(scope, allowed_scopes=('user', 'repo', 'all')) + if repo_path is None: + repo_path = Path(os.getcwd()) + ide_type = validate_and_parse_ide(ide) + + scopes_to_check = ['user', 'repo'] if scope == 'all' else [scope] + + for check_scope in scopes_to_check: + status = get_hooks_status(check_scope, repo_path if check_scope == 'repo' else None, ide=ide_type) + + console.print() + console.print(f'[bold]{check_scope.upper()} SCOPE[/]') + console.print(f'Path: {status["hooks_path"]}') + + if not status['file_exists']: + console.print('[dim]No hooks.json file found[/]') + continue + + if status['cycode_installed']: + console.print('[green]✓ Cycode AI guardrails: INSTALLED[/]') + else: + console.print('[yellow]○ Cycode AI guardrails: NOT INSTALLED[/]') + + # Show hook details + table = Table(show_header=True, header_style='bold') + table.add_column('Hook Event') + table.add_column('Cycode Enabled') + table.add_column('Total Hooks') + + for event, info in status['hooks'].items(): + enabled = '[green]Yes[/]' if info['enabled'] else '[dim]No[/]' + table.add_row(event, enabled, str(info['total_entries'])) + + console.print(table) + + console.print() diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py new file mode 100644 index 00000000..0a62d342 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -0,0 +1,73 @@ +"""Uninstall command for AI guardrails hooks.""" + +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from cycode.cli.apps.ai_guardrails.command_utils import ( + console, + resolve_repo_path, + validate_and_parse_ide, + validate_scope, +) +from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS +from cycode.cli.apps.ai_guardrails.hooks_manager import uninstall_hooks +from cycode.cli.utils.sentry import add_breadcrumb + + +def uninstall_command( + ctx: typer.Context, + scope: Annotated[ + str, + typer.Option( + '--scope', + '-s', + help='Uninstall scope: "user" for user-level hooks, "repo" for repository-level hooks.', + ), + ] = 'user', + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE to uninstall hooks from (e.g., "cursor"). Defaults to cursor.', + ), + ] = 'cursor', + repo_path: Annotated[ + Optional[Path], + typer.Option( + '--repo-path', + help='Repository path for repo-scoped uninstallation (defaults to current directory).', + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, +) -> None: + """Remove AI guardrails hooks from supported IDEs. + + This command removes Cycode hooks from the IDE's hooks configuration. + Other hooks (if any) will be preserved. + + Examples: + cycode ai-guardrails uninstall # Remove user-level hooks + cycode ai-guardrails uninstall --scope repo # Remove repo-level hooks + cycode ai-guardrails uninstall --ide cursor # Uninstall from Cursor IDE + """ + add_breadcrumb('ai-guardrails-uninstall') + + # Validate inputs + validate_scope(scope) + repo_path = resolve_repo_path(scope, repo_path) + ide_type = validate_and_parse_ide(ide) + ide_name = IDE_CONFIGS[ide_type].name + success, message = uninstall_hooks(scope, repo_path, ide=ide_type) + + if success: + console.print(f'[green]✓[/] {message}') + console.print() + console.print(f'[dim]Restart {ide_name} for changes to take effect.[/]') + else: + console.print(f'[red]✗[/] {message}', style='bold red') + raise typer.Exit(1) diff --git a/cycode/cli/apps/scan/__init__.py b/cycode/cli/apps/scan/__init__.py index 629c3b8f..d45f8d25 100644 --- a/cycode/cli/apps/scan/__init__.py +++ b/cycode/cli/apps/scan/__init__.py @@ -5,6 +5,7 @@ from cycode.cli.apps.scan.pre_commit.pre_commit_command import pre_commit_command from cycode.cli.apps.scan.pre_push.pre_push_command import pre_push_command from cycode.cli.apps.scan.pre_receive.pre_receive_command import pre_receive_command +from cycode.cli.apps.scan.prompt.prompt_command import prompt_command from cycode.cli.apps.scan.repository.repository_command import repository_command from cycode.cli.apps.scan.scan_command import scan_command, scan_command_result_callback @@ -43,6 +44,14 @@ rich_help_panel=_AUTOMATION_COMMANDS_RICH_HELP_PANEL, )(pre_receive_command) +_AI_GUARDRAILS_RICH_HELP_PANEL = 'AI Guardrails commands' + +app.command( + name='prompt', + short_help='Handle AI guardrails hooks from supported IDEs (reads JSON from stdin).', + rich_help_panel=_AI_GUARDRAILS_RICH_HELP_PANEL, +)(prompt_command) + # backward compatibility app.command(hidden=True, name='commit_history')(commit_history_command) app.command(hidden=True, name='pre_commit')(pre_commit_command) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 5b4c3e78..c15c9fe3 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -74,7 +74,7 @@ def _should_use_sync_flow(command_scan_type: str, scan_type: str, sync_option: b if not sync_option and scan_type != consts.IAC_SCAN_TYPE: return False - if command_scan_type not in {'path', 'repository'}: + if command_scan_type not in {'path', 'repository', 'prompt'}: return False if scan_type == consts.IAC_SCAN_TYPE: diff --git a/cycode/cli/apps/scan/prompt/__init__.py b/cycode/cli/apps/scan/prompt/__init__.py new file mode 100644 index 00000000..47349e78 --- /dev/null +++ b/cycode/cli/apps/scan/prompt/__init__.py @@ -0,0 +1 @@ +# Prompt scan command for AI guardrails (hooks) diff --git a/cycode/cli/apps/scan/prompt/consts.py b/cycode/cli/apps/scan/prompt/consts.py new file mode 100644 index 00000000..007892a8 --- /dev/null +++ b/cycode/cli/apps/scan/prompt/consts.py @@ -0,0 +1,48 @@ +""" +Constants and default configuration for AI guardrails. + +These defaults can be overridden by: +1. User-level config: ~/.cycode/ai-guardrails.yaml +2. Repo-level config: /.cycode/ai-guardrails.yaml +""" + +# Policy file name +POLICY_FILE_NAME = 'ai-guardrails.yaml' + +# Default policy configuration +DEFAULT_POLICY = { + 'version': 1, + 'mode': 'block', # block | warn + 'fail_open': True, # allow if scan fails/timeouts + 'secrets': { + 'scan_type': 'secret', + 'timeout_ms': 30000, + 'max_bytes': 200000, + }, + 'prompt': { + 'enabled': True, + 'action': 'block', + }, + 'file_read': { + 'enabled': True, + 'action': 'block', + 'deny_globs': [ + '.env', + '.env.*', + '*.pem', + '*.p12', + '*.key', + '.aws/**', + '.ssh/**', + '*kubeconfig*', + '.npmrc', + '.netrc', + ], + 'scan_content': True, + }, + 'mcp': { + 'enabled': True, + 'action': 'block', + 'scan_arguments': True, + }, +} diff --git a/cycode/cli/apps/scan/prompt/handlers.py b/cycode/cli/apps/scan/prompt/handlers.py new file mode 100644 index 00000000..b0d8b8fa --- /dev/null +++ b/cycode/cli/apps/scan/prompt/handlers.py @@ -0,0 +1,338 @@ +""" +Hook handlers for AI IDE events. + +Each handler receives a unified payload from an IDE, applies policy rules, +and returns a response that either allows or blocks the action. +""" + +import json +import os +from multiprocessing.pool import ThreadPool, TimeoutError as PoolTimeoutError +from typing import Optional + +import typer + +from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func +from cycode.cli.apps.scan.prompt.payload import AIHookPayload +from cycode.cli.apps.scan.prompt.policy import get_policy_value +from cycode.cli.apps.scan.prompt.response_builders import get_response_builder +from cycode.cli.apps.scan.prompt.types import AIHookOutcome, AiHookEventType, BlockReason +from cycode.cli.apps.scan.prompt.utils import ( + is_denied_path, + truncate_utf8, +) +from cycode.cli.apps.scan.scan_parameters import get_scan_parameters +from cycode.cli.models import Document +from cycode.cli.utils.progress_bar import DummyProgressBar, ScanProgressBarSection +from cycode.cli.utils.scan_utils import build_violation_summary +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails') + + +def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict: + """ + Handle beforeSubmitPrompt hook. + + Scans prompt text for secrets before it's sent to the AI model. + Returns {"continue": False} to block, {"continue": True} to allow. + """ + ai_client = ctx.obj['ai_security_client'] + ide = payload.ide_provider + response_builder = get_response_builder(ide) + + prompt_config = get_policy_value(policy, 'prompt', default={}) + ai_client.create_conversation(payload) + if not get_policy_value(prompt_config, 'enabled', default=True): + ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED) + return response_builder.allow_prompt() + + mode = get_policy_value(policy, 'mode', default='block') + prompt = payload.prompt or '' + max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) + timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) + clipped = truncate_utf8(prompt, max_bytes) + + scan_id = None + block_reason = None + outcome = AIHookOutcome.ALLOWED + + try: + violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) + + if violation_summary and get_policy_value(prompt_config, 'action', + default='block') == 'block' and mode == 'block': + outcome = AIHookOutcome.BLOCKED + block_reason = BlockReason.SECRETS_IN_PROMPT + user_message = f'{violation_summary}. Remove secrets before sending.' + response = response_builder.deny_prompt(user_message) + else: + if violation_summary: + outcome = AIHookOutcome.WARNED + response = response_builder.allow_prompt() + return response + except Exception as e: + outcome = AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', + default=True) else AIHookOutcome.BLOCKED + block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + raise e + finally: + ai_client.create_event( + payload, + AiHookEventType.PROMPT, + outcome, + scan_id=scan_id, + block_reason=block_reason, + ) + + +def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict: + """ + Handle beforeReadFile hook. + + Blocks sensitive files (via deny_globs) and scans file content for secrets. + Returns {"permission": "deny"} to block, {"permission": "allow"} to allow. + """ + ai_client = ctx.obj['ai_security_client'] + ide = payload.ide_provider + response_builder = get_response_builder(ide) + + file_read_config = get_policy_value(policy, 'file_read', default={}) + ai_client.create_conversation(payload) + if not get_policy_value(file_read_config, 'enabled', default=True): + ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED) + return response_builder.allow_permission() + + mode = get_policy_value(policy, 'mode', default='block') + file_path = payload.file_path or '' + action = get_policy_value(file_read_config, 'action', default='block') + + scan_id = None + block_reason = None + outcome = AIHookOutcome.ALLOWED + + try: + # Check path-based denylist first + if is_denied_path(file_path, policy) and action == 'block': + outcome = AIHookOutcome.BLOCKED + block_reason = BlockReason.SENSITIVE_PATH + user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).' + return response_builder.deny_permission( + user_message, + 'This file path is classified as sensitive; do not read/send it to the model.', + ) + + # Scan file content if enabled + if get_policy_value(file_read_config, 'scan_content', default=True): + violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy) + if violation_summary and action == 'block' and mode == 'block': + outcome = AIHookOutcome.BLOCKED + block_reason = BlockReason.SECRETS_IN_FILE + user_message = f'Cycode blocked reading {file_path}. {violation_summary}' + return response_builder.deny_permission( + user_message, + 'Secrets detected; do not send this file to the model.', + ) + else: + if violation_summary: + outcome = AIHookOutcome.WARNED + return response_builder.allow_permission() + + return response_builder.allow_permission() + except Exception as e: + outcome = AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', + default=True) else AIHookOutcome.BLOCKED + block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + raise e + finally: + ai_client.create_event( + payload, + AiHookEventType.FILE_READ, + outcome, + scan_id=scan_id, + block_reason=block_reason, + ) + + +def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict: + """ + Handle beforeMCPExecution hook. + + Scans tool arguments for secrets before MCP tool execution. + Returns {"permission": "deny"} to block, {"permission": "ask"} to warn, + {"permission": "allow"} to allow. + """ + ai_client = ctx.obj['ai_security_client'] + ide = payload.ide_provider + response_builder = get_response_builder(ide) + + mcp_config = get_policy_value(policy, 'mcp', default={}) + ai_client.create_conversation(payload) + if not get_policy_value(mcp_config, 'enabled', default=True): + ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED) + return response_builder.allow_permission() + + mode = get_policy_value(policy, 'mode', default='block') + tool = payload.mcp_tool_name or 'unknown' + args = payload.mcp_arguments or {} + args_text = args if isinstance(args, str) else json.dumps(args) + max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) + timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) + clipped = truncate_utf8(args_text, max_bytes) + action = get_policy_value(mcp_config, 'action', default='block') + + scan_id = None + block_reason = None + outcome = AIHookOutcome.ALLOWED + + try: + if get_policy_value(mcp_config, 'scan_arguments', default=True): + violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) + if violation_summary: + if mode == 'block' and action == 'block': + outcome = AIHookOutcome.BLOCKED + block_reason = BlockReason.SECRETS_IN_MCP_ARGS + user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}' + return response_builder.deny_permission( + user_message, + 'Do not pass secrets to tools. Use secret references (name/id) instead.', + ) + else: + outcome = AIHookOutcome.WARNED + return response_builder.ask_permission( + f'{violation_summary} in MCP tool call "{tool}". Allow execution?', + 'Possible secrets detected in tool arguments; proceed with caution.', + ) + + return response_builder.allow_permission() + except Exception as e: + outcome = AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', + default=True) else AIHookOutcome.BLOCKED + block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + raise e + finally: + ai_client.create_event( + payload, + AiHookEventType.MCP_EXECUTION, + outcome, + scan_id=scan_id, + block_reason=block_reason, + ) + + +def get_handler_for_event(event_type: str): + """Get the appropriate handler function for a canonical event type. + + Args: + event_type: Canonical event type string (from AiHookEventType enum) + + Returns: + Handler function or None if event type is not recognized + """ + handlers = { + AiHookEventType.PROMPT.value: handle_before_submit_prompt, + AiHookEventType.FILE_READ.value: handle_before_read_file, + AiHookEventType.MCP_EXECUTION.value: handle_before_mcp_execution, + } + return handlers.get(event_type) + + +def _setup_scan_context(ctx: typer.Context) -> typer.Context: + """Set up minimal context for scan_documents without progress bars or printing.""" + + # Set up minimal required context + ctx.obj['progress_bar'] = DummyProgressBar([ScanProgressBarSection]) + ctx.obj['sync'] = True # Synchronous scan + + # Set command name for scan logic + ctx.info_name = 'prompt' + + return ctx + + +def _perform_scan( + ctx: typer.Context, documents: list[Document], scan_parameters: dict, timeout_seconds: float +) -> tuple[Optional[str], Optional[str]]: + """ + Perform a scan on documents and extract results. + + Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean. + Raises exception if scan fails or times out (triggers fail_open policy). + """ + if not documents: + return None, None + + # Get the thread function for scanning + scan_batch_thread_func = _get_scan_documents_thread_func( + ctx, is_git_diff=False, is_commit_range=False, scan_parameters=scan_parameters + ) + + # Use ThreadPool.apply_async with timeout to abort if scan takes too long + # This uses the same ThreadPool mechanism as run_parallel_batched_scan but with timeout support + with ThreadPool(processes=1) as pool: + result = pool.apply_async(scan_batch_thread_func, (documents,)) + try: + scan_id, error, local_scan_result = result.get(timeout=timeout_seconds) + except PoolTimeoutError: + logger.debug(f'Scan timed out after {timeout_seconds} seconds') + raise RuntimeError(f'Scan timed out after {timeout_seconds} seconds') + + # Check if scan failed - raise exception to trigger fail_open policy + if error: + raise RuntimeError(error.message) + + if not local_scan_result: + return None, None + + scan_id = local_scan_result.scan_id + + # Check if there are any detections + if local_scan_result.detections_count > 0: + violation_summary = build_violation_summary([local_scan_result]) + return violation_summary, scan_id + + return None, scan_id + + +def _scan_text_for_secrets( + ctx: typer.Context, text: str, timeout_ms: int +) -> tuple[Optional[str], Optional[str]]: + """ + Scan text content for secrets using Cycode CLI. + + Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean. + Raises exception on error or timeout. + """ + if not text: + return None, None + + document = Document(path='prompt-content.txt', content=text, is_git_diff_format=False) + scan_ctx = _setup_scan_context(ctx) + timeout_seconds = timeout_ms / 1000.0 + return _perform_scan(scan_ctx, [document], get_scan_parameters(scan_ctx, None), timeout_seconds) + + +def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> tuple[Optional[str], Optional[str]]: + """ + Scan a file path for secrets. + + Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean. + Raises exception on error or timeout. + """ + if not file_path or not os.path.exists(file_path): + return None, None + + with open(file_path, 'r', encoding='utf-8', errors='replace') as f: + content = f.read() + + # Truncate content based on policy max_bytes + max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) + content = truncate_utf8(content, max_bytes) + + # Get timeout from policy + timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) + timeout_seconds = timeout_ms / 1000.0 + + document = Document(path=os.path.basename(file_path), content=content, is_git_diff_format=False) + scan_ctx = _setup_scan_context(ctx) + return _perform_scan(scan_ctx, [document], get_scan_parameters(scan_ctx, (file_path,)), timeout_seconds) diff --git a/cycode/cli/apps/scan/prompt/payload.py b/cycode/cli/apps/scan/prompt/payload.py new file mode 100644 index 00000000..36dc2779 --- /dev/null +++ b/cycode/cli/apps/scan/prompt/payload.py @@ -0,0 +1,71 @@ +"""Unified payload object for AI hook events from different tools.""" + +from dataclasses import dataclass +from typing import Optional + +from cycode.cli.apps.scan.prompt.types import CURSOR_EVENT_MAPPING + + +@dataclass +class AIHookPayload: + """Unified payload object that normalizes field names from different AI tools.""" + + # Event identification + event_name: str # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution') + conversation_id: Optional[str] = None + generation_id: Optional[str] = None + + # User and IDE information + ide_user_email: Optional[str] = None + model: Optional[str] = None + ide_provider: str = None # e.g., 'cursor', 'claude-code' + ide_version: Optional[str] = None + + # Event-specific data + prompt: Optional[str] = None # For prompt events + file_path: Optional[str] = None # For file_read events + mcp_tool_name: Optional[str] = None # For mcp_execution events + mcp_arguments: Optional[dict] = None # For mcp_execution events + + @classmethod + def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload': + """Create AIHookPayload from Cursor IDE payload. + + Maps Cursor-specific event names to canonical event types. + """ + cursor_event_name = payload.get('hook_event_name', '') + # Map Cursor event name to canonical type, fallback to original if not found + canonical_event = CURSOR_EVENT_MAPPING.get(cursor_event_name, cursor_event_name) + + return cls( + event_name=canonical_event, + conversation_id=payload.get('conversation_id'), + generation_id=payload.get('generation_id'), + ide_user_email=payload.get('user_email'), + model=payload.get('model'), + ide_provider='cursor', + ide_version=payload.get('cursor_version'), + prompt=payload.get('prompt', ''), + file_path=payload.get('file_path') or payload.get('path'), + mcp_tool_name=payload.get('tool_name') or payload.get('tool'), + mcp_arguments=payload.get('arguments') or payload.get('tool_input') or payload.get('input'), + ) + + @classmethod + def from_payload(cls, payload: dict, tool: str = 'cursor') -> 'AIHookPayload': + """Create AIHookPayload from any tool's payload. + + Args: + payload: The raw payload from the IDE + tool: The IDE/tool name (e.g., 'cursor') + + Returns: + AIHookPayload instance + + Raises: + ValueError: If the tool is not supported + """ + if tool == 'cursor': + return cls.from_cursor_payload(payload) + else: + raise ValueError(f'Unsupported IDE/tool: {tool}.') diff --git a/cycode/cli/apps/scan/prompt/policy.py b/cycode/cli/apps/scan/prompt/policy.py new file mode 100644 index 00000000..cbae2c2d --- /dev/null +++ b/cycode/cli/apps/scan/prompt/policy.py @@ -0,0 +1,85 @@ +""" +Policy loading and configuration management for AI guardrails. + +Policies are loaded and merged in order (later overrides earlier): +1. Built-in defaults (consts.DEFAULT_POLICY) +2. User-level config (~/.cycode/ai-guardrails.yaml) +3. Repo-level config (/.cycode/ai-guardrails.yaml) +""" + +import json +from pathlib import Path +from typing import Any, Optional + +import yaml + +from cycode.cli.apps.scan.prompt.consts import DEFAULT_POLICY, POLICY_FILE_NAME + + +def deep_merge(base: dict, override: dict) -> dict: + """Deep merge two dictionaries, with override taking precedence.""" + result = base.copy() + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = deep_merge(result[key], value) + else: + result[key] = value + return result + + +def load_yaml_file(path: Path) -> Optional[dict]: + """Load a YAML or JSON config file.""" + if not path.exists(): + return None + try: + content = path.read_text(encoding='utf-8') + if path.suffix in ('.yaml', '.yml'): + return yaml.safe_load(content) + return json.loads(content) + except Exception: + return None + + +def load_defaults() -> dict: + """Load built-in defaults.""" + return DEFAULT_POLICY.copy() + + +def get_policy_value(policy: dict, *keys: str, default: Any = None) -> Any: + """Get a nested value from the policy dict.""" + current = policy + for key in keys: + if not isinstance(current, dict): + return default + current = current.get(key) + if current is None: + return default + return current + + +def load_policy(workspace_root: Optional[str] = None) -> dict: + """ + Load policy by merging configs in order of precedence. + + Merge order: defaults <- user config <- repo config + + Args: + workspace_root: Workspace root path for repo-level config lookup. + """ + # Start with defaults + policy = load_defaults() + + # Merge user-level config (if exists) + user_policy_path = Path.home() / '.cycode' / POLICY_FILE_NAME + user_config = load_yaml_file(user_policy_path) + if user_config: + policy = deep_merge(policy, user_config) + + # Merge repo-level config (if exists) - highest precedence + if workspace_root: + repo_policy_path = Path(workspace_root) / '.cycode' / POLICY_FILE_NAME + repo_config = load_yaml_file(repo_policy_path) + if repo_config: + policy = deep_merge(policy, repo_config) + + return policy diff --git a/cycode/cli/apps/scan/prompt/prompt_command.py b/cycode/cli/apps/scan/prompt/prompt_command.py new file mode 100644 index 00000000..7ee493ed --- /dev/null +++ b/cycode/cli/apps/scan/prompt/prompt_command.py @@ -0,0 +1,113 @@ +""" +Prompt scan command for AI guardrails. + +This command handles AI IDE hooks by reading JSON from stdin and outputting +a JSON response to stdout. + +Supports multiple IDEs with different hook event types. The specific hook events +supported depend on the IDE being used (e.g., Cursor supports beforeSubmitPrompt, +beforeReadFile, beforeMCPExecution). +""" + +import sys +from typing import Annotated + +import typer + +from cycode.cli.apps.scan.prompt.handlers import get_handler_for_event +from cycode.cli.apps.scan.prompt.payload import AIHookPayload +from cycode.cli.apps.scan.prompt.policy import load_policy +from cycode.cli.apps.scan.prompt.response_builders import get_response_builder +from cycode.cli.apps.scan.prompt.types import AiHookEventType +from cycode.cli.apps.scan.prompt.utils import output_json, safe_json_parse +from cycode.cli.utils.get_api_client import get_ai_security_manager_client +from cycode.cli.utils.sentry import add_breadcrumb +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails') + + +def prompt_command( + ctx: typer.Context, + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.', + hidden=True, + ), + ] = 'cursor', +) -> None: + """Handle AI guardrails hooks from supported IDEs. + + This command reads a JSON payload from stdin containing hook event data + and outputs a JSON response to stdout indicating whether to allow or block the action. + + The hook event type is determined from the event field in the payload (field name + varies by IDE). Each IDE may support different hook events for scanning prompts, + file access, and tool executions. + + Example usage (from IDE hooks configuration): + { "command": "cycode scan prompt" } + """ + add_breadcrumb('prompt') + + # Initialize AI Security Manager client + ai_security_client = get_ai_security_manager_client(ctx) + ctx.obj['ai_security_client'] = ai_security_client + + # Read JSON payload from stdin + stdin_data = sys.stdin.read().strip() + payload = safe_json_parse(stdin_data) + + tool = ide.lower() + + # Get response builder for this IDE + response_builder = get_response_builder(tool) + + if not payload: + logger.debug('Empty or invalid JSON payload received') + output_json(response_builder.allow_prompt()) + return + + # Create unified payload object + unified_payload = AIHookPayload.from_payload(payload, tool=tool) + + # Extract event type from unified payload + event_name = unified_payload.event_name + logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'tool': tool}) + + # Load policy (merges defaults <- user config <- repo config) + # Extract first workspace root from payload if available + workspace_roots = payload.get('workspace_roots', ['.']) + policy = load_policy(workspace_roots[0]) + + # Get the appropriate handler for this event + handler = get_handler_for_event(event_name) + + if handler is None: + logger.debug('Unknown hook event, allowing by default', extra={'event_name': event_name}) + # Unknown event type - allow by default + output_json(response_builder.allow_prompt()) + return + + # Execute the handler and output the response + try: + response = handler(ctx, unified_payload, policy) + logger.debug('Hook handler completed', extra={'event_name': event_name, 'response': response}) + output_json(response) + except Exception as e: + logger.error('Hook handler failed', exc_info=e) + # Fail open by default + if policy.get('fail_open', True): + output_json(response_builder.allow_prompt()) + else: + # Fail closed + if event_name == AiHookEventType.PROMPT: + output_json( + response_builder.deny_prompt('Cycode guardrails error - blocking due to fail-closed policy')) + else: + output_json(response_builder.deny_permission( + 'Cycode guardrails error', + 'Blocking due to fail-closed policy' + )) diff --git a/cycode/cli/apps/scan/prompt/response_builders.py b/cycode/cli/apps/scan/prompt/response_builders.py new file mode 100644 index 00000000..3bc715cb --- /dev/null +++ b/cycode/cli/apps/scan/prompt/response_builders.py @@ -0,0 +1,91 @@ +""" +Response builders for different AI IDE hooks. + +Each IDE has its own response format for hooks. This module provides +an abstract interface and concrete implementations for each supported IDE. +""" + +from abc import ABC, abstractmethod + + +class IDEResponseBuilder(ABC): + """Abstract base class for IDE-specific response builders.""" + + @abstractmethod + def allow_permission(self) -> dict: + """Build response to allow file read or MCP execution.""" + pass + + @abstractmethod + def deny_permission(self, user_message: str, agent_message: str) -> dict: + """Build response to deny file read or MCP execution.""" + pass + + @abstractmethod + def ask_permission(self, user_message: str, agent_message: str) -> dict: + """Build response to ask user for permission (warn mode).""" + pass + + @abstractmethod + def allow_prompt(self) -> dict: + """Build response to allow prompt submission.""" + pass + + @abstractmethod + def deny_prompt(self, user_message: str) -> dict: + """Build response to deny prompt submission.""" + pass + + +class CursorResponseBuilder(IDEResponseBuilder): + """Response builder for Cursor IDE hooks. + + Cursor hook response formats: + - beforeSubmitPrompt: {"continue": bool, "user_message": str} + - beforeReadFile: {"permission": str, "user_message": str, "agent_message": str} + - beforeMCPExecution: {"permission": str, "user_message": str, "agent_message": str} + """ + + def allow_permission(self) -> dict: + """Allow file read or MCP execution.""" + return {'permission': 'allow'} + + def deny_permission(self, user_message: str, agent_message: str) -> dict: + """Deny file read or MCP execution.""" + return {'permission': 'deny', 'user_message': user_message, 'agent_message': agent_message} + + def ask_permission(self, user_message: str, agent_message: str) -> dict: + """Ask user for permission (warn mode).""" + return {'permission': 'ask', 'user_message': user_message, 'agent_message': agent_message} + + def allow_prompt(self) -> dict: + """Allow prompt submission.""" + return {'continue': True} + + def deny_prompt(self, user_message: str) -> dict: + """Deny prompt submission.""" + return {'continue': False, 'user_message': user_message} + + +# Registry of response builders by IDE name +_RESPONSE_BUILDERS: dict[str, IDEResponseBuilder] = { + 'cursor': CursorResponseBuilder(), +} + + +def get_response_builder(ide: str = 'cursor') -> IDEResponseBuilder: + """Get the response builder for a specific IDE. + + Args: + ide: The IDE name (e.g., 'cursor', 'claude-code') + + Returns: + IDEResponseBuilder instance for the specified IDE + + Raises: + ValueError: If the IDE is not supported + """ + builder = _RESPONSE_BUILDERS.get(ide.lower()) + if not builder: + raise ValueError(f'Unsupported IDE: {ide}. Supported IDEs: {list(_RESPONSE_BUILDERS.keys())}') + return builder diff --git a/cycode/cli/apps/scan/prompt/types.py b/cycode/cli/apps/scan/prompt/types.py new file mode 100644 index 00000000..8beb7ff8 --- /dev/null +++ b/cycode/cli/apps/scan/prompt/types.py @@ -0,0 +1,45 @@ +"""Type definitions for AI guardrails.""" + +from enum import StrEnum + + +class AiHookEventType(StrEnum): + """Canonical event types for AI guardrails. + + These are IDE-agnostic event types. Each IDE's specific event names + are mapped to these canonical types using the mapping dictionaries below. + """ + + PROMPT = 'prompt' + FILE_READ = 'file_read' + MCP_EXECUTION = 'mcp_execution' + + +# IDE-specific event name mappings to canonical types +CURSOR_EVENT_MAPPING = { + 'beforeSubmitPrompt': AiHookEventType.PROMPT, + 'beforeReadFile': AiHookEventType.FILE_READ, + 'beforeMCPExecution': AiHookEventType.MCP_EXECUTION, +} + + +class AIHookOutcome(StrEnum): + """Outcome of an AI hook event evaluation.""" + + ALLOWED = 'allowed' + BLOCKED = 'blocked' + WARNED = 'warned' + + +class BlockReason(StrEnum): + """Reason why an AI hook event was blocked. + + These are categorical reasons sent to the backend for tracking/analytics, + separate from the detailed user-facing messages. + """ + + SECRETS_IN_PROMPT = 'secrets_in_prompt' + SECRETS_IN_FILE = 'secrets_in_file' + SECRETS_IN_MCP_ARGS = 'secrets_in_mcp_args' + SENSITIVE_PATH = 'sensitive_path' + SCAN_FAILURE = 'scan_failure' diff --git a/cycode/cli/apps/scan/prompt/utils.py b/cycode/cli/apps/scan/prompt/utils.py new file mode 100644 index 00000000..21ff6518 --- /dev/null +++ b/cycode/cli/apps/scan/prompt/utils.py @@ -0,0 +1,72 @@ +""" +Utility functions for AI guardrails. + +Includes JSON parsing, path matching, and text handling utilities. +""" + +import json +import os +from pathlib import Path + +from cycode.cli.apps.scan.prompt.policy import get_policy_value + + +def safe_json_parse(s: str) -> dict: + """Parse JSON string, returning empty dict on failure.""" + try: + return json.loads(s) if s else {} + except (json.JSONDecodeError, TypeError): + return {} + + +def truncate_utf8(text: str, max_bytes: int) -> str: + """Truncate text to max bytes while preserving valid UTF-8.""" + if not text: + return '' + encoded = text.encode('utf-8') + if len(encoded) <= max_bytes: + return text + return encoded[:max_bytes].decode('utf-8', errors='ignore') + + +def normalize_path(file_path: str) -> str: + """Normalize path to prevent traversal attacks.""" + if not file_path: + return '' + normalized = os.path.normpath(file_path) + # Reject paths that attempt to escape outside bounds + if normalized.startswith('..'): + return '' + return normalized + + +def matches_glob(file_path: str, pattern: str) -> bool: + """Check if file path matches a glob pattern. + + Case-insensitive matching for cross-platform compatibility. + """ + normalized = normalize_path(file_path) + if not normalized or not pattern: + return False + + path = Path(normalized) + # Try case-sensitive first + if path.match(pattern): + return True + + # Then try case-insensitive by lowercasing both path and pattern + path_lower = Path(normalized.lower()) + return path_lower.match(pattern.lower()) + + +def is_denied_path(file_path: str, policy: dict) -> bool: + """Check if file path is in the denylist.""" + if not file_path: + return False + globs = get_policy_value(policy, 'file_read', 'deny_globs', default=[]) + return any(matches_glob(file_path, g) for g in globs) + + +def output_json(obj: dict) -> None: + """Write JSON response to stdout (for IDE to read).""" + print(json.dumps(obj), end='') diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index 63a1cb36..bd88faea 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -86,6 +86,10 @@ def get_member_color(name: str) -> str: def get_member_emoji(name: str) -> str: return _SEVERITY_EMOJIS.get(name.lower(), _SEVERITY_DEFAULT_EMOJI) + @staticmethod + def get_member_unicode_emoji(name: str) -> str: + return _SEVERITY_UNICODE_EMOJIS.get(name.lower(), _SEVERITY_DEFAULT_UNICODE_EMOJI) + def __rich__(self) -> str: color = self.get_member_color(self.value) return f'[{color}]{self.value.upper()}[/]' @@ -117,3 +121,12 @@ def __rich__(self) -> str: SeverityOption.HIGH.value: ':red_circle:', SeverityOption.CRITICAL.value: ':exclamation_mark:', # double_exclamation_mark is not red } + +_SEVERITY_DEFAULT_UNICODE_EMOJI = '⚪' +_SEVERITY_UNICODE_EMOJIS = { + SeverityOption.INFO.value: '🔵', + SeverityOption.LOW.value: '🟡', + SeverityOption.MEDIUM.value: '🟠', + SeverityOption.HIGH.value: '🔴', + SeverityOption.CRITICAL.value: '❗', +} diff --git a/cycode/cli/utils/get_api_client.py b/cycode/cli/utils/get_api_client.py index 5c712288..b69666d3 100644 --- a/cycode/cli/utils/get_api_client.py +++ b/cycode/cli/utils/get_api_client.py @@ -3,11 +3,17 @@ import click from cycode.cli.user_settings.credentials_manager import CredentialsManager -from cycode.cyclient.client_creator import create_import_sbom_client, create_report_client, create_scan_client +from cycode.cyclient.client_creator import ( + create_ai_security_manager_client, + create_import_sbom_client, + create_report_client, + create_scan_client, +) if TYPE_CHECKING: import typer + from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient from cycode.cyclient.import_sbom_client import ImportSbomClient from cycode.cyclient.report_client import ReportClient from cycode.cyclient.scan_client import ScanClient @@ -19,7 +25,7 @@ def _get_cycode_client( client_secret: Optional[str], hide_response_log: bool, id_token: Optional[str] = None, -) -> Union['ScanClient', 'ReportClient']: +) -> Union['ScanClient', 'ReportClient', 'ImportSbomClient', 'AISecurityManagerClient']: if client_id and id_token: return create_client_func(client_id, None, hide_response_log, id_token) @@ -62,6 +68,13 @@ def get_import_sbom_cycode_client(ctx: 'typer.Context', hide_response_log: bool return _get_cycode_client(create_import_sbom_client, client_id, client_secret, hide_response_log, id_token) +def get_ai_security_manager_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'AISecurityManagerClient': + client_id = ctx.obj.get('client_id') + client_secret = ctx.obj.get('client_secret') + id_token = ctx.obj.get('id_token') + return _get_cycode_client(create_ai_security_manager_client, client_id, client_secret, hide_response_log, id_token) + + def _get_configured_credentials() -> tuple[str, str]: credentials_manager = CredentialsManager() return credentials_manager.get_credentials() diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index 1332a7cf..be86716b 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -1,9 +1,12 @@ import os +from collections import defaultdict from typing import TYPE_CHECKING, Optional from uuid import UUID, uuid4 import typer +from cycode.cli.cli_types import SeverityOption + if TYPE_CHECKING: from cycode.cli.models import LocalScanResult from cycode.cyclient.models import ScanConfiguration @@ -33,3 +36,24 @@ def generate_unique_scan_id() -> UUID: return UUID(os.environ['PYTEST_TEST_UNIQUE_ID']) return uuid4() + + +def build_violation_summary(local_scan_results: list['LocalScanResult']) -> str: + """Build violation summary string with severity breakdown and emojis.""" + detections_count = 0 + severity_counts = defaultdict(int) + + for local_scan_result in local_scan_results: + for document_detections in local_scan_result.document_detections: + for detection in document_detections.detections: + if detection.severity: + detections_count += 1 + severity_counts[SeverityOption(detection.severity)] += 1 + + severity_parts = [] + for severity in reversed(SeverityOption): + emoji = SeverityOption.get_member_unicode_emoji(severity) + count = severity_counts[severity] + severity_parts.append(f'{emoji} {severity.upper()} - {count}') + + return f'Cycode found {detections_count} violations: {" | ".join(severity_parts)}' diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py new file mode 100644 index 00000000..d8b089cf --- /dev/null +++ b/cycode/cyclient/ai_security_manager_client.py @@ -0,0 +1,81 @@ +"""Client for AI Security Manager service.""" + +from typing import TYPE_CHECKING, Optional + +from cycode.cyclient.cycode_client_base import CycodeClientBase +from cycode.cyclient.logger import logger + +if TYPE_CHECKING: + from cycode.cli.apps.scan.prompt.payload import AIHookPayload + from cycode.cli.apps.scan.prompt.types import AIHookOutcome, AiHookEventType, BlockReason + from cycode.cyclient.ai_security_manager_service_config import AISecurityManagerServiceConfigBase + + +class AISecurityManagerClient: + """Client for interacting with AI Security Manager service.""" + + _CONVERSATIONS_PATH = 'v4/ai-security/interactions/conversations' + _EVENTS_PATH = 'v4/ai-security/interactions/events' + + def __init__(self, client: CycodeClientBase, service_config: 'AISecurityManagerServiceConfigBase') -> None: + self.client = client + self.service_config = service_config + + def _build_endpoint_path(self, path: str) -> str: + """Build the full endpoint path including service name/port.""" + service_name = self.service_config.get_service_name() + if service_name: + return f'{service_name}/{path}' + return path + + def create_conversation(self, payload: 'AIHookPayload') -> Optional[str]: + """Creates an AI conversation from hook payload.""" + conversation_id = payload.conversation_id + if not conversation_id: + return None + + body = { + 'id': conversation_id, + 'ide_user_email': payload.ide_user_email, + 'model': payload.model, + 'ide_provider': payload.ide_provider, + 'ide_version': payload.ide_version, + } + + try: + self.client.post(self._build_endpoint_path(self._CONVERSATIONS_PATH), body=body) + except Exception as e: + logger.debug('Failed to create conversation', exc_info=e) + # Don't fail the hook if tracking fails + + return conversation_id + + def create_event( + self, + payload: 'AIHookPayload', + event_type: 'AiHookEventType', + outcome: 'AIHookOutcome', + scan_id: Optional[str] = None, + block_reason: Optional['BlockReason'] = None, + ) -> None: + """Create an AI hook event from hook payload.""" + conversation_id = payload.conversation_id + if not conversation_id: + logger.debug('No conversation ID available, skipping event creation') + return + + body = { + 'conversation_id': conversation_id, + 'event_type': event_type, + 'outcome': outcome, + 'generation_id': payload.generation_id, + 'block_reason': block_reason, + 'cli_scan_id': scan_id, + 'mcp_tool_name': payload.mcp_tool_name, + } + + try: + self.client.post(self._build_endpoint_path(self._EVENTS_PATH), body=body) + except Exception as e: + logger.debug('Failed to create AI hook event', exc_info=e) + # Don't fail the hook if tracking fails diff --git a/cycode/cyclient/ai_security_manager_service_config.py b/cycode/cyclient/ai_security_manager_service_config.py new file mode 100644 index 00000000..60d7f2dd --- /dev/null +++ b/cycode/cyclient/ai_security_manager_service_config.py @@ -0,0 +1,27 @@ +"""Service configuration for AI Security Manager.""" + + +class AISecurityManagerServiceConfigBase: + """Base class for AI Security Manager service configuration.""" + + def get_service_name(self) -> str: + """Get the service name or port for URL construction. + + In dev mode, returns the port number. + In production, returns the service name. + """ + raise NotImplementedError + + +class DevAISecurityManagerServiceConfig(AISecurityManagerServiceConfigBase): + """Dev configuration for AI Security Manager.""" + + def get_service_name(self) -> str: + return '5163/api' + + +class DefaultAISecurityManagerServiceConfig(AISecurityManagerServiceConfigBase): + """Production configuration for AI Security Manager.""" + + def get_service_name(self) -> str: + return '' diff --git a/cycode/cyclient/client_creator.py b/cycode/cyclient/client_creator.py index 01ab6b59..fb563a0b 100644 --- a/cycode/cyclient/client_creator.py +++ b/cycode/cyclient/client_creator.py @@ -1,5 +1,10 @@ from typing import Optional +from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient +from cycode.cyclient.ai_security_manager_service_config import ( + DefaultAISecurityManagerServiceConfig, + DevAISecurityManagerServiceConfig, +) from cycode.cyclient.config import dev_mode from cycode.cyclient.config_dev import DEV_CYCODE_API_URL from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient @@ -49,3 +54,18 @@ def create_import_sbom_client( else: client = CycodeTokenBasedClient(client_id, client_secret) return ImportSbomClient(client) + + +def create_ai_security_manager_client( + client_id: str, client_secret: Optional[str] = None, _: bool = False, id_token: Optional[str] = None +) -> AISecurityManagerClient: + if dev_mode: + client = CycodeDevBasedClient(DEV_CYCODE_API_URL) + service_config = DevAISecurityManagerServiceConfig() + else: + if id_token: + client = CycodeOidcBasedClient(client_id, id_token) + else: + client = CycodeTokenBasedClient(client_id, client_secret) + service_config = DefaultAISecurityManagerServiceConfig() + return AISecurityManagerClient(client, service_config) diff --git a/tests/cli/commands/ai_guardrails/__init__.py b/tests/cli/commands/ai_guardrails/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/commands/ai_guardrails/test_command_utils.py b/tests/cli/commands/ai_guardrails/test_command_utils.py new file mode 100644 index 00000000..4f0ef55e --- /dev/null +++ b/tests/cli/commands/ai_guardrails/test_command_utils.py @@ -0,0 +1,57 @@ +"""Tests for AI guardrails command utilities.""" + +import pytest +import typer + +from cycode.cli.apps.ai_guardrails.command_utils import ( + validate_and_parse_ide, + validate_scope, +) +from cycode.cli.apps.ai_guardrails.consts import AIIDEType + + +def test_validate_and_parse_ide_valid() -> None: + """Test parsing valid IDE names.""" + assert validate_and_parse_ide('cursor') == AIIDEType.CURSOR + assert validate_and_parse_ide('CURSOR') == AIIDEType.CURSOR + assert validate_and_parse_ide('CuRsOr') == AIIDEType.CURSOR + + +def test_validate_and_parse_ide_invalid() -> None: + """Test that invalid IDE raises typer.Exit.""" + with pytest.raises(typer.Exit) as exc_info: + validate_and_parse_ide('invalid_ide') + assert exc_info.value.exit_code == 1 + + +def test_validate_scope_valid_default() -> None: + """Test validating valid scope with default allowed scopes.""" + # Should not raise any exception + validate_scope('user') + validate_scope('repo') + + +def test_validate_scope_invalid_default() -> None: + """Test that invalid scope raises typer.Exit with default allowed scopes.""" + with pytest.raises(typer.Exit) as exc_info: + validate_scope('invalid') + assert exc_info.value.exit_code == 1 + + with pytest.raises(typer.Exit) as exc_info: + validate_scope('all') # 'all' not in default allowed scopes + assert exc_info.value.exit_code == 1 + + +def test_validate_scope_valid_custom() -> None: + """Test validating scope with custom allowed scopes.""" + # Should not raise any exception + validate_scope('user', allowed_scopes=('user', 'repo', 'all')) + validate_scope('repo', allowed_scopes=('user', 'repo', 'all')) + validate_scope('all', allowed_scopes=('user', 'repo', 'all')) + + +def test_validate_scope_invalid_custom() -> None: + """Test that invalid scope raises typer.Exit with custom allowed scopes.""" + with pytest.raises(typer.Exit) as exc_info: + validate_scope('invalid', allowed_scopes=('user', 'repo', 'all')) + assert exc_info.value.exit_code == 1 diff --git a/tests/cli/commands/scan/prompt/__init__.py b/tests/cli/commands/scan/prompt/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/commands/scan/prompt/test_handlers.py b/tests/cli/commands/scan/prompt/test_handlers.py new file mode 100644 index 00000000..a4fcf5b7 --- /dev/null +++ b/tests/cli/commands/scan/prompt/test_handlers.py @@ -0,0 +1,352 @@ +"""Tests for AI guardrails handlers.""" + +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.apps.scan.prompt.handlers import ( + handle_before_mcp_execution, + handle_before_read_file, + handle_before_submit_prompt, +) +from cycode.cli.apps.scan.prompt.payload import AIHookPayload +from cycode.cli.apps.scan.prompt.types import AIHookOutcome, BlockReason + + +@pytest.fixture +def mock_ctx(): + """Create a mock Typer context.""" + ctx = MagicMock(spec=typer.Context) + ctx.obj = { + 'ai_security_client': MagicMock(), + 'scan_type': 'secret', + } + return ctx + + +@pytest.fixture +def mock_payload(): + """Create a mock AIHookPayload.""" + return AIHookPayload( + event_name='prompt', + conversation_id='test-conv-id', + generation_id='test-gen-id', + ide_user_email='test@example.com', + model='gpt-4', + ide_provider='cursor', + ide_version='1.0.0', + prompt='Test prompt', + ) + + +@pytest.fixture +def default_policy(): + """Create a default policy dict.""" + return { + 'mode': 'block', + 'fail_open': True, + 'secrets': {'max_bytes': 200000}, + 'prompt': {'enabled': True, 'action': 'block'}, + 'file_read': {'enabled': True, 'action': 'block', 'scan_content': True, 'deny_globs': []}, + 'mcp': {'enabled': True, 'action': 'block', 'scan_arguments': True}, + } + + +# Tests for handle_before_submit_prompt + + +def test_handle_before_submit_prompt_disabled(mock_ctx, mock_payload, default_policy) -> None: + """Test that disabled prompt scanning allows the prompt.""" + default_policy['prompt']['enabled'] = False + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result == {'continue': True} + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + + +@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_no_secrets( + mock_scan, mock_ctx, mock_payload, default_policy +) -> None: + """Test that prompt with no secrets is allowed.""" + mock_scan.return_value = (None, 'scan-id-123') + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result == {'continue': True} + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + # outcome is arg[2], scan_id and block_reason are kwargs + assert call_args.args[2] == AIHookOutcome.ALLOWED + assert call_args.kwargs['scan_id'] == 'scan-id-123' + assert call_args.kwargs['block_reason'] is None + + +@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_with_secrets_blocked( + mock_scan, mock_ctx, mock_payload, default_policy +) -> None: + """Test that prompt with secrets is blocked.""" + mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-456') + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result['continue'] is False + assert 'Found 1 secret: API key' in result['user_message'] + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_PROMPT + + +@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_with_secrets_warned( + mock_scan, mock_ctx, mock_payload, default_policy +) -> None: + """Test that prompt with secrets in warn mode is allowed.""" + default_policy['prompt']['action'] = 'warn' + mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-789') + + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert result == {'continue': True} + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.WARNED + + +@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_scan_failure_fail_open( + mock_scan, mock_ctx, mock_payload, default_policy +) -> None: + """Test that scan failure with fail_open=True allows the prompt.""" + mock_scan.side_effect = RuntimeError('Scan failed') + default_policy['fail_open'] = True + + with pytest.raises(RuntimeError): + handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + # Event should be tracked even on exception + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.ALLOWED + # When fail_open=True, no block_reason since action is allowed + assert call_args.kwargs['block_reason'] is None + + +@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_scan_failure_fail_closed( + mock_scan, mock_ctx, mock_payload, default_policy +) -> None: + """Test that scan failure with fail_open=False blocks the prompt.""" + mock_scan.side_effect = RuntimeError('Scan failed') + default_policy['fail_open'] = False + + with pytest.raises(RuntimeError): + handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + # Event should be tracked even on exception + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SCAN_FAILURE + + +# Tests for handle_before_read_file + + +def test_handle_before_read_file_disabled(mock_ctx, default_policy) -> None: + """Test that disabled file read scanning allows the file.""" + default_policy['file_read']['enabled'] = False + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + + +@patch('cycode.cli.apps.scan.prompt.handlers.is_denied_path') +def test_handle_before_read_file_sensitive_path(mock_is_denied, mock_ctx, default_policy) -> None: + """Test that sensitive path is blocked.""" + mock_is_denied.return_value = True + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result['permission'] == 'deny' + assert '.env' in result['user_message'] + mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + + +@patch('cycode.cli.apps.scan.prompt.handlers.is_denied_path') +@patch('cycode.cli.apps.scan.prompt.handlers._scan_path_for_secrets') +def test_handle_before_read_file_no_secrets( + mock_scan, mock_is_denied, mock_ctx, default_policy +) -> None: + """Test that file with no secrets is allowed.""" + mock_is_denied.return_value = False + mock_scan.return_value = (None, 'scan-id-123') + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.ALLOWED + + +@patch('cycode.cli.apps.scan.prompt.handlers.is_denied_path') +@patch('cycode.cli.apps.scan.prompt.handlers._scan_path_for_secrets') +def test_handle_before_read_file_with_secrets( + mock_scan, mock_is_denied, mock_ctx, default_policy +) -> None: + """Test that file with secrets is blocked.""" + mock_is_denied.return_value = False + mock_scan.return_value = ('Found 1 secret: password', 'scan-id-456') + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result['permission'] == 'deny' + assert 'Found 1 secret: password' in result['user_message'] + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_FILE + + +@patch('cycode.cli.apps.scan.prompt.handlers.is_denied_path') +@patch('cycode.cli.apps.scan.prompt.handlers._scan_path_for_secrets') +def test_handle_before_read_file_scan_disabled( + mock_scan, mock_is_denied, mock_ctx, default_policy +) -> None: + """Test that file is allowed when content scanning is disabled.""" + mock_is_denied.return_value = False + default_policy['file_read']['scan_content'] = False + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/file.txt', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + mock_scan.assert_not_called() + + +# Tests for handle_before_mcp_execution + + +def test_handle_before_mcp_execution_disabled(mock_ctx, default_policy) -> None: + """Test that disabled MCP scanning allows the execution.""" + default_policy['mcp']['enabled'] = False + payload = AIHookPayload( + event_name='mcp_execution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'value1'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + + +@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_no_secrets(mock_scan, mock_ctx, default_policy) -> None: + """Test that MCP execution with no secrets is allowed.""" + mock_scan.return_value = (None, 'scan-id-123') + payload = AIHookPayload( + event_name='mcp_execution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'value1'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.ALLOWED + + +@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_with_secrets_blocked( + mock_scan, mock_ctx, default_policy +) -> None: + """Test that MCP execution with secrets is blocked.""" + mock_scan.return_value = ('Found 1 secret: token', 'scan-id-456') + payload = AIHookPayload( + event_name='mcp_execution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'secret_token_12345'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result['permission'] == 'deny' + assert 'Found 1 secret: token' in result['user_message'] + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.BLOCKED + assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_MCP_ARGS + + +@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_with_secrets_warned( + mock_scan, mock_ctx, default_policy +) -> None: + """Test that MCP execution with secrets in warn mode asks permission.""" + mock_scan.return_value = ('Found 1 secret: token', 'scan-id-789') + default_policy['mcp']['action'] = 'warn' + payload = AIHookPayload( + event_name='mcp_execution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'secret_token_12345'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result['permission'] == 'ask' + assert 'Found 1 secret: token' in result['user_message'] + call_args = mock_ctx.obj['ai_security_client'].create_event.call_args + assert call_args.args[2] == AIHookOutcome.WARNED + + +@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_scan_disabled(mock_scan, mock_ctx, default_policy) -> None: + """Test that MCP execution is allowed when argument scanning is disabled.""" + default_policy['mcp']['scan_arguments'] = False + payload = AIHookPayload( + event_name='mcp_execution', + ide_provider='cursor', + mcp_tool_name='test_tool', + mcp_arguments={'arg1': 'value1'}, + ) + + result = handle_before_mcp_execution(mock_ctx, payload, default_policy) + + assert result == {'permission': 'allow'} + mock_scan.assert_not_called() diff --git a/tests/cli/commands/scan/prompt/test_payload.py b/tests/cli/commands/scan/prompt/test_payload.py new file mode 100644 index 00000000..a0d9bd12 --- /dev/null +++ b/tests/cli/commands/scan/prompt/test_payload.py @@ -0,0 +1,129 @@ +"""Tests for AI hook payload normalization.""" + +import pytest + +from cycode.cli.apps.scan.prompt.payload import AIHookPayload +from cycode.cli.apps.scan.prompt.types import AiHookEventType + + +def test_from_cursor_payload_prompt_event() -> None: + """Test conversion of Cursor beforeSubmitPrompt payload.""" + cursor_payload = { + 'hook_event_name': 'beforeSubmitPrompt', + 'conversation_id': 'conv-123', + 'generation_id': 'gen-456', + 'user_email': 'user@example.com', + 'model': 'gpt-4', + 'cursor_version': '0.42.0', + 'prompt': 'Test prompt', + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == 'conv-123' + assert unified.generation_id == 'gen-456' + assert unified.ide_user_email == 'user@example.com' + assert unified.model == 'gpt-4' + assert unified.ide_provider == 'cursor' + assert unified.ide_version == '0.42.0' + assert unified.prompt == 'Test prompt' + + +def test_from_cursor_payload_file_read_event() -> None: + """Test conversion of Cursor beforeReadFile payload.""" + cursor_payload = { + 'hook_event_name': 'beforeReadFile', + 'conversation_id': 'conv-123', + 'file_path': '/path/to/secret.env', + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/path/to/secret.env' + assert unified.ide_provider == 'cursor' + + +def test_from_cursor_payload_mcp_execution_event() -> None: + """Test conversion of Cursor beforeMCPExecution payload.""" + cursor_payload = { + 'hook_event_name': 'beforeMCPExecution', + 'conversation_id': 'conv-123', + 'tool_name': 'execute_command', + 'arguments': {'command': 'ls -la', 'secret': 'password123'}, + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_tool_name == 'execute_command' + assert unified.mcp_arguments == {'command': 'ls -la', 'secret': 'password123'} + + +def test_from_cursor_payload_with_alternative_field_names() -> None: + """Test that alternative field names are handled (path vs file_path, etc.).""" + cursor_payload = { + 'hook_event_name': 'beforeReadFile', + 'path': '/alternative/path.txt', # Alternative to file_path + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + assert unified.file_path == '/alternative/path.txt' + + cursor_payload = { + 'hook_event_name': 'beforeMCPExecution', + 'tool': 'my_tool', # Alternative to tool_name + 'tool_input': {'key': 'value'}, # Alternative to arguments + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + assert unified.mcp_tool_name == 'my_tool' + assert unified.mcp_arguments == {'key': 'value'} + + +def test_from_cursor_payload_unknown_event() -> None: + """Test that unknown event names are passed through as-is.""" + cursor_payload = { + 'hook_event_name': 'unknownEvent', + 'conversation_id': 'conv-123', + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + # Unknown events fall back to original name + assert unified.event_name == 'unknownEvent' + + +def test_from_payload_cursor() -> None: + """Test from_payload dispatcher with Cursor tool.""" + cursor_payload = { + 'hook_event_name': 'beforeSubmitPrompt', + 'prompt': 'test', + } + + unified = AIHookPayload.from_payload(cursor_payload, tool='cursor') + assert unified.event_name == AiHookEventType.PROMPT + assert unified.ide_provider == 'cursor' + + +def test_from_payload_unsupported_tool() -> None: + """Test from_payload raises ValueError for unsupported tools.""" + payload = {'hook_event_name': 'someEvent'} + + with pytest.raises(ValueError, match='Unsupported IDE/tool: unsupported'): + AIHookPayload.from_payload(payload, tool='unsupported') + + +def test_from_cursor_payload_empty_fields() -> None: + """Test handling of empty/missing fields.""" + cursor_payload = { + 'hook_event_name': 'beforeSubmitPrompt', + # Most fields missing + } + + unified = AIHookPayload.from_cursor_payload(cursor_payload) + + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id is None + assert unified.prompt == '' # Default to empty string + assert unified.ide_provider == 'cursor' diff --git a/tests/cli/commands/scan/prompt/test_policy.py b/tests/cli/commands/scan/prompt/test_policy.py new file mode 100644 index 00000000..35a8cd2e --- /dev/null +++ b/tests/cli/commands/scan/prompt/test_policy.py @@ -0,0 +1,215 @@ +"""Tests for AI guardrails policy loading and management.""" + +from pathlib import Path +from unittest.mock import patch + +from cycode.cli.apps.scan.prompt.policy import ( + deep_merge, + get_policy_value, + load_defaults, + load_policy, + load_yaml_file, +) + + +def test_deep_merge_simple() -> None: + """Test deep merging two simple dictionaries.""" + base = {'a': 1, 'b': 2} + override = {'b': 3, 'c': 4} + result = deep_merge(base, override) + + assert result == {'a': 1, 'b': 3, 'c': 4} + + +def test_deep_merge_nested() -> None: + """Test deep merging nested dictionaries.""" + base = {'level1': {'level2': {'key1': 'value1', 'key2': 'value2'}}} + override = {'level1': {'level2': {'key2': 'override2', 'key3': 'value3'}}} + result = deep_merge(base, override) + + assert result == { + 'level1': {'level2': {'key1': 'value1', 'key2': 'override2', 'key3': 'value3'}} + } + + +def test_deep_merge_override_with_non_dict() -> None: + """Test that non-dict overrides replace the base value entirely.""" + base = {'key': {'nested': 'value'}} + override = {'key': 'simple_value'} + result = deep_merge(base, override) + + assert result == {'key': 'simple_value'} + + +def test_load_yaml_file_nonexistent(tmp_path: Path) -> None: + """Test loading a non-existent file returns None.""" + result = load_yaml_file(tmp_path / 'nonexistent.yaml') + assert result is None + + +def test_load_yaml_file_valid_yaml(tmp_path: Path) -> None: + """Test loading a valid YAML file.""" + yaml_file = tmp_path / 'config.yaml' + yaml_file.write_text('mode: block\nfail_open: true\n') + + result = load_yaml_file(yaml_file) + assert result == {'mode': 'block', 'fail_open': True} + + +def test_load_yaml_file_valid_json(tmp_path: Path) -> None: + """Test loading a valid JSON file.""" + json_file = tmp_path / 'config.json' + json_file.write_text('{"mode": "block", "fail_open": true}') + + result = load_yaml_file(json_file) + assert result == {'mode': 'block', 'fail_open': True} + + +def test_load_yaml_file_invalid_yaml(tmp_path: Path) -> None: + """Test loading an invalid YAML file returns None.""" + yaml_file = tmp_path / 'invalid.yaml' + yaml_file.write_text('{ invalid yaml content [') + + result = load_yaml_file(yaml_file) + assert result is None + + +def test_load_defaults() -> None: + """Test that load_defaults returns a dict with expected keys.""" + defaults = load_defaults() + + assert isinstance(defaults, dict) + assert 'mode' in defaults + assert 'fail_open' in defaults + assert 'prompt' in defaults + assert 'file_read' in defaults + assert 'mcp' in defaults + + +def test_get_policy_value_single_key() -> None: + """Test getting a single-level value.""" + policy = {'mode': 'block', 'fail_open': True} + + assert get_policy_value(policy, 'mode') == 'block' + assert get_policy_value(policy, 'fail_open') is True + + +def test_get_policy_value_nested_keys() -> None: + """Test getting a nested value.""" + policy = {'prompt': {'enabled': True, 'action': 'block'}} + + assert get_policy_value(policy, 'prompt', 'enabled') is True + assert get_policy_value(policy, 'prompt', 'action') == 'block' + + +def test_get_policy_value_missing_key() -> None: + """Test that missing keys return the default value.""" + policy = {'mode': 'block'} + + assert get_policy_value(policy, 'nonexistent', default='default_value') == 'default_value' + + +def test_get_policy_value_deeply_nested() -> None: + """Test getting deeply nested values.""" + policy = {'level1': {'level2': {'level3': 'value'}}} + + assert get_policy_value(policy, 'level1', 'level2', 'level3') == 'value' + assert get_policy_value(policy, 'level1', 'level2', 'missing', default='def') == 'def' + + +def test_get_policy_value_non_dict_in_path() -> None: + """Test that non-dict values in path return default.""" + policy = {'key': 'string_value'} + + # Trying to access nested key on non-dict should return default + assert get_policy_value(policy, 'key', 'nested', default='default') == 'default' + + +def test_load_policy_defaults_only() -> None: + """Test loading policy with only defaults (no user or repo config).""" + with patch('cycode.cli.apps.scan.prompt.policy.load_yaml_file') as mock_load: + mock_load.return_value = None # No user or repo config + + policy = load_policy() + + assert 'mode' in policy + assert 'fail_open' in policy + + +def test_load_policy_with_user_config(tmp_path: Path) -> None: + """Test loading policy with user config override.""" + with patch('pathlib.Path.home') as mock_home: + mock_home.return_value = tmp_path + + # Create user config + user_config_dir = tmp_path / '.cycode' + user_config_dir.mkdir() + user_config = user_config_dir / 'ai-guardrails.yaml' + user_config.write_text('mode: warn\nfail_open: false\n') + + policy = load_policy() + + # User config should override defaults + assert policy['mode'] == 'warn' + assert policy['fail_open'] is False + + +def test_load_policy_with_repo_config(tmp_path: Path) -> None: + """Test loading policy with repo config (highest precedence).""" + # Create repo config + repo_config_dir = tmp_path / '.cycode' + repo_config_dir.mkdir() + repo_config = repo_config_dir / 'ai-guardrails.yaml' + repo_config.write_text('mode: block\nprompt:\n enabled: false\n') + + with patch('cycode.cli.apps.scan.prompt.policy.load_yaml_file') as mock_load: + def side_effect(path: Path): + if path == repo_config: + return {'mode': 'block', 'prompt': {'enabled': False}} + return None + + mock_load.side_effect = side_effect + + policy = load_policy(str(tmp_path)) + + # Repo config should have highest precedence + assert policy['mode'] == 'block' + assert policy['prompt']['enabled'] is False + + +def test_load_policy_precedence(tmp_path: Path) -> None: + """Test that policy precedence is: defaults < user < repo.""" + with patch('pathlib.Path.home') as mock_home: + mock_home.return_value = tmp_path + + # Create user config + user_config_dir = tmp_path / '.cycode' + user_config_dir.mkdir() + user_config = user_config_dir / 'ai-guardrails.yaml' + user_config.write_text('mode: warn\nfail_open: false\n') + + # Create repo config in a different location + repo_path = tmp_path / 'repo' + repo_path.mkdir() + repo_config_dir = repo_path / '.cycode' + repo_config_dir.mkdir() + repo_config = repo_config_dir / 'ai-guardrails.yaml' + repo_config.write_text('mode: block\n') # Override mode but not fail_open + + policy = load_policy(str(repo_path)) + + # mode should come from repo (highest precedence) + assert policy['mode'] == 'block' + # fail_open should come from user config (repo doesn't override it) + assert policy['fail_open'] is False + + +def test_load_policy_none_workspace_root() -> None: + """Test that None workspace_root is handled correctly.""" + with patch('cycode.cli.apps.scan.prompt.policy.load_yaml_file') as mock_load: + mock_load.return_value = None + + policy = load_policy(None) + + # Should only load defaults (no repo config) + assert 'mode' in policy diff --git a/tests/cli/commands/scan/prompt/test_response_builders.py b/tests/cli/commands/scan/prompt/test_response_builders.py new file mode 100644 index 00000000..74071e11 --- /dev/null +++ b/tests/cli/commands/scan/prompt/test_response_builders.py @@ -0,0 +1,79 @@ +"""Tests for IDE response builders.""" + +import pytest + +from cycode.cli.apps.scan.prompt.response_builders import ( + CursorResponseBuilder, + IDEResponseBuilder, + get_response_builder, +) + + +def test_cursor_response_builder_allow_permission() -> None: + """Test Cursor allow permission response.""" + builder = CursorResponseBuilder() + response = builder.allow_permission() + + assert response == {'permission': 'allow'} + + +def test_cursor_response_builder_deny_permission() -> None: + """Test Cursor deny permission response with messages.""" + builder = CursorResponseBuilder() + response = builder.deny_permission('User message', 'Agent message') + + assert response == { + 'permission': 'deny', + 'user_message': 'User message', + 'agent_message': 'Agent message', + } + + +def test_cursor_response_builder_ask_permission() -> None: + """Test Cursor ask permission response for warnings.""" + builder = CursorResponseBuilder() + response = builder.ask_permission('Warning message', 'Agent warning') + + assert response == { + 'permission': 'ask', + 'user_message': 'Warning message', + 'agent_message': 'Agent warning', + } + + +def test_cursor_response_builder_allow_prompt() -> None: + """Test Cursor allow prompt response.""" + builder = CursorResponseBuilder() + response = builder.allow_prompt() + + assert response == {'continue': True} + + +def test_cursor_response_builder_deny_prompt() -> None: + """Test Cursor deny prompt response with message.""" + builder = CursorResponseBuilder() + response = builder.deny_prompt('Secrets detected') + + assert response == {'continue': False, 'user_message': 'Secrets detected'} + + +def test_get_response_builder_cursor() -> None: + """Test getting Cursor response builder.""" + builder = get_response_builder('cursor') + + assert isinstance(builder, CursorResponseBuilder) + assert isinstance(builder, IDEResponseBuilder) + + +def test_get_response_builder_unsupported() -> None: + """Test that unsupported IDE raises ValueError.""" + with pytest.raises(ValueError, match='Unsupported IDE: unknown'): + get_response_builder('unknown') + + +def test_cursor_response_builder_is_singleton() -> None: + """Test that getting the same builder returns the same instance.""" + builder1 = get_response_builder('cursor') + builder2 = get_response_builder('cursor') + + assert builder1 is builder2 diff --git a/tests/cli/commands/scan/prompt/test_utils.py b/tests/cli/commands/scan/prompt/test_utils.py new file mode 100644 index 00000000..61ce2eb8 --- /dev/null +++ b/tests/cli/commands/scan/prompt/test_utils.py @@ -0,0 +1,129 @@ +"""Tests for AI guardrails utility functions.""" + +from cycode.cli.apps.scan.prompt.utils import ( + is_denied_path, + matches_glob, + normalize_path, +) + + +def test_normalize_path_basic() -> None: + """Test basic path normalization.""" + path = '/path/to/file.txt' + result = normalize_path(path) + + assert result == '/path/to/file.txt' + + +def test_normalize_path_with_dots() -> None: + """Test normalizing path with . and .. segments.""" + path = '/path/./to/../file.txt' + result = normalize_path(path) + + assert result == '/path/file.txt' + + +def test_normalize_path_rejects_escape() -> None: + """Test that paths attempting to escape are rejected.""" + path = '../../../etc/passwd' + result = normalize_path(path) + + assert result == '' + + +def test_normalize_path_empty() -> None: + """Test normalizing empty path.""" + result = normalize_path('') + + assert result == '' + + +def test_matches_glob_simple() -> None: + """Test simple glob pattern matching.""" + assert matches_glob('secret.env', '*.env') is True + assert matches_glob('secret.txt', '*.env') is False + + +def test_matches_glob_recursive() -> None: + """Test recursive glob pattern with **.""" + assert matches_glob('path/to/secret.env', '**/*.env') is True + # Note: '**/*.env' requires at least one path separator, so 'secret.env' won't match + assert matches_glob('secret.env', '*.env') is True # Use non-recursive pattern instead + assert matches_glob('path/to/file.txt', '**/*.env') is False + + +def test_matches_glob_directory() -> None: + """Test matching files in specific directories.""" + assert matches_glob('.env', '.env') is True + assert matches_glob('config/.env', '**/.env') is True + assert matches_glob('other/file', '**/.env') is False + + +def test_matches_glob_case_insensitive() -> None: + """Test that glob matching handles case variations.""" + # Case-insensitive matching for cross-platform compatibility + assert matches_glob('secret.env', '*.env') is True + assert matches_glob('SECRET.ENV', '*.env') is True # Uppercase path matches lowercase pattern + assert matches_glob('Secret.Env', '*.env') is True # Mixed case matches + assert matches_glob('secret.env', '*.ENV') is True # Lowercase path matches uppercase pattern + assert matches_glob('SECRET.ENV', '*.ENV') is True # Both uppercase match + + +def test_matches_glob_empty_inputs() -> None: + """Test glob matching with empty inputs.""" + assert matches_glob('', '*.env') is False + assert matches_glob('file.env', '') is False + assert matches_glob('', '') is False + + +def test_matches_glob_with_traversal_attempt() -> None: + """Test that path traversal is normalized before matching.""" + # Path traversal attempts should be normalized + assert matches_glob('../secret.env', '*.env') is False + + +def test_is_denied_path_with_deny_globs() -> None: + """Test path denial with deny_globs policy.""" + policy = {'file_read': {'deny_globs': ['*.env', '.git/*', '**/secrets/*']}} + + assert is_denied_path('.env', policy) is True + # Note: Path.match('*.env') matches paths ending with .env, including nested paths + assert is_denied_path('config/.env', policy) is True # Matches *.env + assert is_denied_path('.git/config', policy) is True # Matches .git/* + assert is_denied_path('app/secrets/api_keys.txt', policy) is True # Matches **/secrets/* + assert is_denied_path('app/config.yaml', policy) is False + + +def test_is_denied_path_nested_patterns() -> None: + """Test denial with various nesting patterns.""" + policy = {'file_read': {'deny_globs': ['*.key', '**/*.key', 'config/*.env']}} + + # *.key matches .key files at root level, **/*.key for nested + assert is_denied_path('private.key', policy) is True + assert is_denied_path('app/private.key', policy) is True + # config/*.env only matches .env files directly in config/ + assert is_denied_path('config/app.env', policy) is True + assert is_denied_path('config/sub/app.env', policy) is False # Not direct child + assert is_denied_path('app/config.yaml', policy) is False + + +def test_is_denied_path_empty_globs() -> None: + """Test that empty deny_globs list denies nothing.""" + policy = {'file_read': {'deny_globs': []}} + + assert is_denied_path('.env', policy) is False + assert is_denied_path('any/path', policy) is False + + +def test_is_denied_path_no_policy() -> None: + """Test denial with missing policy configuration.""" + policy = {} + + assert is_denied_path('.env', policy) is False + + +def test_is_denied_path_empty_path() -> None: + """Test denial check with empty path.""" + policy = {'file_read': {'deny_globs': ['*.env']}} + + assert is_denied_path('', policy) is False From 5adc5c4f7d56d75e6fdafcf3cd283d9470da8353 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Mon, 26 Jan 2026 17:38:14 +0200 Subject: [PATCH 02/22] CM-58022-lint --- .../cli/apps/ai_guardrails/command_utils.py | 2 +- cycode/cli/apps/ai_guardrails/consts.py | 8 ++- .../cli/apps/ai_guardrails/hooks_manager.py | 6 +- cycode/cli/apps/scan/prompt/handlers.py | 59 ++++++++++--------- cycode/cli/apps/scan/prompt/payload.py | 3 +- cycode/cli/apps/scan/prompt/prompt_command.py | 10 ++-- .../cli/apps/scan/prompt/response_builders.py | 5 -- cycode/cli/apps/scan/prompt/utils.py | 2 +- cycode/cyclient/ai_security_manager_client.py | 2 +- pyproject.toml | 2 +- .../cli/commands/scan/prompt/test_handlers.py | 36 +++-------- tests/cli/commands/scan/prompt/test_policy.py | 4 +- 12 files changed, 58 insertions(+), 81 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/command_utils.py b/cycode/cli/apps/ai_guardrails/command_utils.py index 92c48edc..e010f0a2 100644 --- a/cycode/cli/apps/ai_guardrails/command_utils.py +++ b/cycode/cli/apps/ai_guardrails/command_utils.py @@ -32,7 +32,7 @@ def validate_and_parse_ide(ide: str) -> AIIDEType: f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}', style='bold red', ) - raise typer.Exit(1) + raise typer.Exit(1) from None def validate_scope(scope: str, allowed_scopes: tuple[str, ...] = ('user', 'repo')) -> None: diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index 7cda0408..589a3b34 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -18,11 +18,13 @@ class AIIDEType(str, Enum): """Supported AI IDE types.""" + CURSOR = 'cursor' class IDEConfig(NamedTuple): """Configuration for an AI IDE.""" + name: str hooks_dir: Path repo_hooks_subdir: str # Subdirectory in repo for hooks (e.g., '.cursor') @@ -34,10 +36,10 @@ def _get_cursor_hooks_dir() -> Path: """Get Cursor hooks directory based on platform.""" if platform.system() == 'Darwin': return Path.home() / '.cursor' - elif platform.system() == 'Windows': + if platform.system() == 'Windows': return Path.home() / 'AppData' / 'Roaming' / 'Cursor' - else: # Linux - return Path.home() / '.config' / 'Cursor' + # Linux + return Path.home() / '.config' / 'Cursor' # IDE-specific configurations diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index 438705d1..bdf0939e 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -10,11 +10,11 @@ from typing import Optional from cycode.cli.apps.ai_guardrails.consts import ( - AIIDEType, CYCODE_MARKER, CYCODE_SCAN_PROMPT_COMMAND, DEFAULT_IDE, IDE_CONFIGS, + AIIDEType, get_hooks_config, ) from cycode.logger import get_logger @@ -162,9 +162,7 @@ def uninstall_hooks( return False, f'Failed to update hooks file: {hooks_path}' -def get_hooks_status( - scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE -) -> dict: +def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE) -> dict: """ Get the status of AI guardrails hooks. diff --git a/cycode/cli/apps/scan/prompt/handlers.py b/cycode/cli/apps/scan/prompt/handlers.py index b0d8b8fa..8ce242b1 100644 --- a/cycode/cli/apps/scan/prompt/handlers.py +++ b/cycode/cli/apps/scan/prompt/handlers.py @@ -7,8 +7,9 @@ import json import os -from multiprocessing.pool import ThreadPool, TimeoutError as PoolTimeoutError -from typing import Optional +from multiprocessing.pool import ThreadPool +from multiprocessing.pool import TimeoutError as PoolTimeoutError +from typing import Callable, Optional import typer @@ -16,7 +17,7 @@ from cycode.cli.apps.scan.prompt.payload import AIHookPayload from cycode.cli.apps.scan.prompt.policy import get_policy_value from cycode.cli.apps.scan.prompt.response_builders import get_response_builder -from cycode.cli.apps.scan.prompt.types import AIHookOutcome, AiHookEventType, BlockReason +from cycode.cli.apps.scan.prompt.types import AiHookEventType, AIHookOutcome, BlockReason from cycode.cli.apps.scan.prompt.utils import ( is_denied_path, truncate_utf8, @@ -60,8 +61,11 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli try: violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) - if violation_summary and get_policy_value(prompt_config, 'action', - default='block') == 'block' and mode == 'block': + if ( + violation_summary + and get_policy_value(prompt_config, 'action', default='block') == 'block' + and mode == 'block' + ): outcome = AIHookOutcome.BLOCKED block_reason = BlockReason.SECRETS_IN_PROMPT user_message = f'{violation_summary}. Remove secrets before sending.' @@ -72,8 +76,9 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli response = response_builder.allow_prompt() return response except Exception as e: - outcome = AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', - default=True) else AIHookOutcome.BLOCKED + outcome = ( + AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED + ) block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None raise e finally: @@ -133,15 +138,15 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: user_message, 'Secrets detected; do not send this file to the model.', ) - else: - if violation_summary: - outcome = AIHookOutcome.WARNED - return response_builder.allow_permission() + if violation_summary: + outcome = AIHookOutcome.WARNED + return response_builder.allow_permission() return response_builder.allow_permission() except Exception as e: - outcome = AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', - default=True) else AIHookOutcome.BLOCKED + outcome = ( + AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED + ) block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None raise e finally: @@ -197,17 +202,17 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli user_message, 'Do not pass secrets to tools. Use secret references (name/id) instead.', ) - else: - outcome = AIHookOutcome.WARNED - return response_builder.ask_permission( - f'{violation_summary} in MCP tool call "{tool}". Allow execution?', - 'Possible secrets detected in tool arguments; proceed with caution.', - ) + outcome = AIHookOutcome.WARNED + return response_builder.ask_permission( + f'{violation_summary} in MCP tool call "{tool}". Allow execution?', + 'Possible secrets detected in tool arguments; proceed with caution.', + ) return response_builder.allow_permission() except Exception as e: - outcome = AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', - default=True) else AIHookOutcome.BLOCKED + outcome = ( + AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED + ) block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None raise e finally: @@ -220,7 +225,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli ) -def get_handler_for_event(event_type: str): +def get_handler_for_event(event_type: str) -> Optional[Callable[[typer.Context, AIHookPayload, dict], dict]]: """Get the appropriate handler function for a canonical event type. Args: @@ -274,8 +279,8 @@ def _perform_scan( try: scan_id, error, local_scan_result = result.get(timeout=timeout_seconds) except PoolTimeoutError: - logger.debug(f'Scan timed out after {timeout_seconds} seconds') - raise RuntimeError(f'Scan timed out after {timeout_seconds} seconds') + logger.debug('Scan timed out after %s seconds', timeout_seconds) + raise RuntimeError(f'Scan timed out after {timeout_seconds} seconds') from None # Check if scan failed - raise exception to trigger fail_open policy if error: @@ -294,9 +299,7 @@ def _perform_scan( return None, scan_id -def _scan_text_for_secrets( - ctx: typer.Context, text: str, timeout_ms: int -) -> tuple[Optional[str], Optional[str]]: +def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tuple[Optional[str], Optional[str]]: """ Scan text content for secrets using Cycode CLI. @@ -322,7 +325,7 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> if not file_path or not os.path.exists(file_path): return None, None - with open(file_path, 'r', encoding='utf-8', errors='replace') as f: + with open(file_path, encoding='utf-8', errors='replace') as f: content = f.read() # Truncate content based on policy max_bytes diff --git a/cycode/cli/apps/scan/prompt/payload.py b/cycode/cli/apps/scan/prompt/payload.py index 36dc2779..1126d66d 100644 --- a/cycode/cli/apps/scan/prompt/payload.py +++ b/cycode/cli/apps/scan/prompt/payload.py @@ -67,5 +67,4 @@ def from_payload(cls, payload: dict, tool: str = 'cursor') -> 'AIHookPayload': """ if tool == 'cursor': return cls.from_cursor_payload(payload) - else: - raise ValueError(f'Unsupported IDE/tool: {tool}.') + raise ValueError(f'Unsupported IDE/tool: {tool}.') diff --git a/cycode/cli/apps/scan/prompt/prompt_command.py b/cycode/cli/apps/scan/prompt/prompt_command.py index 7ee493ed..2928109a 100644 --- a/cycode/cli/apps/scan/prompt/prompt_command.py +++ b/cycode/cli/apps/scan/prompt/prompt_command.py @@ -105,9 +105,9 @@ def prompt_command( # Fail closed if event_name == AiHookEventType.PROMPT: output_json( - response_builder.deny_prompt('Cycode guardrails error - blocking due to fail-closed policy')) + response_builder.deny_prompt('Cycode guardrails error - blocking due to fail-closed policy') + ) else: - output_json(response_builder.deny_permission( - 'Cycode guardrails error', - 'Blocking due to fail-closed policy' - )) + output_json( + response_builder.deny_permission('Cycode guardrails error', 'Blocking due to fail-closed policy') + ) diff --git a/cycode/cli/apps/scan/prompt/response_builders.py b/cycode/cli/apps/scan/prompt/response_builders.py index 3bc715cb..867965c3 100644 --- a/cycode/cli/apps/scan/prompt/response_builders.py +++ b/cycode/cli/apps/scan/prompt/response_builders.py @@ -14,27 +14,22 @@ class IDEResponseBuilder(ABC): @abstractmethod def allow_permission(self) -> dict: """Build response to allow file read or MCP execution.""" - pass @abstractmethod def deny_permission(self, user_message: str, agent_message: str) -> dict: """Build response to deny file read or MCP execution.""" - pass @abstractmethod def ask_permission(self, user_message: str, agent_message: str) -> dict: """Build response to ask user for permission (warn mode).""" - pass @abstractmethod def allow_prompt(self) -> dict: """Build response to allow prompt submission.""" - pass @abstractmethod def deny_prompt(self, user_message: str) -> dict: """Build response to deny prompt submission.""" - pass class CursorResponseBuilder(IDEResponseBuilder): diff --git a/cycode/cli/apps/scan/prompt/utils.py b/cycode/cli/apps/scan/prompt/utils.py index 21ff6518..9beb2274 100644 --- a/cycode/cli/apps/scan/prompt/utils.py +++ b/cycode/cli/apps/scan/prompt/utils.py @@ -69,4 +69,4 @@ def is_denied_path(file_path: str, policy: dict) -> bool: def output_json(obj: dict) -> None: """Write JSON response to stdout (for IDE to read).""" - print(json.dumps(obj), end='') + print(json.dumps(obj), end='') # noqa: T201 diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index d8b089cf..c998c1c9 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from cycode.cli.apps.scan.prompt.payload import AIHookPayload - from cycode.cli.apps.scan.prompt.types import AIHookOutcome, AiHookEventType, BlockReason + from cycode.cli.apps.scan.prompt.types import AiHookEventType, AIHookOutcome, BlockReason from cycode.cyclient.ai_security_manager_service_config import AISecurityManagerServiceConfigBase diff --git a/pyproject.toml b/pyproject.toml index 65fa2d65..85f5755c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,7 +141,7 @@ inline-quotes = "single" ban-relative-imports = "all" [tool.ruff.lint.per-file-ignores] -"tests/*.py" = ["S101", "S105"] +"tests/**/*.py" = ["S101", "S105", "ANN"] "cycode/*.py" = ["BLE001"] [tool.ruff.format] diff --git a/tests/cli/commands/scan/prompt/test_handlers.py b/tests/cli/commands/scan/prompt/test_handlers.py index a4fcf5b7..999f5c17 100644 --- a/tests/cli/commands/scan/prompt/test_handlers.py +++ b/tests/cli/commands/scan/prompt/test_handlers.py @@ -67,9 +67,7 @@ def test_handle_before_submit_prompt_disabled(mock_ctx, mock_payload, default_po @patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') -def test_handle_before_submit_prompt_no_secrets( - mock_scan, mock_ctx, mock_payload, default_policy -) -> None: +def test_handle_before_submit_prompt_no_secrets(mock_scan, mock_ctx, mock_payload, default_policy) -> None: """Test that prompt with no secrets is allowed.""" mock_scan.return_value = (None, 'scan-id-123') @@ -85,9 +83,7 @@ def test_handle_before_submit_prompt_no_secrets( @patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') -def test_handle_before_submit_prompt_with_secrets_blocked( - mock_scan, mock_ctx, mock_payload, default_policy -) -> None: +def test_handle_before_submit_prompt_with_secrets_blocked(mock_scan, mock_ctx, mock_payload, default_policy) -> None: """Test that prompt with secrets is blocked.""" mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-456') @@ -102,9 +98,7 @@ def test_handle_before_submit_prompt_with_secrets_blocked( @patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') -def test_handle_before_submit_prompt_with_secrets_warned( - mock_scan, mock_ctx, mock_payload, default_policy -) -> None: +def test_handle_before_submit_prompt_with_secrets_warned(mock_scan, mock_ctx, mock_payload, default_policy) -> None: """Test that prompt with secrets in warn mode is allowed.""" default_policy['prompt']['action'] = 'warn' mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-789') @@ -118,9 +112,7 @@ def test_handle_before_submit_prompt_with_secrets_warned( @patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') -def test_handle_before_submit_prompt_scan_failure_fail_open( - mock_scan, mock_ctx, mock_payload, default_policy -) -> None: +def test_handle_before_submit_prompt_scan_failure_fail_open(mock_scan, mock_ctx, mock_payload, default_policy) -> None: """Test that scan failure with fail_open=True allows the prompt.""" mock_scan.side_effect = RuntimeError('Scan failed') default_policy['fail_open'] = True @@ -193,9 +185,7 @@ def test_handle_before_read_file_sensitive_path(mock_is_denied, mock_ctx, defaul @patch('cycode.cli.apps.scan.prompt.handlers.is_denied_path') @patch('cycode.cli.apps.scan.prompt.handlers._scan_path_for_secrets') -def test_handle_before_read_file_no_secrets( - mock_scan, mock_is_denied, mock_ctx, default_policy -) -> None: +def test_handle_before_read_file_no_secrets(mock_scan, mock_is_denied, mock_ctx, default_policy) -> None: """Test that file with no secrets is allowed.""" mock_is_denied.return_value = False mock_scan.return_value = (None, 'scan-id-123') @@ -214,9 +204,7 @@ def test_handle_before_read_file_no_secrets( @patch('cycode.cli.apps.scan.prompt.handlers.is_denied_path') @patch('cycode.cli.apps.scan.prompt.handlers._scan_path_for_secrets') -def test_handle_before_read_file_with_secrets( - mock_scan, mock_is_denied, mock_ctx, default_policy -) -> None: +def test_handle_before_read_file_with_secrets(mock_scan, mock_is_denied, mock_ctx, default_policy) -> None: """Test that file with secrets is blocked.""" mock_is_denied.return_value = False mock_scan.return_value = ('Found 1 secret: password', 'scan-id-456') @@ -237,9 +225,7 @@ def test_handle_before_read_file_with_secrets( @patch('cycode.cli.apps.scan.prompt.handlers.is_denied_path') @patch('cycode.cli.apps.scan.prompt.handlers._scan_path_for_secrets') -def test_handle_before_read_file_scan_disabled( - mock_scan, mock_is_denied, mock_ctx, default_policy -) -> None: +def test_handle_before_read_file_scan_disabled(mock_scan, mock_is_denied, mock_ctx, default_policy) -> None: """Test that file is allowed when content scanning is disabled.""" mock_is_denied.return_value = False default_policy['file_read']['scan_content'] = False @@ -292,9 +278,7 @@ def test_handle_before_mcp_execution_no_secrets(mock_scan, mock_ctx, default_pol @patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') -def test_handle_before_mcp_execution_with_secrets_blocked( - mock_scan, mock_ctx, default_policy -) -> None: +def test_handle_before_mcp_execution_with_secrets_blocked(mock_scan, mock_ctx, default_policy) -> None: """Test that MCP execution with secrets is blocked.""" mock_scan.return_value = ('Found 1 secret: token', 'scan-id-456') payload = AIHookPayload( @@ -314,9 +298,7 @@ def test_handle_before_mcp_execution_with_secrets_blocked( @patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') -def test_handle_before_mcp_execution_with_secrets_warned( - mock_scan, mock_ctx, default_policy -) -> None: +def test_handle_before_mcp_execution_with_secrets_warned(mock_scan, mock_ctx, default_policy) -> None: """Test that MCP execution with secrets in warn mode asks permission.""" mock_scan.return_value = ('Found 1 secret: token', 'scan-id-789') default_policy['mcp']['action'] = 'warn' diff --git a/tests/cli/commands/scan/prompt/test_policy.py b/tests/cli/commands/scan/prompt/test_policy.py index 35a8cd2e..38be5e65 100644 --- a/tests/cli/commands/scan/prompt/test_policy.py +++ b/tests/cli/commands/scan/prompt/test_policy.py @@ -27,9 +27,7 @@ def test_deep_merge_nested() -> None: override = {'level1': {'level2': {'key2': 'override2', 'key3': 'value3'}}} result = deep_merge(base, override) - assert result == { - 'level1': {'level2': {'key1': 'value1', 'key2': 'override2', 'key3': 'value3'}} - } + assert result == {'level1': {'level2': {'key1': 'value1', 'key2': 'override2', 'key3': 'value3'}}} def test_deep_merge_override_with_non_dict() -> None: From 11d087965abb14b4298ff98ba56b4e84a7e345d3 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Mon, 26 Jan 2026 17:41:23 +0200 Subject: [PATCH 03/22] CM-58022-format --- .../cli/apps/ai_guardrails/hooks_manager.py | 4 +- .../cli/apps/ai_guardrails/install_command.py | 54 +++++++++---------- .../cli/apps/ai_guardrails/status_command.py | 54 +++++++++---------- .../apps/ai_guardrails/uninstall_command.py | 54 +++++++++---------- cycode/cli/apps/scan/prompt/handlers.py | 8 +-- cycode/cli/apps/scan/prompt/prompt_command.py | 18 +++---- cycode/cyclient/ai_security_manager_client.py | 12 ++--- cycode/cyclient/client_creator.py | 2 +- .../cli/commands/scan/prompt/test_handlers.py | 2 +- tests/cli/commands/scan/prompt/test_policy.py | 1 + 10 files changed, 105 insertions(+), 104 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index bdf0939e..5d44b07f 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -66,7 +66,7 @@ def is_cycode_hook_entry(entry: dict) -> bool: 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 ) -> tuple[bool, str]: """ Install Cycode AI guardrails hooks. @@ -110,7 +110,7 @@ def install_hooks( def uninstall_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 ) -> tuple[bool, str]: """ Remove Cycode AI guardrails hooks. diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index 4da2eeea..6186752d 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -17,33 +17,33 @@ def install_command( - ctx: typer.Context, - scope: Annotated[ - str, - typer.Option( - '--scope', - '-s', - help='Installation scope: "user" for all projects, "repo" for current repository only.', - ), - ] = 'user', - ide: Annotated[ - str, - typer.Option( - '--ide', - help='IDE to install hooks for (e.g., "cursor"). Defaults to cursor.', - ), - ] = 'cursor', - repo_path: Annotated[ - Optional[Path], - typer.Option( - '--repo-path', - help='Repository path for repo-scoped installation (defaults to current directory).', - exists=True, - file_okay=False, - dir_okay=True, - resolve_path=True, - ), - ] = None, + ctx: typer.Context, + scope: Annotated[ + str, + typer.Option( + '--scope', + '-s', + help='Installation scope: "user" for all projects, "repo" for current repository only.', + ), + ] = 'user', + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE to install hooks for (e.g., "cursor"). Defaults to cursor.', + ), + ] = 'cursor', + repo_path: Annotated[ + Optional[Path], + typer.Option( + '--repo-path', + help='Repository path for repo-scoped installation (defaults to current directory).', + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, ) -> None: """Install AI guardrails hooks for supported IDEs. diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py index ff520de0..0a9801b5 100644 --- a/cycode/cli/apps/ai_guardrails/status_command.py +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -13,33 +13,33 @@ def status_command( - ctx: typer.Context, - scope: Annotated[ - str, - typer.Option( - '--scope', - '-s', - help='Check scope: "user", "repo", or "all" for both.', - ), - ] = 'all', - ide: Annotated[ - str, - typer.Option( - '--ide', - help='IDE to check status for (e.g., "cursor"). Defaults to cursor.', - ), - ] = 'cursor', - repo_path: Annotated[ - Optional[Path], - typer.Option( - '--repo-path', - help='Repository path for repo-scoped status (defaults to current directory).', - exists=True, - file_okay=False, - dir_okay=True, - resolve_path=True, - ), - ] = None, + ctx: typer.Context, + scope: Annotated[ + str, + typer.Option( + '--scope', + '-s', + help='Check scope: "user", "repo", or "all" for both.', + ), + ] = 'all', + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE to check status for (e.g., "cursor"). Defaults to cursor.', + ), + ] = 'cursor', + repo_path: Annotated[ + Optional[Path], + typer.Option( + '--repo-path', + help='Repository path for repo-scoped status (defaults to current directory).', + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, ) -> None: """Show AI guardrails hook installation status. diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py index 0a62d342..23315693 100644 --- a/cycode/cli/apps/ai_guardrails/uninstall_command.py +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -17,33 +17,33 @@ def uninstall_command( - ctx: typer.Context, - scope: Annotated[ - str, - typer.Option( - '--scope', - '-s', - help='Uninstall scope: "user" for user-level hooks, "repo" for repository-level hooks.', - ), - ] = 'user', - ide: Annotated[ - str, - typer.Option( - '--ide', - help='IDE to uninstall hooks from (e.g., "cursor"). Defaults to cursor.', - ), - ] = 'cursor', - repo_path: Annotated[ - Optional[Path], - typer.Option( - '--repo-path', - help='Repository path for repo-scoped uninstallation (defaults to current directory).', - exists=True, - file_okay=False, - dir_okay=True, - resolve_path=True, - ), - ] = None, + ctx: typer.Context, + scope: Annotated[ + str, + typer.Option( + '--scope', + '-s', + help='Uninstall scope: "user" for user-level hooks, "repo" for repository-level hooks.', + ), + ] = 'user', + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE to uninstall hooks from (e.g., "cursor"). Defaults to cursor.', + ), + ] = 'cursor', + repo_path: Annotated[ + Optional[Path], + typer.Option( + '--repo-path', + help='Repository path for repo-scoped uninstallation (defaults to current directory).', + exists=True, + file_okay=False, + dir_okay=True, + resolve_path=True, + ), + ] = None, ) -> None: """Remove AI guardrails hooks from supported IDEs. diff --git a/cycode/cli/apps/scan/prompt/handlers.py b/cycode/cli/apps/scan/prompt/handlers.py index 8ce242b1..b0135c21 100644 --- a/cycode/cli/apps/scan/prompt/handlers.py +++ b/cycode/cli/apps/scan/prompt/handlers.py @@ -62,9 +62,9 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) if ( - violation_summary - and get_policy_value(prompt_config, 'action', default='block') == 'block' - and mode == 'block' + violation_summary + and get_policy_value(prompt_config, 'action', default='block') == 'block' + and mode == 'block' ): outcome = AIHookOutcome.BLOCKED block_reason = BlockReason.SECRETS_IN_PROMPT @@ -256,7 +256,7 @@ def _setup_scan_context(ctx: typer.Context) -> typer.Context: def _perform_scan( - ctx: typer.Context, documents: list[Document], scan_parameters: dict, timeout_seconds: float + ctx: typer.Context, documents: list[Document], scan_parameters: dict, timeout_seconds: float ) -> tuple[Optional[str], Optional[str]]: """ Perform a scan on documents and extract results. diff --git a/cycode/cli/apps/scan/prompt/prompt_command.py b/cycode/cli/apps/scan/prompt/prompt_command.py index 2928109a..01cccf30 100644 --- a/cycode/cli/apps/scan/prompt/prompt_command.py +++ b/cycode/cli/apps/scan/prompt/prompt_command.py @@ -28,15 +28,15 @@ def prompt_command( - ctx: typer.Context, - ide: Annotated[ - str, - typer.Option( - '--ide', - help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.', - hidden=True, - ), - ] = 'cursor', + ctx: typer.Context, + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.', + hidden=True, + ), + ] = 'cursor', ) -> None: """Handle AI guardrails hooks from supported IDEs. diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index c998c1c9..2046af69 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -51,12 +51,12 @@ def create_conversation(self, payload: 'AIHookPayload') -> Optional[str]: return conversation_id def create_event( - self, - payload: 'AIHookPayload', - event_type: 'AiHookEventType', - outcome: 'AIHookOutcome', - scan_id: Optional[str] = None, - block_reason: Optional['BlockReason'] = None, + self, + payload: 'AIHookPayload', + event_type: 'AiHookEventType', + outcome: 'AIHookOutcome', + scan_id: Optional[str] = None, + block_reason: Optional['BlockReason'] = None, ) -> None: """Create an AI hook event from hook payload.""" conversation_id = payload.conversation_id diff --git a/cycode/cyclient/client_creator.py b/cycode/cyclient/client_creator.py index fb563a0b..c26795c7 100644 --- a/cycode/cyclient/client_creator.py +++ b/cycode/cyclient/client_creator.py @@ -57,7 +57,7 @@ def create_import_sbom_client( def create_ai_security_manager_client( - client_id: str, client_secret: Optional[str] = None, _: bool = False, id_token: Optional[str] = None + client_id: str, client_secret: Optional[str] = None, _: bool = False, id_token: Optional[str] = None ) -> AISecurityManagerClient: if dev_mode: client = CycodeDevBasedClient(DEV_CYCODE_API_URL) diff --git a/tests/cli/commands/scan/prompt/test_handlers.py b/tests/cli/commands/scan/prompt/test_handlers.py index 999f5c17..70ffb031 100644 --- a/tests/cli/commands/scan/prompt/test_handlers.py +++ b/tests/cli/commands/scan/prompt/test_handlers.py @@ -130,7 +130,7 @@ def test_handle_before_submit_prompt_scan_failure_fail_open(mock_scan, mock_ctx, @patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') def test_handle_before_submit_prompt_scan_failure_fail_closed( - mock_scan, mock_ctx, mock_payload, default_policy + mock_scan, mock_ctx, mock_payload, default_policy ) -> None: """Test that scan failure with fail_open=False blocks the prompt.""" mock_scan.side_effect = RuntimeError('Scan failed') diff --git a/tests/cli/commands/scan/prompt/test_policy.py b/tests/cli/commands/scan/prompt/test_policy.py index 38be5e65..23aee4f7 100644 --- a/tests/cli/commands/scan/prompt/test_policy.py +++ b/tests/cli/commands/scan/prompt/test_policy.py @@ -161,6 +161,7 @@ def test_load_policy_with_repo_config(tmp_path: Path) -> None: repo_config.write_text('mode: block\nprompt:\n enabled: false\n') with patch('cycode.cli.apps.scan.prompt.policy.load_yaml_file') as mock_load: + def side_effect(path: Path): if path == repo_config: return {'mode': 'block', 'prompt': {'enabled': False}} From c09a65221e52422661ec70e3f69215cb9bd47506 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Mon, 26 Jan 2026 17:44:30 +0200 Subject: [PATCH 04/22] CM-58022-fix strenum --- cycode/cli/apps/scan/prompt/types.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cycode/cli/apps/scan/prompt/types.py b/cycode/cli/apps/scan/prompt/types.py index 8beb7ff8..a432a5ad 100644 --- a/cycode/cli/apps/scan/prompt/types.py +++ b/cycode/cli/apps/scan/prompt/types.py @@ -1,6 +1,10 @@ """Type definitions for AI guardrails.""" +from enum import Enum -from enum import StrEnum + +class StrEnum(str, Enum): + def __str__(self) -> str: + return self.value class AiHookEventType(StrEnum): From d239d9f1add6d7b0e5d6ba88e6b739641e06303c Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Mon, 26 Jan 2026 17:46:40 +0200 Subject: [PATCH 05/22] CM-58022-format --- cycode/cli/apps/scan/prompt/types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cycode/cli/apps/scan/prompt/types.py b/cycode/cli/apps/scan/prompt/types.py index a432a5ad..5fc3fde2 100644 --- a/cycode/cli/apps/scan/prompt/types.py +++ b/cycode/cli/apps/scan/prompt/types.py @@ -1,4 +1,5 @@ """Type definitions for AI guardrails.""" + from enum import Enum From 4ecb761d7dc396697815cdb4e797b0baa5a7b6c9 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Mon, 26 Jan 2026 17:54:54 +0200 Subject: [PATCH 06/22] CM-58022-fix --- tests/cli/commands/scan/prompt/test_utils.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/cli/commands/scan/prompt/test_utils.py b/tests/cli/commands/scan/prompt/test_utils.py index 61ce2eb8..c8e0f7d3 100644 --- a/tests/cli/commands/scan/prompt/test_utils.py +++ b/tests/cli/commands/scan/prompt/test_utils.py @@ -7,22 +7,6 @@ ) -def test_normalize_path_basic() -> None: - """Test basic path normalization.""" - path = '/path/to/file.txt' - result = normalize_path(path) - - assert result == '/path/to/file.txt' - - -def test_normalize_path_with_dots() -> None: - """Test normalizing path with . and .. segments.""" - path = '/path/./to/../file.txt' - result = normalize_path(path) - - assert result == '/path/file.txt' - - def test_normalize_path_rejects_escape() -> None: """Test that paths attempting to escape are rejected.""" path = '../../../etc/passwd' From 14afe21a2f845160e71cf543fcb3d12e8a54c204 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Mon, 26 Jan 2026 18:58:12 +0200 Subject: [PATCH 07/22] CM-58022 skip scan configuration fetching for prompt command --- cycode/cli/apps/scan/__init__.py | 1 + cycode/cli/apps/scan/prompt/prompt_command.py | 14 +++++++------- cycode/cli/apps/scan/scan_command.py | 5 +++++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/cycode/cli/apps/scan/__init__.py b/cycode/cli/apps/scan/__init__.py index d45f8d25..c85ac227 100644 --- a/cycode/cli/apps/scan/__init__.py +++ b/cycode/cli/apps/scan/__init__.py @@ -47,6 +47,7 @@ _AI_GUARDRAILS_RICH_HELP_PANEL = 'AI Guardrails commands' app.command( + hidden=True, name='prompt', short_help='Handle AI guardrails hooks from supported IDEs (reads JSON from stdin).', rich_help_panel=_AI_GUARDRAILS_RICH_HELP_PANEL, diff --git a/cycode/cli/apps/scan/prompt/prompt_command.py b/cycode/cli/apps/scan/prompt/prompt_command.py index 01cccf30..e2b286b4 100644 --- a/cycode/cli/apps/scan/prompt/prompt_command.py +++ b/cycode/cli/apps/scan/prompt/prompt_command.py @@ -20,7 +20,7 @@ from cycode.cli.apps.scan.prompt.response_builders import get_response_builder from cycode.cli.apps.scan.prompt.types import AiHookEventType from cycode.cli.apps.scan.prompt.utils import output_json, safe_json_parse -from cycode.cli.utils.get_api_client import get_ai_security_manager_client +from cycode.cli.utils.get_api_client import get_ai_security_manager_client, get_scan_cycode_client from cycode.cli.utils.sentry import add_breadcrumb from cycode.logger import get_logger @@ -52,17 +52,11 @@ def prompt_command( """ add_breadcrumb('prompt') - # Initialize AI Security Manager client - ai_security_client = get_ai_security_manager_client(ctx) - ctx.obj['ai_security_client'] = ai_security_client - # Read JSON payload from stdin stdin_data = sys.stdin.read().strip() payload = safe_json_parse(stdin_data) tool = ide.lower() - - # Get response builder for this IDE response_builder = get_response_builder(tool) if not payload: @@ -77,6 +71,12 @@ def prompt_command( event_name = unified_payload.event_name logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'tool': tool}) + scan_client = get_scan_cycode_client(ctx) + ctx.obj['client'] = scan_client + + ai_security_client = get_ai_security_manager_client(ctx) + ctx.obj['ai_security_client'] = ai_security_client + # Load policy (merges defaults <- user config <- repo config) # Extract first workspace root from payload if available workspace_roots = payload.get('workspace_roots', ['.']) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 2eb51f12..15f0c018 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -160,6 +160,11 @@ def scan_command( ctx.obj['gradle_all_sub_projects'] = gradle_all_sub_projects ctx.obj['no_restore'] = no_restore + # Skip standard scan initialization for prompt command. + # Prompt command handles its own authentication and doesn't need scan configuration + if ctx.invoked_subcommand == 'prompt': + return + scan_client = get_scan_cycode_client(ctx) ctx.obj['client'] = scan_client From d95b19ba6e41ea0a2036b5e9f04504eb7148b138 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Tue, 27 Jan 2026 17:42:04 +0200 Subject: [PATCH 08/22] CM-58248 - CM-58022 skip scan configuration fetching for prompt command --- cycode/cli/apps/scan/prompt/prompt_command.py | 88 ++++++++++++------- cycode/cyclient/ai_security_manager_client.py | 6 +- 2 files changed, 61 insertions(+), 33 deletions(-) diff --git a/cycode/cli/apps/scan/prompt/prompt_command.py b/cycode/cli/apps/scan/prompt/prompt_command.py index e2b286b4..b17ca858 100644 --- a/cycode/cli/apps/scan/prompt/prompt_command.py +++ b/cycode/cli/apps/scan/prompt/prompt_command.py @@ -12,6 +12,7 @@ import sys from typing import Annotated +import click import typer from cycode.cli.apps.scan.prompt.handlers import get_handler_for_event @@ -20,6 +21,7 @@ from cycode.cli.apps.scan.prompt.response_builders import get_response_builder from cycode.cli.apps.scan.prompt.types import AiHookEventType from cycode.cli.apps.scan.prompt.utils import output_json, safe_json_parse +from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError from cycode.cli.utils.get_api_client import get_ai_security_manager_client, get_scan_cycode_client from cycode.cli.utils.sentry import add_breadcrumb from cycode.logger import get_logger @@ -27,6 +29,36 @@ logger = get_logger('AI Guardrails') +def _get_auth_error_message(error: Exception) -> str: + """Get user-friendly message for authentication errors.""" + if isinstance(error, click.ClickException): + # Missing credentials + return f'{error.message} Please run `cycode configure` to set up your credentials.' + + if isinstance(error, HttpUnauthorizedError): + # Invalid/expired credentials + return ( + 'Unable to authenticate to Cycode. Your credentials are invalid or have expired. ' + 'Please run `cycode configure` to update your credentials.' + ) + + # Fallback + return 'Authentication failed. Please run `cycode configure` to set up your credentials.' + + +def _initialize_clients(ctx: typer.Context) -> None: + """Initialize API clients. + + May raise click.ClickException if credentials are missing, + or HttpUnauthorizedError if credentials are invalid. + """ + scan_client = get_scan_cycode_client(ctx) + ctx.obj['client'] = scan_client + + ai_security_client = get_ai_security_manager_client(ctx) + ctx.obj['ai_security_client'] = ai_security_client + + def prompt_command( ctx: typer.Context, ide: Annotated[ @@ -52,7 +84,6 @@ def prompt_command( """ add_breadcrumb('prompt') - # Read JSON payload from stdin stdin_data = sys.stdin.read().strip() payload = safe_json_parse(stdin_data) @@ -64,50 +95,43 @@ def prompt_command( output_json(response_builder.allow_prompt()) return - # Create unified payload object unified_payload = AIHookPayload.from_payload(payload, tool=tool) - - # Extract event type from unified payload event_name = unified_payload.event_name logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'tool': tool}) - scan_client = get_scan_cycode_client(ctx) - ctx.obj['client'] = scan_client - - ai_security_client = get_ai_security_manager_client(ctx) - ctx.obj['ai_security_client'] = ai_security_client - - # Load policy (merges defaults <- user config <- repo config) - # Extract first workspace root from payload if available workspace_roots = payload.get('workspace_roots', ['.']) policy = load_policy(workspace_roots[0]) - # Get the appropriate handler for this event - handler = get_handler_for_event(event_name) + try: + _initialize_clients(ctx) - if handler is None: - logger.debug('Unknown hook event, allowing by default', extra={'event_name': event_name}) - # Unknown event type - allow by default - output_json(response_builder.allow_prompt()) - return + handler = get_handler_for_event(event_name) + if handler is None: + logger.debug('Unknown hook event, allowing by default', extra={'event_name': event_name}) + output_json(response_builder.allow_prompt()) + return - # Execute the handler and output the response - try: response = handler(ctx, unified_payload, policy) logger.debug('Hook handler completed', extra={'event_name': event_name, 'response': response}) output_json(response) + + except (click.ClickException, HttpUnauthorizedError) as e: + error_message = _get_auth_error_message(e) + if event_name == AiHookEventType.PROMPT: + output_json(response_builder.deny_prompt(error_message)) + return + output_json(response_builder.deny_permission(error_message, 'Authentication required')) + except Exception as e: logger.error('Hook handler failed', exc_info=e) - # Fail open by default if policy.get('fail_open', True): output_json(response_builder.allow_prompt()) - else: - # Fail closed - if event_name == AiHookEventType.PROMPT: - output_json( - response_builder.deny_prompt('Cycode guardrails error - blocking due to fail-closed policy') - ) - else: - output_json( - response_builder.deny_permission('Cycode guardrails error', 'Blocking due to fail-closed policy') - ) + return + if event_name == AiHookEventType.PROMPT: + output_json( + response_builder.deny_prompt('Cycode guardrails error - blocking due to fail-closed policy') + ) + return + output_json( + response_builder.deny_permission('Cycode guardrails error', 'Blocking due to fail-closed policy') + ) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 2046af69..0d3d80fa 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Optional +from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError from cycode.cyclient.cycode_client_base import CycodeClientBase from cycode.cyclient.logger import logger @@ -44,9 +45,12 @@ def create_conversation(self, payload: 'AIHookPayload') -> Optional[str]: try: self.client.post(self._build_endpoint_path(self._CONVERSATIONS_PATH), body=body) + except HttpUnauthorizedError: + # Authentication error - re-raise so prompt_command can catch it + raise except Exception as e: logger.debug('Failed to create conversation', exc_info=e) - # Don't fail the hook if tracking fails + # Don't fail the hook if tracking fails (non-auth errors) return conversation_id From 29114c2a7a8552a9dc59344dd01d2ab61396d454 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Tue, 27 Jan 2026 18:14:21 +0200 Subject: [PATCH 09/22] CM-58248 format --- cycode/cli/apps/scan/prompt/prompt_command.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/cycode/cli/apps/scan/prompt/prompt_command.py b/cycode/cli/apps/scan/prompt/prompt_command.py index b17ca858..8559bc43 100644 --- a/cycode/cli/apps/scan/prompt/prompt_command.py +++ b/cycode/cli/apps/scan/prompt/prompt_command.py @@ -128,10 +128,6 @@ def prompt_command( output_json(response_builder.allow_prompt()) return if event_name == AiHookEventType.PROMPT: - output_json( - response_builder.deny_prompt('Cycode guardrails error - blocking due to fail-closed policy') - ) + output_json(response_builder.deny_prompt('Cycode guardrails error - blocking due to fail-closed policy')) return - output_json( - response_builder.deny_permission('Cycode guardrails error', 'Blocking due to fail-closed policy') - ) + output_json(response_builder.deny_permission('Cycode guardrails error', 'Blocking due to fail-closed policy')) From d183e2e03a1c12fa4bc23f5249d689d769939623 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Tue, 27 Jan 2026 19:55:09 +0200 Subject: [PATCH 10/22] CM-58022-fix-types --- cycode/cli/apps/scan/prompt/types.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cycode/cli/apps/scan/prompt/types.py b/cycode/cli/apps/scan/prompt/types.py index 5fc3fde2..01e50f3b 100644 --- a/cycode/cli/apps/scan/prompt/types.py +++ b/cycode/cli/apps/scan/prompt/types.py @@ -15,9 +15,9 @@ class AiHookEventType(StrEnum): are mapped to these canonical types using the mapping dictionaries below. """ - PROMPT = 'prompt' - FILE_READ = 'file_read' - MCP_EXECUTION = 'mcp_execution' + PROMPT = 'Prompt' + FILE_READ = 'FileRead' + MCP_EXECUTION = 'McpExecution' # IDE-specific event name mappings to canonical types From 678967f39e3b13777a5e5cc8ea3420eb2da2adbb Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Wed, 28 Jan 2026 16:57:41 +0200 Subject: [PATCH 11/22] CM-58022-review --- cycode/cli/apps/ai_guardrails/__init__.py | 6 + .../prompt => ai_guardrails/scan}/__init__.py | 0 .../prompt => ai_guardrails/scan}/consts.py | 0 .../prompt => ai_guardrails/scan}/handlers.py | 12 +- .../prompt => ai_guardrails/scan}/payload.py | 2 +- .../prompt => ai_guardrails/scan}/policy.py | 2 +- .../scan}/response_builders.py | 0 .../scan/scan_command.py} | 25 +++-- .../prompt => ai_guardrails/scan}/types.py | 12 +- .../prompt => ai_guardrails/scan}/utils.py | 2 +- cycode/cli/apps/scan/__init__.py | 10 -- cycode/cli/apps/scan/scan_command.py | 5 - cycode/cyclient/ai_security_manager_client.py | 4 +- pyproject.toml | 2 +- .../prompt => ai_guardrails/scan}/__init__.py | 0 .../scan}/test_handlers.py | 103 +++++++++++------- .../scan}/test_payload.py | 4 +- .../scan}/test_policy.py | 10 +- .../scan}/test_response_builders.py | 2 +- .../scan}/test_utils.py | 2 +- 20 files changed, 113 insertions(+), 90 deletions(-) rename cycode/cli/apps/{scan/prompt => ai_guardrails/scan}/__init__.py (100%) rename cycode/cli/apps/{scan/prompt => ai_guardrails/scan}/consts.py (100%) rename cycode/cli/apps/{scan/prompt => ai_guardrails/scan}/handlers.py (97%) rename cycode/cli/apps/{scan/prompt => ai_guardrails/scan}/payload.py (97%) rename cycode/cli/apps/{scan/prompt => ai_guardrails/scan}/policy.py (96%) rename cycode/cli/apps/{scan/prompt => ai_guardrails/scan}/response_builders.py (100%) rename cycode/cli/apps/{scan/prompt/prompt_command.py => ai_guardrails/scan/scan_command.py} (85%) rename cycode/cli/apps/{scan/prompt => ai_guardrails/scan}/types.py (85%) rename cycode/cli/apps/{scan/prompt => ai_guardrails/scan}/utils.py (96%) rename tests/cli/commands/{scan/prompt => ai_guardrails/scan}/__init__.py (100%) rename tests/cli/commands/{scan/prompt => ai_guardrails/scan}/test_handlers.py (72%) rename tests/cli/commands/{scan/prompt => ai_guardrails/scan}/test_payload.py (96%) rename tests/cli/commands/{scan/prompt => ai_guardrails/scan}/test_policy.py (94%) rename tests/cli/commands/{scan/prompt => ai_guardrails/scan}/test_response_builders.py (97%) rename tests/cli/commands/{scan/prompt => ai_guardrails/scan}/test_utils.py (98%) diff --git a/cycode/cli/apps/ai_guardrails/__init__.py b/cycode/cli/apps/ai_guardrails/__init__.py index d8fe88e0..0538ce45 100644 --- a/cycode/cli/apps/ai_guardrails/__init__.py +++ b/cycode/cli/apps/ai_guardrails/__init__.py @@ -1,6 +1,7 @@ import typer from cycode.cli.apps.ai_guardrails.install_command import install_command +from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command from cycode.cli.apps.ai_guardrails.status_command import status_command from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command @@ -9,3 +10,8 @@ app.command(name='install', short_help='Install AI guardrails hooks for supported IDEs.')(install_command) app.command(name='uninstall', short_help='Remove AI guardrails hooks from supported IDEs.')(uninstall_command) app.command(name='status', short_help='Show AI guardrails hook installation status.')(status_command) +app.command( + hidden=True, + name='scan', + short_help='Scan content from AI IDE hooks for secrets (reads JSON from stdin).', +)(scan_command) diff --git a/cycode/cli/apps/scan/prompt/__init__.py b/cycode/cli/apps/ai_guardrails/scan/__init__.py similarity index 100% rename from cycode/cli/apps/scan/prompt/__init__.py rename to cycode/cli/apps/ai_guardrails/scan/__init__.py diff --git a/cycode/cli/apps/scan/prompt/consts.py b/cycode/cli/apps/ai_guardrails/scan/consts.py similarity index 100% rename from cycode/cli/apps/scan/prompt/consts.py rename to cycode/cli/apps/ai_guardrails/scan/consts.py diff --git a/cycode/cli/apps/scan/prompt/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py similarity index 97% rename from cycode/cli/apps/scan/prompt/handlers.py rename to cycode/cli/apps/ai_guardrails/scan/handlers.py index b0135c21..69a7fb6a 100644 --- a/cycode/cli/apps/scan/prompt/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -13,15 +13,15 @@ import typer -from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func -from cycode.cli.apps.scan.prompt.payload import AIHookPayload -from cycode.cli.apps.scan.prompt.policy import get_policy_value -from cycode.cli.apps.scan.prompt.response_builders import get_response_builder -from cycode.cli.apps.scan.prompt.types import AiHookEventType, AIHookOutcome, BlockReason -from cycode.cli.apps.scan.prompt.utils import ( +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value +from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason +from cycode.cli.apps.ai_guardrails.scan.utils import ( is_denied_path, truncate_utf8, ) +from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func from cycode.cli.apps.scan.scan_parameters import get_scan_parameters from cycode.cli.models import Document from cycode.cli.utils.progress_bar import DummyProgressBar, ScanProgressBarSection diff --git a/cycode/cli/apps/scan/prompt/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py similarity index 97% rename from cycode/cli/apps/scan/prompt/payload.py rename to cycode/cli/apps/ai_guardrails/scan/payload.py index 1126d66d..4fafbf24 100644 --- a/cycode/cli/apps/scan/prompt/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Optional -from cycode.cli.apps.scan.prompt.types import CURSOR_EVENT_MAPPING +from cycode.cli.apps.ai_guardrails.scan.types import CURSOR_EVENT_MAPPING @dataclass diff --git a/cycode/cli/apps/scan/prompt/policy.py b/cycode/cli/apps/ai_guardrails/scan/policy.py similarity index 96% rename from cycode/cli/apps/scan/prompt/policy.py rename to cycode/cli/apps/ai_guardrails/scan/policy.py index cbae2c2d..f40d77c0 100644 --- a/cycode/cli/apps/scan/prompt/policy.py +++ b/cycode/cli/apps/ai_guardrails/scan/policy.py @@ -13,7 +13,7 @@ import yaml -from cycode.cli.apps.scan.prompt.consts import DEFAULT_POLICY, POLICY_FILE_NAME +from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME def deep_merge(base: dict, override: dict) -> dict: diff --git a/cycode/cli/apps/scan/prompt/response_builders.py b/cycode/cli/apps/ai_guardrails/scan/response_builders.py similarity index 100% rename from cycode/cli/apps/scan/prompt/response_builders.py rename to cycode/cli/apps/ai_guardrails/scan/response_builders.py diff --git a/cycode/cli/apps/scan/prompt/prompt_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py similarity index 85% rename from cycode/cli/apps/scan/prompt/prompt_command.py rename to cycode/cli/apps/ai_guardrails/scan/scan_command.py index 8559bc43..e08bb4de 100644 --- a/cycode/cli/apps/scan/prompt/prompt_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -1,8 +1,9 @@ """ -Prompt scan command for AI guardrails. +Scan command for AI guardrails. This command handles AI IDE hooks by reading JSON from stdin and outputting -a JSON response to stdout. +a JSON response to stdout. It scans prompts, file reads, and MCP tool calls +for secrets before they are sent to AI models. Supports multiple IDEs with different hook event types. The specific hook events supported depend on the IDE being used (e.g., Cursor supports beforeSubmitPrompt, @@ -15,12 +16,12 @@ import click import typer -from cycode.cli.apps.scan.prompt.handlers import get_handler_for_event -from cycode.cli.apps.scan.prompt.payload import AIHookPayload -from cycode.cli.apps.scan.prompt.policy import load_policy -from cycode.cli.apps.scan.prompt.response_builders import get_response_builder -from cycode.cli.apps.scan.prompt.types import AiHookEventType -from cycode.cli.apps.scan.prompt.utils import output_json, safe_json_parse +from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.policy import load_policy +from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.cli.apps.ai_guardrails.scan.utils import output_json, safe_json_parse from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError from cycode.cli.utils.get_api_client import get_ai_security_manager_client, get_scan_cycode_client from cycode.cli.utils.sentry import add_breadcrumb @@ -59,7 +60,7 @@ def _initialize_clients(ctx: typer.Context) -> None: ctx.obj['ai_security_client'] = ai_security_client -def prompt_command( +def scan_command( ctx: typer.Context, ide: Annotated[ str, @@ -70,7 +71,7 @@ def prompt_command( ), ] = 'cursor', ) -> None: - """Handle AI guardrails hooks from supported IDEs. + """Scan content from AI IDE hooks for secrets. This command reads a JSON payload from stdin containing hook event data and outputs a JSON response to stdout indicating whether to allow or block the action. @@ -80,9 +81,9 @@ def prompt_command( file access, and tool executions. Example usage (from IDE hooks configuration): - { "command": "cycode scan prompt" } + { "command": "cycode ai-guardrails scan" } """ - add_breadcrumb('prompt') + add_breadcrumb('ai-guardrails-scan') stdin_data = sys.stdin.read().strip() payload = safe_json_parse(stdin_data) diff --git a/cycode/cli/apps/scan/prompt/types.py b/cycode/cli/apps/ai_guardrails/scan/types.py similarity index 85% rename from cycode/cli/apps/scan/prompt/types.py rename to cycode/cli/apps/ai_guardrails/scan/types.py index 01e50f3b..095ca61b 100644 --- a/cycode/cli/apps/scan/prompt/types.py +++ b/cycode/cli/apps/ai_guardrails/scan/types.py @@ -1,11 +1,15 @@ """Type definitions for AI guardrails.""" -from enum import Enum +import sys +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum -class StrEnum(str, Enum): - def __str__(self) -> str: - return self.value + class StrEnum(str, Enum): + def __str__(self) -> str: + return self.value class AiHookEventType(StrEnum): diff --git a/cycode/cli/apps/scan/prompt/utils.py b/cycode/cli/apps/ai_guardrails/scan/utils.py similarity index 96% rename from cycode/cli/apps/scan/prompt/utils.py rename to cycode/cli/apps/ai_guardrails/scan/utils.py index 9beb2274..e14c1c02 100644 --- a/cycode/cli/apps/scan/prompt/utils.py +++ b/cycode/cli/apps/ai_guardrails/scan/utils.py @@ -8,7 +8,7 @@ import os from pathlib import Path -from cycode.cli.apps.scan.prompt.policy import get_policy_value +from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value def safe_json_parse(s: str) -> dict: diff --git a/cycode/cli/apps/scan/__init__.py b/cycode/cli/apps/scan/__init__.py index c85ac227..629c3b8f 100644 --- a/cycode/cli/apps/scan/__init__.py +++ b/cycode/cli/apps/scan/__init__.py @@ -5,7 +5,6 @@ from cycode.cli.apps.scan.pre_commit.pre_commit_command import pre_commit_command from cycode.cli.apps.scan.pre_push.pre_push_command import pre_push_command from cycode.cli.apps.scan.pre_receive.pre_receive_command import pre_receive_command -from cycode.cli.apps.scan.prompt.prompt_command import prompt_command from cycode.cli.apps.scan.repository.repository_command import repository_command from cycode.cli.apps.scan.scan_command import scan_command, scan_command_result_callback @@ -44,15 +43,6 @@ rich_help_panel=_AUTOMATION_COMMANDS_RICH_HELP_PANEL, )(pre_receive_command) -_AI_GUARDRAILS_RICH_HELP_PANEL = 'AI Guardrails commands' - -app.command( - hidden=True, - name='prompt', - short_help='Handle AI guardrails hooks from supported IDEs (reads JSON from stdin).', - rich_help_panel=_AI_GUARDRAILS_RICH_HELP_PANEL, -)(prompt_command) - # backward compatibility app.command(hidden=True, name='commit_history')(commit_history_command) app.command(hidden=True, name='pre_commit')(pre_commit_command) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 15f0c018..2eb51f12 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -160,11 +160,6 @@ def scan_command( ctx.obj['gradle_all_sub_projects'] = gradle_all_sub_projects ctx.obj['no_restore'] = no_restore - # Skip standard scan initialization for prompt command. - # Prompt command handles its own authentication and doesn't need scan configuration - if ctx.invoked_subcommand == 'prompt': - return - scan_client = get_scan_cycode_client(ctx) ctx.obj['client'] = scan_client diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 0d3d80fa..19addd06 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -7,8 +7,8 @@ from cycode.cyclient.logger import logger if TYPE_CHECKING: - from cycode.cli.apps.scan.prompt.payload import AIHookPayload - from cycode.cli.apps.scan.prompt.types import AiHookEventType, AIHookOutcome, BlockReason + from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload + from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason from cycode.cyclient.ai_security_manager_service_config import AISecurityManagerServiceConfigBase diff --git a/pyproject.toml b/pyproject.toml index 85f5755c..65fa2d65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,7 +141,7 @@ inline-quotes = "single" ban-relative-imports = "all" [tool.ruff.lint.per-file-ignores] -"tests/**/*.py" = ["S101", "S105", "ANN"] +"tests/*.py" = ["S101", "S105"] "cycode/*.py" = ["BLE001"] [tool.ruff.format] diff --git a/tests/cli/commands/scan/prompt/__init__.py b/tests/cli/commands/ai_guardrails/scan/__init__.py similarity index 100% rename from tests/cli/commands/scan/prompt/__init__.py rename to tests/cli/commands/ai_guardrails/scan/__init__.py diff --git a/tests/cli/commands/scan/prompt/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py similarity index 72% rename from tests/cli/commands/scan/prompt/test_handlers.py rename to tests/cli/commands/ai_guardrails/scan/test_handlers.py index 70ffb031..58dfe195 100644 --- a/tests/cli/commands/scan/prompt/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -1,21 +1,22 @@ """Tests for AI guardrails handlers.""" +from typing import Any from unittest.mock import MagicMock, patch import pytest import typer -from cycode.cli.apps.scan.prompt.handlers import ( +from cycode.cli.apps.ai_guardrails.scan.handlers import ( handle_before_mcp_execution, handle_before_read_file, handle_before_submit_prompt, ) -from cycode.cli.apps.scan.prompt.payload import AIHookPayload -from cycode.cli.apps.scan.prompt.types import AIHookOutcome, BlockReason +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AIHookOutcome, BlockReason @pytest.fixture -def mock_ctx(): +def mock_ctx() -> MagicMock: """Create a mock Typer context.""" ctx = MagicMock(spec=typer.Context) ctx.obj = { @@ -26,7 +27,7 @@ def mock_ctx(): @pytest.fixture -def mock_payload(): +def mock_payload() -> AIHookPayload: """Create a mock AIHookPayload.""" return AIHookPayload( event_name='prompt', @@ -41,7 +42,7 @@ def mock_payload(): @pytest.fixture -def default_policy(): +def default_policy() -> dict[str, Any]: """Create a default policy dict.""" return { 'mode': 'block', @@ -56,7 +57,9 @@ def default_policy(): # Tests for handle_before_submit_prompt -def test_handle_before_submit_prompt_disabled(mock_ctx, mock_payload, default_policy) -> None: +def test_handle_before_submit_prompt_disabled( + mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: """Test that disabled prompt scanning allows the prompt.""" default_policy['prompt']['enabled'] = False @@ -66,8 +69,10 @@ def test_handle_before_submit_prompt_disabled(mock_ctx, mock_payload, default_po mock_ctx.obj['ai_security_client'].create_event.assert_called_once() -@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') -def test_handle_before_submit_prompt_no_secrets(mock_scan, mock_ctx, mock_payload, default_policy) -> None: +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_no_secrets( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: """Test that prompt with no secrets is allowed.""" mock_scan.return_value = (None, 'scan-id-123') @@ -82,8 +87,10 @@ def test_handle_before_submit_prompt_no_secrets(mock_scan, mock_ctx, mock_payloa assert call_args.kwargs['block_reason'] is None -@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') -def test_handle_before_submit_prompt_with_secrets_blocked(mock_scan, mock_ctx, mock_payload, default_policy) -> None: +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_with_secrets_blocked( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: """Test that prompt with secrets is blocked.""" mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-456') @@ -97,8 +104,10 @@ def test_handle_before_submit_prompt_with_secrets_blocked(mock_scan, mock_ctx, m assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_PROMPT -@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') -def test_handle_before_submit_prompt_with_secrets_warned(mock_scan, mock_ctx, mock_payload, default_policy) -> None: +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_with_secrets_warned( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: """Test that prompt with secrets in warn mode is allowed.""" default_policy['prompt']['action'] = 'warn' mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-789') @@ -111,8 +120,10 @@ def test_handle_before_submit_prompt_with_secrets_warned(mock_scan, mock_ctx, mo assert call_args.args[2] == AIHookOutcome.WARNED -@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') -def test_handle_before_submit_prompt_scan_failure_fail_open(mock_scan, mock_ctx, mock_payload, default_policy) -> None: +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_submit_prompt_scan_failure_fail_open( + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] +) -> None: """Test that scan failure with fail_open=True allows the prompt.""" mock_scan.side_effect = RuntimeError('Scan failed') default_policy['fail_open'] = True @@ -128,9 +139,9 @@ def test_handle_before_submit_prompt_scan_failure_fail_open(mock_scan, mock_ctx, assert call_args.kwargs['block_reason'] is None -@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') def test_handle_before_submit_prompt_scan_failure_fail_closed( - mock_scan, mock_ctx, mock_payload, default_policy + mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] ) -> None: """Test that scan failure with fail_open=False blocks the prompt.""" mock_scan.side_effect = RuntimeError('Scan failed') @@ -149,7 +160,7 @@ def test_handle_before_submit_prompt_scan_failure_fail_closed( # Tests for handle_before_read_file -def test_handle_before_read_file_disabled(mock_ctx, default_policy) -> None: +def test_handle_before_read_file_disabled(mock_ctx: MagicMock, default_policy: dict[str, Any]) -> None: """Test that disabled file read scanning allows the file.""" default_policy['file_read']['enabled'] = False payload = AIHookPayload( @@ -163,8 +174,10 @@ def test_handle_before_read_file_disabled(mock_ctx, default_policy) -> None: assert result == {'permission': 'allow'} -@patch('cycode.cli.apps.scan.prompt.handlers.is_denied_path') -def test_handle_before_read_file_sensitive_path(mock_is_denied, mock_ctx, default_policy) -> None: +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +def test_handle_before_read_file_sensitive_path( + mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: """Test that sensitive path is blocked.""" mock_is_denied.return_value = True payload = AIHookPayload( @@ -183,9 +196,11 @@ def test_handle_before_read_file_sensitive_path(mock_is_denied, mock_ctx, defaul assert call_args.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH -@patch('cycode.cli.apps.scan.prompt.handlers.is_denied_path') -@patch('cycode.cli.apps.scan.prompt.handlers._scan_path_for_secrets') -def test_handle_before_read_file_no_secrets(mock_scan, mock_is_denied, mock_ctx, default_policy) -> None: +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_no_secrets( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: """Test that file with no secrets is allowed.""" mock_is_denied.return_value = False mock_scan.return_value = (None, 'scan-id-123') @@ -202,9 +217,11 @@ def test_handle_before_read_file_no_secrets(mock_scan, mock_is_denied, mock_ctx, assert call_args.args[2] == AIHookOutcome.ALLOWED -@patch('cycode.cli.apps.scan.prompt.handlers.is_denied_path') -@patch('cycode.cli.apps.scan.prompt.handlers._scan_path_for_secrets') -def test_handle_before_read_file_with_secrets(mock_scan, mock_is_denied, mock_ctx, default_policy) -> None: +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_with_secrets( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: """Test that file with secrets is blocked.""" mock_is_denied.return_value = False mock_scan.return_value = ('Found 1 secret: password', 'scan-id-456') @@ -223,9 +240,11 @@ def test_handle_before_read_file_with_secrets(mock_scan, mock_is_denied, mock_ct assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_FILE -@patch('cycode.cli.apps.scan.prompt.handlers.is_denied_path') -@patch('cycode.cli.apps.scan.prompt.handlers._scan_path_for_secrets') -def test_handle_before_read_file_scan_disabled(mock_scan, mock_is_denied, mock_ctx, default_policy) -> None: +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_scan_disabled( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: """Test that file is allowed when content scanning is disabled.""" mock_is_denied.return_value = False default_policy['file_read']['scan_content'] = False @@ -244,7 +263,7 @@ def test_handle_before_read_file_scan_disabled(mock_scan, mock_is_denied, mock_c # Tests for handle_before_mcp_execution -def test_handle_before_mcp_execution_disabled(mock_ctx, default_policy) -> None: +def test_handle_before_mcp_execution_disabled(mock_ctx: MagicMock, default_policy: dict[str, Any]) -> None: """Test that disabled MCP scanning allows the execution.""" default_policy['mcp']['enabled'] = False payload = AIHookPayload( @@ -259,8 +278,10 @@ def test_handle_before_mcp_execution_disabled(mock_ctx, default_policy) -> None: assert result == {'permission': 'allow'} -@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') -def test_handle_before_mcp_execution_no_secrets(mock_scan, mock_ctx, default_policy) -> None: +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_no_secrets( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: """Test that MCP execution with no secrets is allowed.""" mock_scan.return_value = (None, 'scan-id-123') payload = AIHookPayload( @@ -277,8 +298,10 @@ def test_handle_before_mcp_execution_no_secrets(mock_scan, mock_ctx, default_pol assert call_args.args[2] == AIHookOutcome.ALLOWED -@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') -def test_handle_before_mcp_execution_with_secrets_blocked(mock_scan, mock_ctx, default_policy) -> None: +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_with_secrets_blocked( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: """Test that MCP execution with secrets is blocked.""" mock_scan.return_value = ('Found 1 secret: token', 'scan-id-456') payload = AIHookPayload( @@ -297,8 +320,10 @@ def test_handle_before_mcp_execution_with_secrets_blocked(mock_scan, mock_ctx, d assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_MCP_ARGS -@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') -def test_handle_before_mcp_execution_with_secrets_warned(mock_scan, mock_ctx, default_policy) -> None: +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_with_secrets_warned( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: """Test that MCP execution with secrets in warn mode asks permission.""" mock_scan.return_value = ('Found 1 secret: token', 'scan-id-789') default_policy['mcp']['action'] = 'warn' @@ -317,8 +342,10 @@ def test_handle_before_mcp_execution_with_secrets_warned(mock_scan, mock_ctx, de assert call_args.args[2] == AIHookOutcome.WARNED -@patch('cycode.cli.apps.scan.prompt.handlers._scan_text_for_secrets') -def test_handle_before_mcp_execution_scan_disabled(mock_scan, mock_ctx, default_policy) -> None: +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') +def test_handle_before_mcp_execution_scan_disabled( + mock_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: """Test that MCP execution is allowed when argument scanning is disabled.""" default_policy['mcp']['scan_arguments'] = False payload = AIHookPayload( diff --git a/tests/cli/commands/scan/prompt/test_payload.py b/tests/cli/commands/ai_guardrails/scan/test_payload.py similarity index 96% rename from tests/cli/commands/scan/prompt/test_payload.py rename to tests/cli/commands/ai_guardrails/scan/test_payload.py index a0d9bd12..549cea65 100644 --- a/tests/cli/commands/scan/prompt/test_payload.py +++ b/tests/cli/commands/ai_guardrails/scan/test_payload.py @@ -2,8 +2,8 @@ import pytest -from cycode.cli.apps.scan.prompt.payload import AIHookPayload -from cycode.cli.apps.scan.prompt.types import AiHookEventType +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType def test_from_cursor_payload_prompt_event() -> None: diff --git a/tests/cli/commands/scan/prompt/test_policy.py b/tests/cli/commands/ai_guardrails/scan/test_policy.py similarity index 94% rename from tests/cli/commands/scan/prompt/test_policy.py rename to tests/cli/commands/ai_guardrails/scan/test_policy.py index 23aee4f7..8ad17d04 100644 --- a/tests/cli/commands/scan/prompt/test_policy.py +++ b/tests/cli/commands/ai_guardrails/scan/test_policy.py @@ -3,7 +3,7 @@ from pathlib import Path from unittest.mock import patch -from cycode.cli.apps.scan.prompt.policy import ( +from cycode.cli.apps.ai_guardrails.scan.policy import ( deep_merge, get_policy_value, load_defaults, @@ -125,7 +125,7 @@ def test_get_policy_value_non_dict_in_path() -> None: def test_load_policy_defaults_only() -> None: """Test loading policy with only defaults (no user or repo config).""" - with patch('cycode.cli.apps.scan.prompt.policy.load_yaml_file') as mock_load: + with patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') as mock_load: mock_load.return_value = None # No user or repo config policy = load_policy() @@ -160,9 +160,9 @@ def test_load_policy_with_repo_config(tmp_path: Path) -> None: repo_config = repo_config_dir / 'ai-guardrails.yaml' repo_config.write_text('mode: block\nprompt:\n enabled: false\n') - with patch('cycode.cli.apps.scan.prompt.policy.load_yaml_file') as mock_load: + with patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') as mock_load: - def side_effect(path: Path): + def side_effect(path: Path) -> dict | None: if path == repo_config: return {'mode': 'block', 'prompt': {'enabled': False}} return None @@ -205,7 +205,7 @@ def test_load_policy_precedence(tmp_path: Path) -> None: def test_load_policy_none_workspace_root() -> None: """Test that None workspace_root is handled correctly.""" - with patch('cycode.cli.apps.scan.prompt.policy.load_yaml_file') as mock_load: + with patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') as mock_load: mock_load.return_value = None policy = load_policy(None) diff --git a/tests/cli/commands/scan/prompt/test_response_builders.py b/tests/cli/commands/ai_guardrails/scan/test_response_builders.py similarity index 97% rename from tests/cli/commands/scan/prompt/test_response_builders.py rename to tests/cli/commands/ai_guardrails/scan/test_response_builders.py index 74071e11..86e87ca7 100644 --- a/tests/cli/commands/scan/prompt/test_response_builders.py +++ b/tests/cli/commands/ai_guardrails/scan/test_response_builders.py @@ -2,7 +2,7 @@ import pytest -from cycode.cli.apps.scan.prompt.response_builders import ( +from cycode.cli.apps.ai_guardrails.scan.response_builders import ( CursorResponseBuilder, IDEResponseBuilder, get_response_builder, diff --git a/tests/cli/commands/scan/prompt/test_utils.py b/tests/cli/commands/ai_guardrails/scan/test_utils.py similarity index 98% rename from tests/cli/commands/scan/prompt/test_utils.py rename to tests/cli/commands/ai_guardrails/scan/test_utils.py index c8e0f7d3..ce84c609 100644 --- a/tests/cli/commands/scan/prompt/test_utils.py +++ b/tests/cli/commands/ai_guardrails/scan/test_utils.py @@ -1,6 +1,6 @@ """Tests for AI guardrails utility functions.""" -from cycode.cli.apps.scan.prompt.utils import ( +from cycode.cli.apps.ai_guardrails.scan.utils import ( is_denied_path, matches_glob, normalize_path, From 9970be7350da30ea6697f4096acc5c45a237bab5 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Wed, 28 Jan 2026 17:31:06 +0200 Subject: [PATCH 12/22] CM-58022-change units to fakefs --- .../ai_guardrails/scan/test_policy.py | 137 ++++++++---------- 1 file changed, 61 insertions(+), 76 deletions(-) diff --git a/tests/cli/commands/ai_guardrails/scan/test_policy.py b/tests/cli/commands/ai_guardrails/scan/test_policy.py index 8ad17d04..bbe884b0 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_policy.py +++ b/tests/cli/commands/ai_guardrails/scan/test_policy.py @@ -1,7 +1,10 @@ """Tests for AI guardrails policy loading and management.""" from pathlib import Path -from unittest.mock import patch +from typing import Optional +from unittest.mock import MagicMock, patch + +from pyfakefs.fake_filesystem import FakeFilesystem from cycode.cli.apps.ai_guardrails.scan.policy import ( deep_merge, @@ -39,36 +42,33 @@ def test_deep_merge_override_with_non_dict() -> None: assert result == {'key': 'simple_value'} -def test_load_yaml_file_nonexistent(tmp_path: Path) -> None: +def test_load_yaml_file_nonexistent(fs: FakeFilesystem) -> None: """Test loading a non-existent file returns None.""" - result = load_yaml_file(tmp_path / 'nonexistent.yaml') + result = load_yaml_file(Path('/fake/nonexistent.yaml')) assert result is None -def test_load_yaml_file_valid_yaml(tmp_path: Path) -> None: +def test_load_yaml_file_valid_yaml(fs: FakeFilesystem) -> None: """Test loading a valid YAML file.""" - yaml_file = tmp_path / 'config.yaml' - yaml_file.write_text('mode: block\nfail_open: true\n') + fs.create_file('/fake/config.yaml', contents='mode: block\nfail_open: true\n') - result = load_yaml_file(yaml_file) + result = load_yaml_file(Path('/fake/config.yaml')) assert result == {'mode': 'block', 'fail_open': True} -def test_load_yaml_file_valid_json(tmp_path: Path) -> None: +def test_load_yaml_file_valid_json(fs: FakeFilesystem) -> None: """Test loading a valid JSON file.""" - json_file = tmp_path / 'config.json' - json_file.write_text('{"mode": "block", "fail_open": true}') + fs.create_file('/fake/config.json', contents='{"mode": "block", "fail_open": true}') - result = load_yaml_file(json_file) + result = load_yaml_file(Path('/fake/config.json')) assert result == {'mode': 'block', 'fail_open': True} -def test_load_yaml_file_invalid_yaml(tmp_path: Path) -> None: +def test_load_yaml_file_invalid_yaml(fs: FakeFilesystem) -> None: """Test loading an invalid YAML file returns None.""" - yaml_file = tmp_path / 'invalid.yaml' - yaml_file.write_text('{ invalid yaml content [') + fs.create_file('/fake/invalid.yaml', contents='{ invalid yaml content [') - result = load_yaml_file(yaml_file) + result = load_yaml_file(Path('/fake/invalid.yaml')) assert result is None @@ -123,92 +123,77 @@ def test_get_policy_value_non_dict_in_path() -> None: assert get_policy_value(policy, 'key', 'nested', default='default') == 'default' -def test_load_policy_defaults_only() -> None: +@patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') +def test_load_policy_defaults_only(mock_load: MagicMock) -> None: """Test loading policy with only defaults (no user or repo config).""" - with patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') as mock_load: - mock_load.return_value = None # No user or repo config + mock_load.return_value = None # No user or repo config - policy = load_policy() + policy = load_policy() - assert 'mode' in policy - assert 'fail_open' in policy + assert 'mode' in policy + assert 'fail_open' in policy -def test_load_policy_with_user_config(tmp_path: Path) -> None: +@patch('pathlib.Path.home') +def test_load_policy_with_user_config(mock_home: MagicMock, fs: FakeFilesystem) -> None: """Test loading policy with user config override.""" - with patch('pathlib.Path.home') as mock_home: - mock_home.return_value = tmp_path + mock_home.return_value = Path('/home/testuser') - # Create user config - user_config_dir = tmp_path / '.cycode' - user_config_dir.mkdir() - user_config = user_config_dir / 'ai-guardrails.yaml' - user_config.write_text('mode: warn\nfail_open: false\n') + # Create user config in fake filesystem + fs.create_file('/home/testuser/.cycode/ai-guardrails.yaml', contents='mode: warn\nfail_open: false\n') - policy = load_policy() + policy = load_policy() - # User config should override defaults - assert policy['mode'] == 'warn' - assert policy['fail_open'] is False + # User config should override defaults + assert policy['mode'] == 'warn' + assert policy['fail_open'] is False -def test_load_policy_with_repo_config(tmp_path: Path) -> None: +@patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') +def test_load_policy_with_repo_config(mock_load: MagicMock) -> None: """Test loading policy with repo config (highest precedence).""" - # Create repo config - repo_config_dir = tmp_path / '.cycode' - repo_config_dir.mkdir() - repo_config = repo_config_dir / 'ai-guardrails.yaml' - repo_config.write_text('mode: block\nprompt:\n enabled: false\n') + repo_path = Path('/fake/repo') + repo_config = repo_path / '.cycode' / 'ai-guardrails.yaml' - with patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') as mock_load: + def side_effect(path: Path) -> Optional[dict]: + if path == repo_config: + return {'mode': 'block', 'prompt': {'enabled': False}} + return None - def side_effect(path: Path) -> dict | None: - if path == repo_config: - return {'mode': 'block', 'prompt': {'enabled': False}} - return None + mock_load.side_effect = side_effect - mock_load.side_effect = side_effect + policy = load_policy(str(repo_path)) - policy = load_policy(str(tmp_path)) + # Repo config should have highest precedence + assert policy['mode'] == 'block' + assert policy['prompt']['enabled'] is False - # Repo config should have highest precedence - assert policy['mode'] == 'block' - assert policy['prompt']['enabled'] is False - -def test_load_policy_precedence(tmp_path: Path) -> None: +@patch('pathlib.Path.home') +def test_load_policy_precedence(mock_home: MagicMock, fs: FakeFilesystem) -> None: """Test that policy precedence is: defaults < user < repo.""" - with patch('pathlib.Path.home') as mock_home: - mock_home.return_value = tmp_path + mock_home.return_value = Path('/home/testuser') - # Create user config - user_config_dir = tmp_path / '.cycode' - user_config_dir.mkdir() - user_config = user_config_dir / 'ai-guardrails.yaml' - user_config.write_text('mode: warn\nfail_open: false\n') + # Create user config + fs.create_file('/home/testuser/.cycode/ai-guardrails.yaml', contents='mode: warn\nfail_open: false\n') - # Create repo config in a different location - repo_path = tmp_path / 'repo' - repo_path.mkdir() - repo_config_dir = repo_path / '.cycode' - repo_config_dir.mkdir() - repo_config = repo_config_dir / 'ai-guardrails.yaml' - repo_config.write_text('mode: block\n') # Override mode but not fail_open + # Create repo config + fs.create_file('/fake/repo/.cycode/ai-guardrails.yaml', contents='mode: block\n') - policy = load_policy(str(repo_path)) + policy = load_policy('/fake/repo') - # mode should come from repo (highest precedence) - assert policy['mode'] == 'block' - # fail_open should come from user config (repo doesn't override it) - assert policy['fail_open'] is False + # mode should come from repo (highest precedence) + assert policy['mode'] == 'block' + # fail_open should come from user config (repo doesn't override it) + assert policy['fail_open'] is False -def test_load_policy_none_workspace_root() -> None: +@patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') +def test_load_policy_none_workspace_root(mock_load: MagicMock) -> None: """Test that None workspace_root is handled correctly.""" - with patch('cycode.cli.apps.ai_guardrails.scan.policy.load_yaml_file') as mock_load: - mock_load.return_value = None + mock_load.return_value = None - policy = load_policy(None) + policy = load_policy(None) - # Should only load defaults (no repo config) - assert 'mode' in policy + # Should only load defaults (no repo config) + assert 'mode' in policy From d7949382bfa8a5cbd90f5fc77b96e6984d22daed Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Wed, 28 Jan 2026 17:58:16 +0200 Subject: [PATCH 13/22] CM-58022-rename scan type name --- cycode/cli/apps/ai_guardrails/scan/handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 69a7fb6a..dba7769b 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -250,7 +250,7 @@ def _setup_scan_context(ctx: typer.Context) -> typer.Context: ctx.obj['sync'] = True # Synchronous scan # Set command name for scan logic - ctx.info_name = 'prompt' + ctx.info_name = 'ai-guardrails' return ctx From 48d9d2ff26a21fdacf045ec20c28aedd9b536519 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Thu, 29 Jan 2026 14:54:45 +0200 Subject: [PATCH 14/22] CM-58022-added mcp server name removed cycode marker add ai-guardrails scan type --- cycode/cli/apps/ai_guardrails/consts.py | 6 +----- cycode/cli/apps/ai_guardrails/hooks_manager.py | 16 +++++----------- cycode/cli/apps/ai_guardrails/scan/handlers.py | 2 ++ cycode/cli/apps/ai_guardrails/scan/payload.py | 2 ++ cycode/cli/apps/scan/code_scanner.py | 2 +- cycode/cyclient/ai_security_manager_client.py | 1 + .../commands/ai_guardrails/scan/test_payload.py | 10 ++++++---- 7 files changed, 18 insertions(+), 21 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index 589a3b34..21d89a3f 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -56,11 +56,8 @@ def _get_cursor_hooks_dir() -> Path: # Default IDE DEFAULT_IDE = AIIDEType.CURSOR -# Marker to identify Cycode hooks -CYCODE_MARKER = 'cycode_guardrails' - # Command used in hooks -CYCODE_SCAN_PROMPT_COMMAND = 'cycode scan prompt' +CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan' def get_hooks_config(ide: AIIDEType) -> dict: @@ -78,5 +75,4 @@ def get_hooks_config(ide: AIIDEType) -> dict: return { 'version': 1, 'hooks': hooks, - CYCODE_MARKER: True, } diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index 5d44b07f..42f879f6 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -10,7 +10,6 @@ from typing import Optional from cycode.cli.apps.ai_guardrails.consts import ( - CYCODE_MARKER, CYCODE_SCAN_PROMPT_COMMAND, DEFAULT_IDE, IDE_CONFIGS, @@ -100,9 +99,6 @@ def install_hooks( for entry in entries: existing['hooks'][event].append(entry) - # Add marker - existing[CYCODE_MARKER] = True - # Save if save_hooks_file(hooks_path, existing): return True, f'AI guardrails hooks installed: {hooks_path}' @@ -140,11 +136,6 @@ def uninstall_hooks( if not existing['hooks'][event]: del existing['hooks'][event] - # Remove marker - if CYCODE_MARKER in existing: - del existing[CYCODE_MARKER] - modified = True - if not modified: return True, 'No Cycode hooks found to remove' @@ -190,17 +181,20 @@ def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide: if existing is None: return status - status['cycode_installed'] = existing.get(CYCODE_MARKER, False) - # Check each hook event for this IDE ide_config = IDE_CONFIGS[ide] + has_cycode_hooks = False for event in ide_config.hook_events: entries = existing.get('hooks', {}).get(event, []) cycode_entries = [e for e in entries if is_cycode_hook_entry(e)] + if cycode_entries: + has_cycode_hooks = True status['hooks'][event] = { 'total_entries': len(entries), 'cycode_entries': len(cycode_entries), 'enabled': len(cycode_entries) > 0, } + status['cycode_installed'] = has_cycode_hooks + return status diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index dba7769b..334e3f33 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -248,6 +248,8 @@ def _setup_scan_context(ctx: typer.Context) -> typer.Context: # Set up minimal required context ctx.obj['progress_bar'] = DummyProgressBar([ScanProgressBarSection]) ctx.obj['sync'] = True # Synchronous scan + ctx.obj['scan_type'] = ScanTypeOption.SECRET # AI guardrails always scans for secrets + ctx.obj['severity_threshold'] = SeverityOption.INFO # Report all severities # Set command name for scan logic ctx.info_name = 'ai-guardrails' diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index 4fafbf24..83787348 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -24,6 +24,7 @@ class AIHookPayload: # Event-specific data prompt: Optional[str] = None # For prompt events file_path: Optional[str] = None # For file_read events + mcp_server_name: Optional[str] = None # For mcp_execution events mcp_tool_name: Optional[str] = None # For mcp_execution events mcp_arguments: Optional[dict] = None # For mcp_execution events @@ -47,6 +48,7 @@ def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload': ide_version=payload.get('cursor_version'), prompt=payload.get('prompt', ''), file_path=payload.get('file_path') or payload.get('path'), + mcp_server_name=payload.get('command'), # MCP server name mcp_tool_name=payload.get('tool_name') or payload.get('tool'), mcp_arguments=payload.get('arguments') or payload.get('tool_input') or payload.get('input'), ) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index f43c02e7..f58dc0ca 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -91,7 +91,7 @@ def _should_use_sync_flow(command_scan_type: str, scan_type: str, sync_option: b if not sync_option and scan_type != consts.IAC_SCAN_TYPE: return False - if command_scan_type not in {'path', 'repository', 'prompt'}: + if command_scan_type not in {'path', 'repository', 'ai-guardrails'}: return False if scan_type == consts.IAC_SCAN_TYPE: diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 19addd06..627e2b33 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -75,6 +75,7 @@ def create_event( 'generation_id': payload.generation_id, 'block_reason': block_reason, 'cli_scan_id': scan_id, + 'mcp_server_name': payload.mcp_server_name, 'mcp_tool_name': payload.mcp_tool_name, } diff --git a/tests/cli/commands/ai_guardrails/scan/test_payload.py b/tests/cli/commands/ai_guardrails/scan/test_payload.py index 549cea65..90e45c2f 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_payload.py +++ b/tests/cli/commands/ai_guardrails/scan/test_payload.py @@ -50,15 +50,17 @@ def test_from_cursor_payload_mcp_execution_event() -> None: cursor_payload = { 'hook_event_name': 'beforeMCPExecution', 'conversation_id': 'conv-123', - 'tool_name': 'execute_command', - 'arguments': {'command': 'ls -la', 'secret': 'password123'}, + 'command': 'GitLab', + 'tool_name': 'discussion_list', + 'arguments': {'resource_type': 'merge_request', 'parent_id': 'organization/repo', 'resource_id': '4'}, } unified = AIHookPayload.from_cursor_payload(cursor_payload) assert unified.event_name == AiHookEventType.MCP_EXECUTION - assert unified.mcp_tool_name == 'execute_command' - assert unified.mcp_arguments == {'command': 'ls -la', 'secret': 'password123'} + assert unified.mcp_server_name == 'GitLab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == {'resource_type': 'merge_request', 'parent_id': 'organization/repo', 'resource_id': '4'} def test_from_cursor_payload_with_alternative_field_names() -> None: From 0dccae8e5d75b465ad4ed1e9bd3d9c73256ff7f5 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Thu, 29 Jan 2026 16:08:54 +0200 Subject: [PATCH 15/22] CM-58022-lint --- cycode/cli/apps/ai_guardrails/scan/handlers.py | 6 ++---- tests/cli/commands/ai_guardrails/scan/test_payload.py | 6 +++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 334e3f33..310a09aa 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -17,12 +17,10 @@ from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason -from cycode.cli.apps.ai_guardrails.scan.utils import ( - is_denied_path, - truncate_utf8, -) +from cycode.cli.apps.ai_guardrails.scan.utils import is_denied_path, truncate_utf8 from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func from cycode.cli.apps.scan.scan_parameters import get_scan_parameters +from cycode.cli.cli_types import ScanTypeOption, SeverityOption from cycode.cli.models import Document from cycode.cli.utils.progress_bar import DummyProgressBar, ScanProgressBarSection from cycode.cli.utils.scan_utils import build_violation_summary diff --git a/tests/cli/commands/ai_guardrails/scan/test_payload.py b/tests/cli/commands/ai_guardrails/scan/test_payload.py index 90e45c2f..9d14dda3 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_payload.py +++ b/tests/cli/commands/ai_guardrails/scan/test_payload.py @@ -60,7 +60,11 @@ def test_from_cursor_payload_mcp_execution_event() -> None: assert unified.event_name == AiHookEventType.MCP_EXECUTION assert unified.mcp_server_name == 'GitLab' assert unified.mcp_tool_name == 'discussion_list' - assert unified.mcp_arguments == {'resource_type': 'merge_request', 'parent_id': 'organization/repo', 'resource_id': '4'} + assert unified.mcp_arguments == { + 'resource_type': 'merge_request', + 'parent_id': 'organization/repo', + 'resource_id': '4', + } def test_from_cursor_payload_with_alternative_field_names() -> None: From 87cd5b2bb26f25ef46cdf3871c994aa1627b44bf Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Thu, 29 Jan 2026 16:15:39 +0200 Subject: [PATCH 16/22] CM-58022-hide ai-guardrails help for now --- cycode/cli/apps/ai_guardrails/__init__.py | 10 ++++++---- cycode/cli/apps/ai_guardrails/scan/handlers.py | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/__init__.py b/cycode/cli/apps/ai_guardrails/__init__.py index 0538ce45..f8486ed4 100644 --- a/cycode/cli/apps/ai_guardrails/__init__.py +++ b/cycode/cli/apps/ai_guardrails/__init__.py @@ -5,11 +5,13 @@ from cycode.cli.apps.ai_guardrails.status_command import status_command from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command -app = typer.Typer(name='ai-guardrails', no_args_is_help=True) +app = typer.Typer(name='ai-guardrails', no_args_is_help=True, hidden=True) -app.command(name='install', short_help='Install AI guardrails hooks for supported IDEs.')(install_command) -app.command(name='uninstall', short_help='Remove AI guardrails hooks from supported IDEs.')(uninstall_command) -app.command(name='status', short_help='Show AI guardrails hook installation status.')(status_command) +app.command(hidden=True, name='install', short_help='Install AI guardrails hooks for supported IDEs.')(install_command) +app.command(hidden=True, name='uninstall', short_help='Remove AI guardrails hooks from supported IDEs.')( + uninstall_command +) +app.command(hidden=True, name='status', short_help='Show AI guardrails hook installation status.')(status_command) app.command( hidden=True, name='scan', diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 310a09aa..8a1b5bd0 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -246,7 +246,7 @@ def _setup_scan_context(ctx: typer.Context) -> typer.Context: # Set up minimal required context ctx.obj['progress_bar'] = DummyProgressBar([ScanProgressBarSection]) ctx.obj['sync'] = True # Synchronous scan - ctx.obj['scan_type'] = ScanTypeOption.SECRET # AI guardrails always scans for secrets + ctx.obj['scan_type'] = ScanTypeOption.SECRET # AI guardrails always scans for secrets ctx.obj['severity_threshold'] = SeverityOption.INFO # Report all severities # Set command name for scan logic From 3418f87dcdca26b058af8c4216c760057a402ddd Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Mon, 2 Feb 2026 15:05:17 +0200 Subject: [PATCH 17/22] CM-58331-support-claude-code --- cycode/cli/apps/ai_guardrails/consts.py | 74 +++++- .../cli/apps/ai_guardrails/hooks_manager.py | 22 +- .../cli/apps/ai_guardrails/scan/handlers.py | 66 ++--- cycode/cli/apps/ai_guardrails/scan/payload.py | 194 +++++++++++++- .../ai_guardrails/scan/response_builders.py | 47 ++++ .../apps/ai_guardrails/scan/scan_command.py | 13 + cycode/cli/apps/ai_guardrails/scan/types.py | 11 + .../ai_guardrails/scan/test_handlers.py | 4 +- .../ai_guardrails/scan/test_payload.py | 241 ++++++++++++++++++ .../scan/test_response_builders.py | 69 +++++ .../ai_guardrails/scan/test_scan_command.py | 138 ++++++++++ .../ai_guardrails/test_hooks_manager.py | 53 ++++ 12 files changed, 883 insertions(+), 49 deletions(-) create mode 100644 tests/cli/commands/ai_guardrails/scan/test_scan_command.py create mode 100644 tests/cli/commands/ai_guardrails/test_hooks_manager.py diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index 21d89a3f..fc67c348 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -2,12 +2,7 @@ Currently supports: - Cursor - -To add a new IDE (e.g., Claude Code): -1. Add new value to AIIDEType enum -2. Create _get__hooks_dir() function with platform-specific paths -3. Add entry to IDE_CONFIGS dict with IDE-specific hook event names -4. Unhide --ide option in commands (install, uninstall, status) +- Claude Code """ import platform @@ -20,6 +15,7 @@ class AIIDEType(str, Enum): """Supported AI IDE types.""" CURSOR = 'cursor' + CLAUDE_CODE = 'claude-code' class IDEConfig(NamedTuple): @@ -42,6 +38,14 @@ def _get_cursor_hooks_dir() -> Path: return Path.home() / '.config' / 'Cursor' +def _get_claude_code_hooks_dir() -> Path: + """Get Claude Code hooks directory. + + Claude Code uses ~/.claude on all platforms. + """ + return Path.home() / '.claude' + + # IDE-specific configurations IDE_CONFIGS: dict[AIIDEType, IDEConfig] = { AIIDEType.CURSOR: IDEConfig( @@ -51,6 +55,13 @@ def _get_cursor_hooks_dir() -> Path: hooks_file_name='hooks.json', hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'], ), + AIIDEType.CLAUDE_CODE: IDEConfig( + name='Claude Code', + hooks_dir=_get_claude_code_hooks_dir(), + repo_hooks_subdir='.claude', + hooks_file_name='settings.json', + hook_events=['UserPromptSubmit', 'PreToolUse'], + ), } # Default IDE @@ -60,6 +71,47 @@ def _get_cursor_hooks_dir() -> Path: CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan' +def _get_cursor_hooks_config() -> dict: + """Get Cursor-specific hooks configuration.""" + config = IDE_CONFIGS[AIIDEType.CURSOR] + hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events} + + return { + 'version': 1, + 'hooks': hooks, + } + + +def _get_claude_code_hooks_config() -> dict: + """Get Claude Code-specific hooks configuration. + + Claude Code uses a different hook format with nested structure: + - hooks are arrays of objects with 'hooks' containing command arrays + - PreToolUse uses 'matcher' field to specify which tools to intercept + """ + command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code' + + return { + 'hooks': { + 'UserPromptSubmit': [ + { + 'hooks': [{'type': 'command', 'command': command}], + } + ], + 'PreToolUse': [ + { + 'matcher': 'Read', + 'hooks': [{'type': 'command', 'command': command}], + }, + { + 'matcher': 'mcp__.*', + 'hooks': [{'type': 'command', 'command': command}], + }, + ], + }, + } + + def get_hooks_config(ide: AIIDEType) -> dict: """Get the hooks configuration for a specific IDE. @@ -69,10 +121,6 @@ def get_hooks_config(ide: AIIDEType) -> dict: Returns: Dict with hooks configuration for the specified IDE """ - config = IDE_CONFIGS[ide] - hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events} - - return { - 'version': 1, - 'hooks': hooks, - } + if ide == AIIDEType.CLAUDE_CODE: + return _get_claude_code_hooks_config() + return _get_cursor_hooks_config() diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index 42f879f6..985b0258 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -59,9 +59,27 @@ def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: def is_cycode_hook_entry(entry: dict) -> bool: - """Check if a hook entry is from cycode-cli.""" + """Check if a hook entry is from cycode-cli. + + Handles both Cursor format (flat) and Claude Code format (nested). + + Cursor format: {"command": "cycode ai-guardrails scan"} + Claude Code format: {"hooks": [{"type": "command", "command": "cycode ai-guardrails scan --ide claude-code"}]} + """ + # Check Cursor format (flat command) command = entry.get('command', '') - return CYCODE_SCAN_PROMPT_COMMAND in command + if CYCODE_SCAN_PROMPT_COMMAND in 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: + return True + + return False def install_hooks( diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 95e9d606..83edd89d 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -59,25 +59,19 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli try: violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) - if ( - violation_summary - and get_policy_value(prompt_config, 'action', default='block') == 'block' - and mode == 'block' - ): - outcome = AIHookOutcome.BLOCKED + if violation_summary: block_reason = BlockReason.SECRETS_IN_PROMPT - user_message = f'{violation_summary}. Remove secrets before sending.' - response = response_builder.deny_prompt(user_message) - else: - if violation_summary: - outcome = AIHookOutcome.WARNED - response = response_builder.allow_prompt() - return response + if get_policy_value(prompt_config, 'action', default='block') == 'block' and mode == 'block': + outcome = AIHookOutcome.BLOCKED + user_message = f'{violation_summary}. Remove secrets before sending.' + return response_builder.deny_prompt(user_message) + outcome = AIHookOutcome.WARNED + return response_builder.allow_prompt() except Exception as e: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED ) - block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + block_reason = BlockReason.SCAN_FAILURE raise e finally: ai_client.create_event( @@ -116,28 +110,42 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: try: # Check path-based denylist first - if is_denied_path(file_path, policy) and action == 'block': - outcome = AIHookOutcome.BLOCKED + if is_denied_path(file_path, policy): block_reason = BlockReason.SENSITIVE_PATH - user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).' - return response_builder.deny_permission( + if mode == 'block' and action == 'block': + outcome = AIHookOutcome.BLOCKED + user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).' + return response_builder.deny_permission( + user_message, + 'This file path is classified as sensitive; do not read/send it to the model.', + ) + # Warn mode - ask user for permission + outcome = AIHookOutcome.WARNED + user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?' + return response_builder.ask_permission( user_message, - 'This file path is classified as sensitive; do not read/send it to the model.', + 'This file path is classified as sensitive; proceed with caution.', ) # Scan file content if enabled if get_policy_value(file_read_config, 'scan_content', default=True): violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy) - if violation_summary and action == 'block' and mode == 'block': - outcome = AIHookOutcome.BLOCKED + if violation_summary: block_reason = BlockReason.SECRETS_IN_FILE - user_message = f'Cycode blocked reading {file_path}. {violation_summary}' - return response_builder.deny_permission( + if mode == 'block' and action == 'block': + outcome = AIHookOutcome.BLOCKED + user_message = f'Cycode blocked reading {file_path}. {violation_summary}' + return response_builder.deny_permission( + user_message, + 'Secrets detected; do not send this file to the model.', + ) + # Warn mode - ask user for permission + outcome = AIHookOutcome.WARNED + user_message = f'Cycode detected secrets in {file_path}. {violation_summary}' + return response_builder.ask_permission( user_message, - 'Secrets detected; do not send this file to the model.', + 'Possible secrets detected; proceed with caution.', ) - if violation_summary: - outcome = AIHookOutcome.WARNED return response_builder.allow_permission() return response_builder.allow_permission() @@ -145,7 +153,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED ) - block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + block_reason = BlockReason.SCAN_FAILURE raise e finally: ai_client.create_event( @@ -192,9 +200,9 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli if get_policy_value(mcp_config, 'scan_arguments', default=True): violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) if violation_summary: + block_reason = BlockReason.SECRETS_IN_MCP_ARGS if mode == 'block' and action == 'block': outcome = AIHookOutcome.BLOCKED - block_reason = BlockReason.SECRETS_IN_MCP_ARGS user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}' return response_builder.deny_permission( user_message, @@ -211,7 +219,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED ) - block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None + block_reason = BlockReason.SCAN_FAILURE raise e finally: ai_client.create_event( diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index 83787348..5206ca3e 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -1,9 +1,112 @@ """Unified payload object for AI hook events from different tools.""" +import json +from collections.abc import Iterator from dataclasses import dataclass +from pathlib import Path from typing import Optional -from cycode.cli.apps.ai_guardrails.scan.types import CURSOR_EVENT_MAPPING +from cycode.cli.apps.ai_guardrails.scan.types import ( + CLAUDE_CODE_EVENT_MAPPING, + CLAUDE_CODE_EVENT_NAMES, + CURSOR_EVENT_MAPPING, + CURSOR_EVENT_NAMES, + AiHookEventType, +) + + +def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]: + """Read a file line by line from the end without loading entire file into memory. + + Yields lines in reverse order (last line first). + """ + with path.open('rb') as f: + f.seek(0, 2) # Seek to end + file_size = f.tell() + if file_size == 0: + return + + remaining = file_size + buffer = b'' + + while remaining > 0: + # Read a chunk from the end + read_size = min(buf_size, remaining) + remaining -= read_size + f.seek(remaining) + chunk = f.read(read_size) + buffer = chunk + buffer + + # Yield complete lines from buffer + while b'\n' in buffer: + # Find the last newline + newline_pos = buffer.rfind(b'\n') + if newline_pos == len(buffer) - 1: + # Trailing newline, look for previous one + newline_pos = buffer.rfind(b'\n', 0, newline_pos) + if newline_pos == -1: + break + # Yield the line after this newline + line = buffer[newline_pos + 1 :] + buffer = buffer[: newline_pos + 1] + if line.strip(): + yield line.decode('utf-8', errors='replace') + + # Yield any remaining content as the first line of the file + if buffer.strip(): + yield buffer.decode('utf-8', errors='replace') + + +def _extract_from_claude_transcript( # noqa: C901 + transcript_path: str, +) -> tuple[Optional[str], Optional[str], Optional[str]]: + """Extract IDE version, model, and latest generation ID from Claude Code transcript file. + + The transcript is a JSONL file where each line is a JSON object. + We look for 'version' (IDE version), 'model', and 'uuid' (generation ID) fields. + The generation_id is the UUID of the latest 'user' type message. + + Scans from end to start since latest entries are at the end. + Uses reverse reading to avoid loading entire file into memory. + + Returns: + Tuple of (ide_version, model, generation_id), any may be None if not found. + """ + if not transcript_path: + return None, None, None + + path = Path(transcript_path) + if not path.exists(): + return None, None, None + + ide_version = None + model = None + generation_id = None + + try: + for line in _reverse_readline(path): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + if ide_version is None and 'version' in entry: + ide_version = entry['version'] + # Model can be at top level or nested in message.model + if model is None: + model = entry.get('model') or (entry.get('message') or {}).get('model') + # Get the latest user message UUID as generation_id + if generation_id is None and entry.get('type') == 'user' and entry.get('uuid'): + generation_id = entry['uuid'] + # Stop early if we found all values + if ide_version is not None and model is not None and generation_id is not None: + break + except json.JSONDecodeError: + continue + except OSError: + pass + + return ide_version, model, generation_id @dataclass @@ -53,13 +156,96 @@ def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload': mcp_arguments=payload.get('arguments') or payload.get('tool_input') or payload.get('input'), ) + @classmethod + def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload': + """Create AIHookPayload from Claude Code IDE payload. + + Claude Code has a different structure: + - hook_event_name: 'UserPromptSubmit' or 'PreToolUse' + - For PreToolUse: tool_name determines if it's file read ('Read') or MCP ('mcp__*') + - tool_input contains tool arguments (e.g., file_path for Read tool) + - transcript_path points to JSONL file with version and model info + """ + hook_event_name = payload.get('hook_event_name', '') + tool_name = payload.get('tool_name', '') + tool_input = payload.get('tool_input') + + if hook_event_name == 'UserPromptSubmit': + canonical_event = AiHookEventType.PROMPT + elif hook_event_name == 'PreToolUse': + canonical_event = AiHookEventType.FILE_READ if tool_name == 'Read' else AiHookEventType.MCP_EXECUTION + else: + # Unknown event, use the raw event name + canonical_event = CLAUDE_CODE_EVENT_MAPPING.get(hook_event_name, hook_event_name) + + # Extract file_path from tool_input for Read tool + file_path = None + if tool_name == 'Read' and isinstance(tool_input, dict): + file_path = tool_input.get('file_path') + + # For MCP tools, the entire tool_input is the arguments + mcp_arguments = tool_input if tool_name.startswith('mcp__') else None + + # Extract MCP server and tool name from tool_name (format: mcp____) + mcp_server_name = None + mcp_tool_name = None + if tool_name.startswith('mcp__'): + parts = tool_name.split('__') + if len(parts) >= 2: + mcp_server_name = parts[1] + if len(parts) >= 3: + mcp_tool_name = parts[2] + + # Extract IDE version, model, and generation ID from transcript file + ide_version, model, generation_id = _extract_from_claude_transcript(payload.get('transcript_path')) + + return cls( + event_name=canonical_event, + conversation_id=payload.get('session_id'), + generation_id=generation_id, + ide_user_email=None, # Claude Code doesn't provide this in hook payload + model=model, + ide_provider='claude-code', + ide_version=ide_version, + prompt=payload.get('prompt', ''), + file_path=file_path, + mcp_server_name=mcp_server_name, + mcp_tool_name=mcp_tool_name, + mcp_arguments=mcp_arguments, + ) + + @staticmethod + def is_payload_for_ide(payload: dict, ide: str) -> bool: + """Check if the payload's event name matches the expected IDE. + + This prevents double-processing when Cursor reads Claude Code hooks + or vice versa. If the payload's hook_event_name doesn't match the + expected IDE's event names, we should skip processing. + + Args: + payload: The raw payload from the IDE + ide: The IDE name (e.g., 'cursor', 'claude-code') + + Returns: + True if the payload matches the IDE, False otherwise. + """ + hook_event_name = payload.get('hook_event_name', '') + + if ide == 'claude-code': + return hook_event_name in CLAUDE_CODE_EVENT_NAMES + if ide == 'cursor': + return hook_event_name in CURSOR_EVENT_NAMES + + # Unknown IDE, allow processing + return True + @classmethod def from_payload(cls, payload: dict, tool: str = 'cursor') -> 'AIHookPayload': """Create AIHookPayload from any tool's payload. Args: payload: The raw payload from the IDE - tool: The IDE/tool name (e.g., 'cursor') + tool: The IDE/tool name (e.g., 'cursor', 'claude-code') Returns: AIHookPayload instance @@ -69,4 +255,6 @@ def from_payload(cls, payload: dict, tool: str = 'cursor') -> 'AIHookPayload': """ if tool == 'cursor': return cls.from_cursor_payload(payload) - raise ValueError(f'Unsupported IDE/tool: {tool}.') + if tool == 'claude-code': + return cls.from_claude_code_payload(payload) + raise ValueError(f'Unsupported IDE/tool: {tool}') diff --git a/cycode/cli/apps/ai_guardrails/scan/response_builders.py b/cycode/cli/apps/ai_guardrails/scan/response_builders.py index 867965c3..6b183dc6 100644 --- a/cycode/cli/apps/ai_guardrails/scan/response_builders.py +++ b/cycode/cli/apps/ai_guardrails/scan/response_builders.py @@ -62,9 +62,56 @@ def deny_prompt(self, user_message: str) -> dict: return {'continue': False, 'user_message': user_message} +class ClaudeCodeResponseBuilder(IDEResponseBuilder): + """Response builder for Claude Code IDE hooks. + + Claude Code hook response formats: + - UserPromptSubmit: {} for allow, {"decision": "block", "reason": str} for deny + - PreToolUse: hookSpecificOutput with permissionDecision (allow/deny/ask) + """ + + def allow_permission(self) -> dict: + """Allow file read or MCP execution.""" + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'allow', + } + } + + def deny_permission(self, user_message: str, agent_message: str) -> dict: + """Deny file read or MCP execution.""" + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': user_message, + } + } + + def ask_permission(self, user_message: str, agent_message: str) -> dict: + """Ask user for permission (warn mode).""" + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'ask', + 'permissionDecisionReason': user_message, + } + } + + def allow_prompt(self) -> dict: + """Allow prompt submission (empty response means allow).""" + return {} + + def deny_prompt(self, user_message: str) -> dict: + """Deny prompt submission.""" + return {'decision': 'block', 'reason': user_message} + + # Registry of response builders by IDE name _RESPONSE_BUILDERS: dict[str, IDEResponseBuilder] = { 'cursor': CursorResponseBuilder(), + 'claude-code': ClaudeCodeResponseBuilder(), } diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index e08bb4de..0cf1b46c 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -88,6 +88,9 @@ def scan_command( stdin_data = sys.stdin.read().strip() payload = safe_json_parse(stdin_data) + with open ('/tmp/test.input', 'w') as f: + f.write(stdin_data) + tool = ide.lower() response_builder = get_response_builder(tool) @@ -96,6 +99,16 @@ def scan_command( output_json(response_builder.allow_prompt()) return + # Check if the payload matches the expected IDE - prevents double-processing + # when Cursor reads Claude Code hooks from ~/.claude/settings.json + if not AIHookPayload.is_payload_for_ide(payload, tool): + logger.debug( + 'Payload event does not match expected IDE, skipping', + extra={'hook_event_name': payload.get('hook_event_name'), 'expected_ide': tool}, + ) + output_json(response_builder.allow_prompt()) + return + unified_payload = AIHookPayload.from_payload(payload, tool=tool) event_name = unified_payload.event_name logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'tool': tool}) diff --git a/cycode/cli/apps/ai_guardrails/scan/types.py b/cycode/cli/apps/ai_guardrails/scan/types.py index 095ca61b..585c7820 100644 --- a/cycode/cli/apps/ai_guardrails/scan/types.py +++ b/cycode/cli/apps/ai_guardrails/scan/types.py @@ -31,6 +31,17 @@ class AiHookEventType(StrEnum): 'beforeMCPExecution': AiHookEventType.MCP_EXECUTION, } +# Claude Code event mapping - note that PreToolUse requires tool_name inspection +# to determine the actual event type (file read vs MCP execution) +CLAUDE_CODE_EVENT_MAPPING = { + 'UserPromptSubmit': AiHookEventType.PROMPT, + 'PreToolUse': None, # Requires tool_name inspection to determine actual type +} + +# Set of known event names per IDE (for IDE detection) +CURSOR_EVENT_NAMES = set(CURSOR_EVENT_MAPPING.keys()) +CLAUDE_CODE_EVENT_NAMES = set(CLAUDE_CODE_EVENT_MAPPING.keys()) + class AIHookOutcome(StrEnum): """Outcome of an AI hook event evaluation.""" diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 58dfe195..634469b7 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -135,8 +135,8 @@ def test_handle_before_submit_prompt_scan_failure_fail_open( mock_ctx.obj['ai_security_client'].create_event.assert_called_once() call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.ALLOWED - # When fail_open=True, no block_reason since action is allowed - assert call_args.kwargs['block_reason'] is None + # block_reason is set for tracking even when fail_open allows the action + assert call_args.kwargs['block_reason'] == BlockReason.SCAN_FAILURE @patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') diff --git a/tests/cli/commands/ai_guardrails/scan/test_payload.py b/tests/cli/commands/ai_guardrails/scan/test_payload.py index 9d14dda3..27c3010f 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_payload.py +++ b/tests/cli/commands/ai_guardrails/scan/test_payload.py @@ -1,6 +1,7 @@ """Tests for AI hook payload normalization.""" import pytest +from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -133,3 +134,243 @@ def test_from_cursor_payload_empty_fields() -> None: assert unified.conversation_id is None assert unified.prompt == '' # Default to empty string assert unified.ide_provider == 'cursor' + + +# Claude Code payload tests + + +def test_from_claude_code_payload_prompt_event() -> None: + """Test conversion of Claude Code UserPromptSubmit payload.""" + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'Test prompt for Claude Code', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == 'session-123' + assert unified.ide_provider == 'claude-code' + assert unified.prompt == 'Test prompt for Claude Code' + + +def test_from_claude_code_payload_file_read_event() -> None: + """Test conversion of Claude Code PreToolUse with Read tool.""" + claude_payload = { + 'hook_event_name': 'PreToolUse', + 'session_id': 'session-456', + 'tool_name': 'Read', + 'tool_input': {'file_path': '/path/to/secret.env'}, + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/path/to/secret.env' + assert unified.ide_provider == 'claude-code' + assert unified.mcp_tool_name is None + + +def test_from_claude_code_payload_mcp_execution_event() -> None: + """Test conversion of Claude Code PreToolUse with MCP tool.""" + claude_payload = { + 'hook_event_name': 'PreToolUse', + 'session_id': 'session-789', + 'tool_name': 'mcp__gitlab__discussion_list', + 'tool_input': {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'}, + } + + unified = AIHookPayload.from_payload(claude_payload, tool='claude-code') + + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'gitlab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'} + assert unified.ide_provider == 'claude-code' + + +def test_from_claude_code_payload_empty_fields() -> None: + """Test handling of empty/missing fields for Claude Code.""" + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + # Most fields missing + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id is None + assert unified.prompt == '' # Default to empty string + assert unified.ide_provider == 'claude-code' + + +# Claude Code transcript extraction tests + + +def test_from_claude_code_payload_extracts_from_transcript(mocker: MockerFixture) -> None: + """Test that version, model, and generation_id are extracted from transcript file.""" + transcript_content = ( + b'{"type":"user","version":"2.1.20","uuid":"user-uuid-1","message":{"role":"user","content":"hello"}}\n' + b'{"type":"assistant","message":{"model":"claude-opus-4-5-20251101","role":"assistant",' + b'"content":[{"type":"text","text":"Hi!"}]},"uuid":"assistant-uuid-1"}\n' + b'{"type":"user","version":"2.1.20","uuid":"user-uuid-2","message":{"role":"user","content":"test prompt"}}\n' + ) + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') + mock_path.return_value.exists.return_value = True + mock_path.return_value.open.return_value.__enter__.return_value.seek = mocker.Mock() + mock_path.return_value.open.return_value.__enter__.return_value.tell.return_value = len(transcript_content) + mock_path.return_value.open.return_value.__enter__.return_value.read.return_value = transcript_content + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test prompt', + 'transcript_path': '/mock/transcript.jsonl', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.ide_version == '2.1.20' + assert unified.model == 'claude-opus-4-5-20251101' + assert unified.generation_id == 'user-uuid-2' + + +def test_from_claude_code_payload_handles_missing_transcript(mocker: MockerFixture) -> None: + """Test that missing transcript file doesn't break payload parsing.""" + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') + mock_path.return_value.exists.return_value = False + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + 'transcript_path': '/nonexistent/path/transcript.jsonl', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.ide_version is None + assert unified.model is None + assert unified.generation_id is None + assert unified.conversation_id == 'session-123' + assert unified.prompt == 'test' + + +def test_from_claude_code_payload_handles_no_transcript_path() -> None: + """Test that absent transcript_path doesn't break payload parsing.""" + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.ide_version is None + assert unified.model is None + assert unified.generation_id is None + + +def test_from_claude_code_payload_extracts_model_from_nested_message(mocker: MockerFixture) -> None: + """Test that model is extracted from nested message.model field.""" + transcript_content = ( + b'{"type":"assistant","message":{"model":"claude-sonnet-4-20250514",' + b'"role":"assistant","content":[]},"uuid":"uuid-1"}\n' + ) + + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') + mock_path.return_value.exists.return_value = True + mock_path.return_value.open.return_value.__enter__.return_value.seek = mocker.Mock() + mock_path.return_value.open.return_value.__enter__.return_value.tell.return_value = len(transcript_content) + mock_path.return_value.open.return_value.__enter__.return_value.read.return_value = transcript_content + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'prompt': 'test', + 'transcript_path': '/mock/transcript.jsonl', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.model == 'claude-sonnet-4-20250514' + + +def test_from_claude_code_payload_gets_latest_user_uuid(mocker: MockerFixture) -> None: + """Test that generation_id is the UUID of the latest user message.""" + transcript_content = b"""{"type":"user","uuid":"old-user-uuid","message":{"role":"user","content":"first"}} +{"type":"assistant","uuid":"assistant-uuid","message":{"role":"assistant","content":[]}} +{"type":"user","uuid":"latest-user-uuid","message":{"role":"user","content":"second"}} +{"type":"assistant","uuid":"last-assistant-uuid","message":{"role":"assistant","content":[]}} +""" + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.scan.payload.Path') + mock_path.return_value.exists.return_value = True + mock_path.return_value.open.return_value.__enter__.return_value.seek = mocker.Mock() + mock_path.return_value.open.return_value.__enter__.return_value.tell.return_value = len(transcript_content) + mock_path.return_value.open.return_value.__enter__.return_value.read.return_value = transcript_content + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'prompt': 'test', + 'transcript_path': '/mock/transcript.jsonl', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + + assert unified.generation_id == 'latest-user-uuid' + + +# IDE detection tests + + +def test_is_payload_for_ide_claude_code_matches_claude_code() -> None: + """Test that Claude Code events match when expected IDE is claude-code.""" + payload = {'hook_event_name': 'UserPromptSubmit'} + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is True + + payload = {'hook_event_name': 'PreToolUse'} + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is True + + +def test_is_payload_for_ide_cursor_matches_cursor() -> None: + """Test that Cursor events match when expected IDE is cursor.""" + payload = {'hook_event_name': 'beforeSubmitPrompt'} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is True + + payload = {'hook_event_name': 'beforeReadFile'} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is True + + payload = {'hook_event_name': 'beforeMCPExecution'} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is True + + +def test_is_payload_for_ide_claude_code_does_not_match_cursor() -> None: + """Test that Claude Code events don't match when expected IDE is cursor. + + This prevents double-processing when Cursor reads Claude Code hooks. + """ + payload = {'hook_event_name': 'UserPromptSubmit'} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False + + payload = {'hook_event_name': 'PreToolUse'} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False + + +def test_is_payload_for_ide_cursor_does_not_match_claude_code() -> None: + """Test that Cursor events don't match when expected IDE is claude-code.""" + payload = {'hook_event_name': 'beforeSubmitPrompt'} + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False + + payload = {'hook_event_name': 'beforeReadFile'} + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False + + +def test_is_payload_for_ide_empty_event_name() -> None: + """Test handling of empty or missing hook_event_name.""" + payload = {'hook_event_name': ''} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False + + payload = {} + assert AIHookPayload.is_payload_for_ide(payload, 'cursor') is False + assert AIHookPayload.is_payload_for_ide(payload, 'claude-code') is False diff --git a/tests/cli/commands/ai_guardrails/scan/test_response_builders.py b/tests/cli/commands/ai_guardrails/scan/test_response_builders.py index 86e87ca7..45f80829 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_response_builders.py +++ b/tests/cli/commands/ai_guardrails/scan/test_response_builders.py @@ -3,6 +3,7 @@ import pytest from cycode.cli.apps.ai_guardrails.scan.response_builders import ( + ClaudeCodeResponseBuilder, CursorResponseBuilder, IDEResponseBuilder, get_response_builder, @@ -77,3 +78,71 @@ def test_cursor_response_builder_is_singleton() -> None: builder2 = get_response_builder('cursor') assert builder1 is builder2 + + +# Claude Code response builder tests + + +def test_claude_code_response_builder_allow_permission() -> None: + """Test Claude Code allow permission response.""" + builder = ClaudeCodeResponseBuilder() + response = builder.allow_permission() + + assert response == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'allow', + } + } + + +def test_claude_code_response_builder_deny_permission() -> None: + """Test Claude Code deny permission response with messages.""" + builder = ClaudeCodeResponseBuilder() + response = builder.deny_permission('User message', 'Agent message') + + assert response == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'User message', + } + } + + +def test_claude_code_response_builder_ask_permission() -> None: + """Test Claude Code ask permission response for warnings.""" + builder = ClaudeCodeResponseBuilder() + response = builder.ask_permission('Warning message', 'Agent warning') + + assert response == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'ask', + 'permissionDecisionReason': 'Warning message', + } + } + + +def test_claude_code_response_builder_allow_prompt() -> None: + """Test Claude Code allow prompt response (empty dict).""" + builder = ClaudeCodeResponseBuilder() + response = builder.allow_prompt() + + assert response == {} + + +def test_claude_code_response_builder_deny_prompt() -> None: + """Test Claude Code deny prompt response with message.""" + builder = ClaudeCodeResponseBuilder() + response = builder.deny_prompt('Secrets detected') + + assert response == {'decision': 'block', 'reason': 'Secrets detected'} + + +def test_get_response_builder_claude_code() -> None: + """Test getting Claude Code response builder.""" + builder = get_response_builder('claude-code') + + assert isinstance(builder, ClaudeCodeResponseBuilder) + assert isinstance(builder, IDEResponseBuilder) diff --git a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py new file mode 100644 index 00000000..d1473c69 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -0,0 +1,138 @@ +"""Tests for AI guardrails scan command.""" + +import json +from io import StringIO +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command + + +@pytest.fixture +def mock_ctx() -> MagicMock: + """Create a mock typer context.""" + ctx = MagicMock() + ctx.obj = {} + return ctx + + +@pytest.fixture +def mock_scan_command_deps(mocker: MockerFixture) -> dict[str, MagicMock]: + """Mock scan_command dependencies that should not be called on early exit.""" + return { + 'initialize_clients': mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command._initialize_clients'), + 'load_policy': mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.load_policy'), + 'get_handler': mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event'), + } + + +def _assert_no_api_calls(mocks: dict[str, MagicMock]) -> None: + """Assert that no API-related functions were called.""" + mocks['initialize_clients'].assert_not_called() + mocks['load_policy'].assert_not_called() + mocks['get_handler'].assert_not_called() + + +class TestIdeMismatchSkipsProcessing: + """Tests that verify IDE mismatch causes early exit without API calls.""" + + def test_claude_code_payload_with_cursor_ide( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test Claude Code payload is skipped when --ide cursor is specified. + + When Cursor reads Claude Code hooks from ~/.claude/settings.json, it will invoke + the hook with Claude Code event names. The scan command should skip processing. + """ + payload = {'hook_event_name': 'UserPromptSubmit', 'session_id': 'session-123', 'prompt': 'test'} + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + scan_command(mock_ctx, ide='cursor') + + _assert_no_api_calls(mock_scan_command_deps) + response = json.loads(capsys.readouterr().out) + assert response.get('continue') is True + + def test_cursor_payload_with_claude_code_ide( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test Cursor payload is skipped when --ide claude-code is specified.""" + payload = {'hook_event_name': 'beforeSubmitPrompt', 'conversation_id': 'conv-123', 'prompt': 'test'} + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + scan_command(mock_ctx, ide='claude-code') + + _assert_no_api_calls(mock_scan_command_deps) + response = json.loads(capsys.readouterr().out) + assert response == {} # Claude Code allow_prompt returns empty dict + + +class TestInvalidPayloadSkipsProcessing: + """Tests that verify invalid payloads cause early exit without API calls.""" + + def test_empty_payload( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test empty payload skips processing.""" + mocker.patch('sys.stdin', StringIO('')) + + scan_command(mock_ctx, ide='cursor') + + mock_scan_command_deps['initialize_clients'].assert_not_called() + response = json.loads(capsys.readouterr().out) + assert response.get('continue') is True + + def test_invalid_json_payload( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test invalid JSON skips processing.""" + mocker.patch('sys.stdin', StringIO('not valid json {')) + + scan_command(mock_ctx, ide='cursor') + + mock_scan_command_deps['initialize_clients'].assert_not_called() + response = json.loads(capsys.readouterr().out) + assert response.get('continue') is True + + +class TestMatchingIdeProcessesPayload: + """Tests that verify matching IDE processes the payload normally.""" + + def test_claude_code_payload_with_claude_code_ide( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Test Claude Code payload is processed when --ide claude-code is specified.""" + payload = {'hook_event_name': 'UserPromptSubmit', 'session_id': 'session-123', 'prompt': 'test'} + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + mock_scan_command_deps['load_policy'].return_value = {'fail_open': True} + mock_handler = MagicMock(return_value={'decision': 'allow'}) + mock_scan_command_deps['get_handler'].return_value = mock_handler + + scan_command(mock_ctx, ide='claude-code') + + mock_scan_command_deps['initialize_clients'].assert_called_once() + mock_scan_command_deps['load_policy'].assert_called_once() + mock_scan_command_deps['get_handler'].assert_called_once() + mock_handler.assert_called_once() diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py new file mode 100644 index 00000000..f0dec6f7 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -0,0 +1,53 @@ +"""Tests for AI guardrails hooks manager.""" + +from cycode.cli.apps.ai_guardrails.hooks_manager import is_cycode_hook_entry + + +def test_is_cycode_hook_entry_cursor_format() -> None: + """Test detecting Cycode hook in Cursor format (flat command).""" + entry = {'command': 'cycode ai-guardrails scan'} + assert is_cycode_hook_entry(entry) is True + + entry = {'command': 'cycode ai-guardrails scan --some-flag'} + assert is_cycode_hook_entry(entry) is True + + +def test_is_cycode_hook_entry_claude_code_format() -> None: + """Test detecting Cycode hook in Claude Code format (nested).""" + entry = { + 'hooks': [{'type': 'command', 'command': 'cycode ai-guardrails scan --ide claude-code'}], + } + assert is_cycode_hook_entry(entry) is True + + entry = { + 'matcher': 'Read', + 'hooks': [{'type': 'command', 'command': 'cycode ai-guardrails scan --ide claude-code'}], + } + assert is_cycode_hook_entry(entry) is True + + +def test_is_cycode_hook_entry_non_cycode() -> None: + """Test that non-Cycode hooks are not detected.""" + # Cursor format + entry = {'command': 'some-other-command'} + assert is_cycode_hook_entry(entry) is False + + # Claude Code format + entry = { + 'hooks': [{'type': 'command', 'command': 'some-other-command'}], + } + assert is_cycode_hook_entry(entry) is False + + # Empty entry + entry = {} + assert is_cycode_hook_entry(entry) is False + + +def test_is_cycode_hook_entry_partial_match() -> None: + """Test partial command match.""" + # Should match if command contains 'cycode ai-guardrails scan' + entry = {'command': '/usr/local/bin/cycode ai-guardrails scan'} + assert is_cycode_hook_entry(entry) is True + + entry = {'command': 'cycode ai-guardrails scan --verbose'} + assert is_cycode_hook_entry(entry) is True From 1789349bbd0d27fd2c76379ef457afdbf755c410 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Mon, 2 Feb 2026 15:06:27 +0200 Subject: [PATCH 18/22] CM-58331-remove test --- cycode/cli/apps/ai_guardrails/scan/scan_command.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index 0cf1b46c..e28d2659 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -88,9 +88,6 @@ def scan_command( stdin_data = sys.stdin.read().strip() payload = safe_json_parse(stdin_data) - with open ('/tmp/test.input', 'w') as f: - f.write(stdin_data) - tool = ide.lower() response_builder = get_response_builder(tool) From b0625ac2d99855990374602ccac70800e612dddf Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Mon, 2 Feb 2026 17:39:15 +0200 Subject: [PATCH 19/22] CM-58331-send error if available --- cycode/cli/apps/ai_guardrails/scan/handlers.py | 9 +++++++++ cycode/cyclient/ai_security_manager_client.py | 2 ++ 2 files changed, 11 insertions(+) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 83edd89d..c158ae1a 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -55,6 +55,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli scan_id = None block_reason = None outcome = AIHookOutcome.ALLOWED + error_message = None try: violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) @@ -72,6 +73,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED ) block_reason = BlockReason.SCAN_FAILURE + error_message = str(e) raise e finally: ai_client.create_event( @@ -80,6 +82,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli outcome, scan_id=scan_id, block_reason=block_reason, + error_message=error_message, ) @@ -107,6 +110,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: scan_id = None block_reason = None outcome = AIHookOutcome.ALLOWED + error_message = None try: # Check path-based denylist first @@ -154,6 +158,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED ) block_reason = BlockReason.SCAN_FAILURE + error_message = str(e) raise e finally: ai_client.create_event( @@ -162,6 +167,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: outcome, scan_id=scan_id, block_reason=block_reason, + error_message=error_message, ) @@ -195,6 +201,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli scan_id = None block_reason = None outcome = AIHookOutcome.ALLOWED + error_message = None try: if get_policy_value(mcp_config, 'scan_arguments', default=True): @@ -220,6 +227,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED ) block_reason = BlockReason.SCAN_FAILURE + error_message = str(e) raise e finally: ai_client.create_event( @@ -228,6 +236,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli outcome, scan_id=scan_id, block_reason=block_reason, + error_message=error_message, ) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 627e2b33..1090ad8d 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -61,6 +61,7 @@ def create_event( outcome: 'AIHookOutcome', scan_id: Optional[str] = None, block_reason: Optional['BlockReason'] = None, + error_message: Optional[str] = None, ) -> None: """Create an AI hook event from hook payload.""" conversation_id = payload.conversation_id @@ -77,6 +78,7 @@ def create_event( 'cli_scan_id': scan_id, 'mcp_server_name': payload.mcp_server_name, 'mcp_tool_name': payload.mcp_tool_name, + 'error_message': error_message, } try: From 5ad5aa5f4f36846cddc980cfe7ecd490e8ecd2f2 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Tue, 3 Feb 2026 12:15:33 +0200 Subject: [PATCH 20/22] CM-58331: Add --ide all option for AI guardrails commands - Add support for `--ide all` to install/uninstall/status hooks for all IDEs at once - Update validate_and_parse_ide to return None for "all" - Split Claude Code PreToolUse into PreToolUse:Read and PreToolUse:mcp for better status visibility - Report results separately for each IDE when using --ide all Co-Authored-By: Claude Opus 4.5 --- .../cli/apps/ai_guardrails/command_utils.py | 12 +++-- cycode/cli/apps/ai_guardrails/consts.py | 2 +- .../cli/apps/ai_guardrails/hooks_manager.py | 10 +++- .../cli/apps/ai_guardrails/install_command.py | 37 +++++++++---- .../cli/apps/ai_guardrails/status_command.py | 53 +++++++++++-------- .../apps/ai_guardrails/uninstall_command.py | 37 +++++++++---- .../ai_guardrails/test_command_utils.py | 3 ++ 7 files changed, 107 insertions(+), 47 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/command_utils.py b/cycode/cli/apps/ai_guardrails/command_utils.py index e010f0a2..edc3104a 100644 --- a/cycode/cli/apps/ai_guardrails/command_utils.py +++ b/cycode/cli/apps/ai_guardrails/command_utils.py @@ -12,24 +12,26 @@ console = Console() -def validate_and_parse_ide(ide: str) -> AIIDEType: - """Validate IDE parameter and convert to AIIDEType enum. +def validate_and_parse_ide(ide: str) -> Optional[AIIDEType]: + """Validate IDE parameter, returning None for 'all'. Args: - ide: IDE name string (e.g., 'cursor') + ide: IDE name string (e.g., 'cursor', 'claude-code', 'all') Returns: - AIIDEType enum value + AIIDEType enum value, or None if 'all' was specified Raises: typer.Exit: If IDE is invalid """ + if ide.lower() == 'all': + return None try: return AIIDEType(ide.lower()) except ValueError: valid_ides = ', '.join([ide_type.value for ide_type in AIIDEType]) console.print( - f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}', + f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}, all', style='bold red', ) raise typer.Exit(1) from None diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index fc67c348..ce6d3c8e 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -60,7 +60,7 @@ def _get_claude_code_hooks_dir() -> Path: hooks_dir=_get_claude_code_hooks_dir(), repo_hooks_subdir='.claude', hooks_file_name='settings.json', - hook_events=['UserPromptSubmit', 'PreToolUse'], + hook_events=['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp'], ), } diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index 985b0258..b8d43c43 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -203,7 +203,15 @@ def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide: ide_config = IDE_CONFIGS[ide] has_cycode_hooks = False for event in ide_config.hook_events: - entries = existing.get('hooks', {}).get(event, []) + # Handle event:matcher format + if ':' in event: + actual_event, matcher_prefix = event.split(':', 1) + all_entries = existing.get('hooks', {}).get(actual_event, []) + # Filter entries by matcher + entries = [e for e in all_entries if e.get('matcher', '').startswith(matcher_prefix)] + else: + entries = existing.get('hooks', {}).get(event, []) + cycode_entries = [e for e in entries if is_cycode_hook_entry(e)] if cycode_entries: has_cycode_hooks = True diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index 6186752d..cf3a0298 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -11,7 +11,7 @@ validate_and_parse_ide, validate_scope, ) -from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS +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.utils.sentry import add_breadcrumb @@ -30,7 +30,7 @@ def install_command( str, typer.Option( '--ide', - help='IDE to install hooks for (e.g., "cursor"). Defaults to cursor.', + help='IDE to install hooks for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', ), ] = 'cursor', repo_path: Annotated[ @@ -54,6 +54,7 @@ def install_command( cycode ai-guardrails install # Install for all projects (user scope) 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 cycode ai-guardrails install --scope repo --repo-path /path/to/repo """ add_breadcrumb('ai-guardrails-install') @@ -62,17 +63,35 @@ def install_command( validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) ide_type = validate_and_parse_ide(ide) - ide_name = IDE_CONFIGS[ide_type].name - success, message = install_hooks(scope, repo_path, ide=ide_type) - if success: - console.print(f'[green]✓[/] {message}') + ides_to_install: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] + + 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) + results.append((ide_name, success, message)) + + # Report results for each IDE + any_success = False + all_success = True + for _ide_name, success, message in results: + if success: + console.print(f'[green]✓[/] {message}') + any_success = True + else: + console.print(f'[red]✗[/] {message}', style='bold red') + all_success = False + + if any_success: console.print() console.print('[bold]Next steps:[/]') - console.print(f'1. Restart {ide_name} to activate the hooks') + 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.[/]') - else: - console.print(f'[red]✗[/] {message}', style='bold red') + + if not all_success: raise typer.Exit(1) diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py index 0a9801b5..56b9bdb1 100644 --- a/cycode/cli/apps/ai_guardrails/status_command.py +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -8,6 +8,7 @@ from rich.table import Table from cycode.cli.apps.ai_guardrails.command_utils import console, 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 get_hooks_status from cycode.cli.utils.sentry import add_breadcrumb @@ -26,7 +27,7 @@ def status_command( str, typer.Option( '--ide', - help='IDE to check status for (e.g., "cursor"). Defaults to cursor.', + help='IDE to check status for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', ), ] = 'cursor', repo_path: Annotated[ @@ -50,6 +51,7 @@ def status_command( cycode ai-guardrails status --scope user # Show only user-level status cycode ai-guardrails status --scope repo # Show only repo-level status cycode ai-guardrails status --ide cursor # Check status for Cursor IDE + cycode ai-guardrails status --ide all # Check status for all supported IDEs """ add_breadcrumb('ai-guardrails-status') @@ -59,34 +61,41 @@ def status_command( repo_path = Path(os.getcwd()) ide_type = validate_and_parse_ide(ide) - scopes_to_check = ['user', 'repo'] if scope == 'all' else [scope] + ides_to_check: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] - for check_scope in scopes_to_check: - status = get_hooks_status(check_scope, repo_path if check_scope == 'repo' else None, ide=ide_type) + scopes_to_check = ['user', 'repo'] if scope == 'all' else [scope] + for current_ide in ides_to_check: + ide_name = IDE_CONFIGS[current_ide].name console.print() - console.print(f'[bold]{check_scope.upper()} SCOPE[/]') - console.print(f'Path: {status["hooks_path"]}') + console.print(f'[bold cyan]═══ {ide_name} ═══[/]') + + for check_scope in scopes_to_check: + status = get_hooks_status(check_scope, repo_path if check_scope == 'repo' else None, ide=current_ide) + + console.print() + console.print(f'[bold]{check_scope.upper()} SCOPE[/]') + console.print(f'Path: {status["hooks_path"]}') - if not status['file_exists']: - console.print('[dim]No hooks.json file found[/]') - continue + if not status['file_exists']: + console.print('[dim]No hooks file found[/]') + continue - if status['cycode_installed']: - console.print('[green]✓ Cycode AI guardrails: INSTALLED[/]') - else: - console.print('[yellow]○ Cycode AI guardrails: NOT INSTALLED[/]') + if status['cycode_installed']: + console.print('[green]✓ Cycode AI guardrails: INSTALLED[/]') + else: + console.print('[yellow]○ Cycode AI guardrails: NOT INSTALLED[/]') - # Show hook details - table = Table(show_header=True, header_style='bold') - table.add_column('Hook Event') - table.add_column('Cycode Enabled') - table.add_column('Total Hooks') + # Show hook details + table = Table(show_header=True, header_style='bold') + table.add_column('Hook Event') + table.add_column('Cycode Enabled') + table.add_column('Total Hooks') - for event, info in status['hooks'].items(): - enabled = '[green]Yes[/]' if info['enabled'] else '[dim]No[/]' - table.add_row(event, enabled, str(info['total_entries'])) + for event, info in status['hooks'].items(): + enabled = '[green]Yes[/]' if info['enabled'] else '[dim]No[/]' + table.add_row(event, enabled, str(info['total_entries'])) - console.print(table) + console.print(table) console.print() diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py index 23315693..3d26eec9 100644 --- a/cycode/cli/apps/ai_guardrails/uninstall_command.py +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -11,7 +11,7 @@ validate_and_parse_ide, validate_scope, ) -from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS +from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType from cycode.cli.apps.ai_guardrails.hooks_manager import uninstall_hooks from cycode.cli.utils.sentry import add_breadcrumb @@ -30,7 +30,7 @@ def uninstall_command( str, typer.Option( '--ide', - help='IDE to uninstall hooks from (e.g., "cursor"). Defaults to cursor.', + help='IDE to uninstall hooks from (e.g., "cursor", "claude-code", "all"). Defaults to cursor.', ), ] = 'cursor', repo_path: Annotated[ @@ -54,6 +54,7 @@ def uninstall_command( cycode ai-guardrails uninstall # Remove user-level hooks cycode ai-guardrails uninstall --scope repo # Remove repo-level hooks cycode ai-guardrails uninstall --ide cursor # Uninstall from Cursor IDE + cycode ai-guardrails uninstall --ide all # Uninstall from all supported IDEs """ add_breadcrumb('ai-guardrails-uninstall') @@ -61,13 +62,31 @@ def uninstall_command( validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) ide_type = validate_and_parse_ide(ide) - ide_name = IDE_CONFIGS[ide_type].name - success, message = uninstall_hooks(scope, repo_path, ide=ide_type) - if success: - console.print(f'[green]✓[/] {message}') + ides_to_uninstall: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] + + results: list[tuple[str, bool, str]] = [] + for current_ide in ides_to_uninstall: + ide_name = IDE_CONFIGS[current_ide].name + success, message = uninstall_hooks(scope, repo_path, ide=current_ide) + results.append((ide_name, success, message)) + + # Report results for each IDE + any_success = False + all_success = True + for _ide_name, success, message in results: + if success: + console.print(f'[green]✓[/] {message}') + any_success = True + else: + console.print(f'[red]✗[/] {message}', style='bold red') + all_success = False + + if any_success: console.print() - console.print(f'[dim]Restart {ide_name} for changes to take effect.[/]') - else: - console.print(f'[red]✗[/] {message}', style='bold red') + successful_ides = [name for name, success, _ in results if success] + ide_list = ', '.join(successful_ides) + console.print(f'[dim]Restart {ide_list} for changes to take effect.[/]') + + if not all_success: raise typer.Exit(1) diff --git a/tests/cli/commands/ai_guardrails/test_command_utils.py b/tests/cli/commands/ai_guardrails/test_command_utils.py index 4f0ef55e..5d8d224b 100644 --- a/tests/cli/commands/ai_guardrails/test_command_utils.py +++ b/tests/cli/commands/ai_guardrails/test_command_utils.py @@ -15,6 +15,9 @@ def test_validate_and_parse_ide_valid() -> None: assert validate_and_parse_ide('cursor') == AIIDEType.CURSOR assert validate_and_parse_ide('CURSOR') == AIIDEType.CURSOR assert validate_and_parse_ide('CuRsOr') == AIIDEType.CURSOR + assert validate_and_parse_ide('claude-code') == AIIDEType.CLAUDE_CODE + assert validate_and_parse_ide('Claude-Code') == AIIDEType.CLAUDE_CODE + assert validate_and_parse_ide('all') is None def test_validate_and_parse_ide_invalid() -> None: From 17db1ab7dcab4e9de0b046ce088c31f1c3259de4 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Tue, 3 Feb 2026 18:22:47 +0200 Subject: [PATCH 21/22] CM-58331-review --- cycode/cli/apps/ai_guardrails/consts.py | 7 +++ .../cli/apps/ai_guardrails/install_command.py | 2 +- .../cli/apps/ai_guardrails/scan/handlers.py | 20 +++---- cycode/cli/apps/ai_guardrails/scan/payload.py | 54 +++++++++++-------- .../ai_guardrails/scan/response_builders.py | 17 +++--- .../apps/ai_guardrails/scan/scan_command.py | 3 +- .../cli/apps/ai_guardrails/status_command.py | 2 +- .../apps/ai_guardrails/uninstall_command.py | 2 +- 8 files changed, 65 insertions(+), 42 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index ce6d3c8e..8714ec10 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -18,6 +18,13 @@ class AIIDEType(str, Enum): CLAUDE_CODE = 'claude-code' +class PolicyMode(str, Enum): + """Policy enforcement mode for global mode and per-feature actions.""" + + BLOCK = 'block' + WARN = 'warn' + + class IDEConfig(NamedTuple): """Configuration for an AI IDE.""" diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index cf3a0298..4b1095ab 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -32,7 +32,7 @@ def install_command( '--ide', help='IDE to install hooks for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', ), - ] = 'cursor', + ] = AIIDEType.CURSOR, repo_path: Annotated[ Optional[Path], typer.Option( diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index c158ae1a..32be1241 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -13,6 +13,7 @@ import typer +from cycode.cli.apps.ai_guardrails.consts import PolicyMode from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder @@ -46,7 +47,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED) return response_builder.allow_prompt() - mode = get_policy_value(policy, 'mode', default='block') + mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) prompt = payload.prompt or '' max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) @@ -62,7 +63,8 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli if violation_summary: block_reason = BlockReason.SECRETS_IN_PROMPT - if get_policy_value(prompt_config, 'action', default='block') == 'block' and mode == 'block': + action = get_policy_value(prompt_config, 'action', default=PolicyMode.BLOCK) + if action == PolicyMode.BLOCK and mode == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'{violation_summary}. Remove secrets before sending.' return response_builder.deny_prompt(user_message) @@ -103,9 +105,9 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED) return response_builder.allow_permission() - mode = get_policy_value(policy, 'mode', default='block') + mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) file_path = payload.file_path or '' - action = get_policy_value(file_read_config, 'action', default='block') + action = get_policy_value(file_read_config, 'action', default=PolicyMode.BLOCK) scan_id = None block_reason = None @@ -116,7 +118,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: # Check path-based denylist first if is_denied_path(file_path, policy): block_reason = BlockReason.SENSITIVE_PATH - if mode == 'block' and action == 'block': + if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).' return response_builder.deny_permission( @@ -136,7 +138,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy) if violation_summary: block_reason = BlockReason.SECRETS_IN_FILE - if mode == 'block' and action == 'block': + if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'Cycode blocked reading {file_path}. {violation_summary}' return response_builder.deny_permission( @@ -189,14 +191,14 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED) return response_builder.allow_permission() - mode = get_policy_value(policy, 'mode', default='block') + mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) tool = payload.mcp_tool_name or 'unknown' args = payload.mcp_arguments or {} args_text = args if isinstance(args, str) else json.dumps(args) max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) clipped = truncate_utf8(args_text, max_bytes) - action = get_policy_value(mcp_config, 'action', default='block') + action = get_policy_value(mcp_config, 'action', default=PolicyMode.BLOCK) scan_id = None block_reason = None @@ -208,7 +210,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) if violation_summary: block_reason = BlockReason.SECRETS_IN_MCP_ARGS - if mode == 'block' and action == 'block': + if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}' return response_builder.deny_permission( diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index 5206ca3e..da5d4b7e 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Optional +from cycode.cli.apps.ai_guardrails.consts import AIIDEType from cycode.cli.apps.ai_guardrails.scan.types import ( CLAUDE_CODE_EVENT_MAPPING, CLAUDE_CODE_EVENT_NAMES, @@ -47,7 +48,7 @@ def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]: if newline_pos == -1: break # Yield the line after this newline - line = buffer[newline_pos + 1 :] + line = buffer[newline_pos + 1:] buffer = buffer[: newline_pos + 1] if line.strip(): yield line.decode('utf-8', errors='replace') @@ -57,8 +58,20 @@ def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]: yield buffer.decode('utf-8', errors='replace') -def _extract_from_claude_transcript( # noqa: C901 - transcript_path: str, +def _extract_model(entry: dict) -> Optional[str]: + """Extract model from a transcript entry (top level or nested in message).""" + return entry.get('model') or (entry.get('message') or {}).get('model') + + +def _extract_generation_id(entry: dict) -> Optional[str]: + """Extract generation ID from a user-type transcript entry.""" + if entry.get('type') == 'user': + return entry.get('uuid') + return None + + +def _extract_from_claude_transcript( + transcript_path: str, ) -> tuple[Optional[str], Optional[str], Optional[str]]: """Extract IDE version, model, and latest generation ID from Claude Code transcript file. @@ -90,16 +103,11 @@ def _extract_from_claude_transcript( # noqa: C901 continue try: entry = json.loads(line) - if ide_version is None and 'version' in entry: - ide_version = entry['version'] - # Model can be at top level or nested in message.model - if model is None: - model = entry.get('model') or (entry.get('message') or {}).get('model') - # Get the latest user message UUID as generation_id - if generation_id is None and entry.get('type') == 'user' and entry.get('uuid'): - generation_id = entry['uuid'] - # Stop early if we found all values - if ide_version is not None and model is not None and generation_id is not None: + ide_version = ide_version or entry.get('version') + model = model or _extract_model(entry) + generation_id = generation_id or _extract_generation_id(entry) + + if ide_version and model and generation_id: break except json.JSONDecodeError: continue @@ -121,7 +129,7 @@ class AIHookPayload: # User and IDE information ide_user_email: Optional[str] = None model: Optional[str] = None - ide_provider: str = None # e.g., 'cursor', 'claude-code' + ide_provider: str = None # AIIDEType value (e.g., 'cursor', 'claude-code') ide_version: Optional[str] = None # Event-specific data @@ -147,7 +155,7 @@ def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload': generation_id=payload.get('generation_id'), ide_user_email=payload.get('user_email'), model=payload.get('model'), - ide_provider='cursor', + ide_provider=AIIDEType.CURSOR, ide_version=payload.get('cursor_version'), prompt=payload.get('prompt', ''), file_path=payload.get('file_path') or payload.get('path'), @@ -205,7 +213,7 @@ def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload': generation_id=generation_id, ide_user_email=None, # Claude Code doesn't provide this in hook payload model=model, - ide_provider='claude-code', + ide_provider=AIIDEType.CLAUDE_CODE, ide_version=ide_version, prompt=payload.get('prompt', ''), file_path=file_path, @@ -224,28 +232,28 @@ def is_payload_for_ide(payload: dict, ide: str) -> bool: Args: payload: The raw payload from the IDE - ide: The IDE name (e.g., 'cursor', 'claude-code') + ide: The IDE name or AIIDEType enum value Returns: True if the payload matches the IDE, False otherwise. """ hook_event_name = payload.get('hook_event_name', '') - if ide == 'claude-code': + if ide == AIIDEType.CLAUDE_CODE: return hook_event_name in CLAUDE_CODE_EVENT_NAMES - if ide == 'cursor': + if ide == AIIDEType.CURSOR: return hook_event_name in CURSOR_EVENT_NAMES # Unknown IDE, allow processing return True @classmethod - def from_payload(cls, payload: dict, tool: str = 'cursor') -> 'AIHookPayload': + def from_payload(cls, payload: dict, tool: str = AIIDEType.CURSOR) -> 'AIHookPayload': """Create AIHookPayload from any tool's payload. Args: payload: The raw payload from the IDE - tool: The IDE/tool name (e.g., 'cursor', 'claude-code') + tool: The IDE/tool name or AIIDEType enum value Returns: AIHookPayload instance @@ -253,8 +261,8 @@ def from_payload(cls, payload: dict, tool: str = 'cursor') -> 'AIHookPayload': Raises: ValueError: If the tool is not supported """ - if tool == 'cursor': + if tool == AIIDEType.CURSOR: return cls.from_cursor_payload(payload) - if tool == 'claude-code': + if tool == AIIDEType.CLAUDE_CODE: return cls.from_claude_code_payload(payload) raise ValueError(f'Unsupported IDE/tool: {tool}') diff --git a/cycode/cli/apps/ai_guardrails/scan/response_builders.py b/cycode/cli/apps/ai_guardrails/scan/response_builders.py index 6b183dc6..f0da71b7 100644 --- a/cycode/cli/apps/ai_guardrails/scan/response_builders.py +++ b/cycode/cli/apps/ai_guardrails/scan/response_builders.py @@ -7,6 +7,8 @@ from abc import ABC, abstractmethod +from cycode.cli.apps.ai_guardrails.consts import AIIDEType + class IDEResponseBuilder(ABC): """Abstract base class for IDE-specific response builders.""" @@ -108,18 +110,18 @@ def deny_prompt(self, user_message: str) -> dict: return {'decision': 'block', 'reason': user_message} -# Registry of response builders by IDE name +# Registry of response builders by IDE type _RESPONSE_BUILDERS: dict[str, IDEResponseBuilder] = { - 'cursor': CursorResponseBuilder(), - 'claude-code': ClaudeCodeResponseBuilder(), + AIIDEType.CURSOR: CursorResponseBuilder(), + AIIDEType.CLAUDE_CODE: ClaudeCodeResponseBuilder(), } -def get_response_builder(ide: str = 'cursor') -> IDEResponseBuilder: +def get_response_builder(ide: str = AIIDEType.CURSOR) -> IDEResponseBuilder: """Get the response builder for a specific IDE. Args: - ide: The IDE name (e.g., 'cursor', 'claude-code') + ide: The IDE name (e.g., 'cursor', 'claude-code') or AIIDEType enum Returns: IDEResponseBuilder instance for the specified IDE @@ -127,7 +129,10 @@ def get_response_builder(ide: str = 'cursor') -> IDEResponseBuilder: Raises: ValueError: If the IDE is not supported """ - builder = _RESPONSE_BUILDERS.get(ide.lower()) + # Normalize to AIIDEType if string passed + if isinstance(ide, str): + ide = ide.lower() + builder = _RESPONSE_BUILDERS.get(ide) if not builder: raise ValueError(f'Unsupported IDE: {ide}. Supported IDEs: {list(_RESPONSE_BUILDERS.keys())}') return builder diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index e28d2659..73981831 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -16,6 +16,7 @@ import click import typer +from cycode.cli.apps.ai_guardrails.consts import AIIDEType from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.policy import load_policy @@ -69,7 +70,7 @@ def scan_command( help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.', hidden=True, ), - ] = 'cursor', + ] = AIIDEType.CURSOR, ) -> None: """Scan content from AI IDE hooks for secrets. diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py index 56b9bdb1..14a31e7f 100644 --- a/cycode/cli/apps/ai_guardrails/status_command.py +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -29,7 +29,7 @@ def status_command( '--ide', help='IDE to check status for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', ), - ] = 'cursor', + ] = AIIDEType.CURSOR, repo_path: Annotated[ Optional[Path], typer.Option( diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py index 3d26eec9..acf3d0c7 100644 --- a/cycode/cli/apps/ai_guardrails/uninstall_command.py +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -32,7 +32,7 @@ def uninstall_command( '--ide', help='IDE to uninstall hooks from (e.g., "cursor", "claude-code", "all"). Defaults to cursor.', ), - ] = 'cursor', + ] = AIIDEType.CURSOR, repo_path: Annotated[ Optional[Path], typer.Option( From 3e7e1bf3b7a17d13b9292b2efa8e2627726cf621 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Tue, 3 Feb 2026 18:28:15 +0200 Subject: [PATCH 22/22] CM-58331 - Style: apply ruff formatting Co-Authored-By: Claude Opus 4.5 --- cycode/cli/apps/ai_guardrails/scan/payload.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index da5d4b7e..ce72a574 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -48,7 +48,7 @@ def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]: if newline_pos == -1: break # Yield the line after this newline - line = buffer[newline_pos + 1:] + line = buffer[newline_pos + 1 :] buffer = buffer[: newline_pos + 1] if line.strip(): yield line.decode('utf-8', errors='replace') @@ -71,7 +71,7 @@ def _extract_generation_id(entry: dict) -> Optional[str]: def _extract_from_claude_transcript( - transcript_path: str, + transcript_path: str, ) -> tuple[Optional[str], Optional[str], Optional[str]]: """Extract IDE version, model, and latest generation ID from Claude Code transcript file.