From c0bc036c1a7318f495da46919c18d1396faccd55 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Sun, 24 May 2026 18:55:18 +0300 Subject: [PATCH] CM-64678: refactor ai-guardrails to single-file-per-IDE abstraction Move all IDE-specific code into one file per IDE under ides/, register instances in ides/__init__.py::IDES, and route install / uninstall / status / scan / session-start through the registry. Adding a new IDE is now one new file plus one registry line. Handlers return a canonical HookDecision; IDE.build_hook_response translates to the per-IDE JSON response shape. Per-IDE config files, response_builders.py, IDE_CONFIGS, per-IDE event mappings, and the AIHookPayload classmethods are deleted in favor of the IDE class. A parameterized IDE contract test gives every future agent the same baseline coverage automatically. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cli/apps/ai_guardrails/command_utils.py | 47 +- cycode/cli/apps/ai_guardrails/consts.py | 138 +----- .../cli/apps/ai_guardrails/hooks_manager.py | 189 +++----- .../cli/apps/ai_guardrails/ides/__init__.py | 44 ++ cycode/cli/apps/ai_guardrails/ides/base.py | 156 +++++++ .../apps/ai_guardrails/ides/claude_code.py | 389 ++++++++++++++++ cycode/cli/apps/ai_guardrails/ides/cursor.py | 119 +++++ .../cli/apps/ai_guardrails/install_command.py | 37 +- .../apps/ai_guardrails/scan/claude_config.py | 159 ------- .../apps/ai_guardrails/scan/cursor_config.py | 36 -- .../cli/apps/ai_guardrails/scan/handlers.py | 135 ++---- cycode/cli/apps/ai_guardrails/scan/payload.py | 269 +---------- .../ai_guardrails/scan/response_builders.py | 135 ------ .../apps/ai_guardrails/scan/scan_command.py | 108 +++-- cycode/cli/apps/ai_guardrails/scan/types.py | 38 +- .../ai_guardrails/session_start_command.py | 92 +--- .../cli/apps/ai_guardrails/status_command.py | 29 +- .../apps/ai_guardrails/uninstall_command.py | 34 +- .../commands/ai_guardrails/ides/__init__.py | 0 .../ai_guardrails/ides/test_claude_code.py | 262 +++++++++++ .../ai_guardrails/ides/test_contract.py | 141 ++++++ .../ai_guardrails/ides/test_cursor.py | 154 +++++++ .../ai_guardrails/scan/test_handlers.py | 98 ++-- .../ai_guardrails/scan/test_payload.py | 432 ------------------ .../scan/test_response_builders.py | 148 ------ .../ai_guardrails/scan/test_scan_command.py | 10 +- .../ai_guardrails/test_claude_config.py | 54 --- .../ai_guardrails/test_command_utils.py | 60 --- .../ai_guardrails/test_hooks_manager.py | 116 ++--- .../test_session_start_command.py | 24 +- 30 files changed, 1618 insertions(+), 2035 deletions(-) create mode 100644 cycode/cli/apps/ai_guardrails/ides/__init__.py create mode 100644 cycode/cli/apps/ai_guardrails/ides/base.py create mode 100644 cycode/cli/apps/ai_guardrails/ides/claude_code.py create mode 100644 cycode/cli/apps/ai_guardrails/ides/cursor.py delete mode 100644 cycode/cli/apps/ai_guardrails/scan/claude_config.py delete mode 100644 cycode/cli/apps/ai_guardrails/scan/cursor_config.py delete mode 100644 cycode/cli/apps/ai_guardrails/scan/response_builders.py create mode 100644 tests/cli/commands/ai_guardrails/ides/__init__.py create mode 100644 tests/cli/commands/ai_guardrails/ides/test_claude_code.py create mode 100644 tests/cli/commands/ai_guardrails/ides/test_contract.py create mode 100644 tests/cli/commands/ai_guardrails/ides/test_cursor.py delete mode 100644 tests/cli/commands/ai_guardrails/scan/test_payload.py delete mode 100644 tests/cli/commands/ai_guardrails/scan/test_response_builders.py delete mode 100644 tests/cli/commands/ai_guardrails/test_claude_config.py delete mode 100644 tests/cli/commands/ai_guardrails/test_command_utils.py diff --git a/cycode/cli/apps/ai_guardrails/command_utils.py b/cycode/cli/apps/ai_guardrails/command_utils.py index edc3104a..291fabcf 100644 --- a/cycode/cli/apps/ai_guardrails/command_utils.py +++ b/cycode/cli/apps/ai_guardrails/command_utils.py @@ -7,46 +7,11 @@ 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) -> Optional[AIIDEType]: - """Validate IDE parameter, returning None for 'all'. - - Args: - ide: IDE name string (e.g., 'cursor', 'claude-code', 'all') - - Returns: - 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}, all', - style='bold red', - ) - raise typer.Exit(1) from None - - 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 - """ + """Validate scope parameter.""" 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') @@ -54,15 +19,7 @@ def validate_scope(scope: str, allowed_scopes: tuple[str, ...] = ('user', 'repo' 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 - """ + """Default repo_path to cwd for 'repo' scope; leave 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 index 2895c8d1..8018fa73 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -1,22 +1,6 @@ -"""Constants for AI guardrails hooks management. +"""Shared constants and policy/mode enums for AI guardrails.""" -Currently supports: -- Cursor -- Claude Code -""" - -import platform -from copy import deepcopy from enum import Enum -from pathlib import Path -from typing import NamedTuple - - -class AIIDEType(str, Enum): - """Supported AI IDE types.""" - - CURSOR = 'cursor' - CLAUDE_CODE = 'claude-code' class PolicyMode(str, Enum): @@ -33,123 +17,7 @@ class InstallMode(str, Enum): BLOCK = 'block' -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' - if platform.system() == 'Windows': - return Path.home() / 'AppData' / 'Roaming' / 'Cursor' - # Linux - 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( - name='Cursor', - hooks_dir=_get_cursor_hooks_dir(), - repo_hooks_subdir='.cursor', - 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:Read', 'PreToolUse:mcp'], - ), -} - -# Default IDE -DEFAULT_IDE = AIIDEType.CURSOR - -# Command used in hooks +# Base CLI commands invoked from installed hooks. IDE classes append --ide flags +# (and any other suffix) on top of these. CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan' CYCODE_SESSION_START_COMMAND = 'cycode ai-guardrails session-start' - - -def _get_cursor_hooks_config(async_mode: bool = False) -> dict: - """Get Cursor-specific hooks configuration.""" - config = IDE_CONFIGS[AIIDEType.CURSOR] - command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND - hooks = {event: [{'command': command}] for event in config.hook_events} - hooks['sessionStart'] = [{'command': f'{CYCODE_SESSION_START_COMMAND} --ide cursor'}] - - return { - 'version': 1, - 'hooks': hooks, - } - - -def _get_claude_code_hooks_config(async_mode: bool = False) -> dict: - """Get Claude Code-specific hooks configuration. - - Claude Code uses a different hook format with nested structure: - - 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' - - hook_entry = {'type': 'command', 'command': command} - if async_mode: - hook_entry['async'] = True - hook_entry['timeout'] = 20 - - return { - 'hooks': { - 'SessionStart': [ - { - 'hooks': [{'type': 'command', 'command': f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'}], - } - ], - 'UserPromptSubmit': [ - { - 'hooks': [deepcopy(hook_entry)], - } - ], - 'PreToolUse': [ - { - 'matcher': 'Read', - 'hooks': [deepcopy(hook_entry)], - }, - { - 'matcher': 'mcp__.*', - 'hooks': [deepcopy(hook_entry)], - }, - ], - }, - } - - -def get_hooks_config(ide: AIIDEType, async_mode: bool = False) -> dict: - """Get the hooks configuration for a specific IDE. - - Args: - ide: The AI IDE type - async_mode: If True, hooks run asynchronously (non-blocking) - - Returns: - Dict with hooks configuration for the specified IDE - """ - if ide == AIIDEType.CLAUDE_CODE: - return _get_claude_code_hooks_config(async_mode=async_mode) - return _get_cursor_hooks_config(async_mode=async_mode) diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index 74c681be..1fe23bb2 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -1,8 +1,8 @@ -""" -Hooks manager for AI guardrails. +"""Hooks manager for AI guardrails. -Handles installation, removal, and status checking of AI IDE hooks. -Supports multiple IDEs: Cursor, Claude Code (future). +Generic install/uninstall/status logic. All IDE-specific concerns (settings +paths, hooks template shape) live on the `IDE` instance; this module is +agent-agnostic. """ import copy @@ -12,47 +12,45 @@ import yaml -from cycode.cli.apps.ai_guardrails.consts import ( - DEFAULT_IDE, - IDE_CONFIGS, - AIIDEType, - PolicyMode, - get_hooks_config, -) +from cycode.cli.apps.ai_guardrails.consts import PolicyMode +from cycode.cli.apps.ai_guardrails.ides.base import IDE from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME from cycode.logger import get_logger logger = get_logger('AI Guardrails Hooks') -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. +_CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',) + - 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 _is_cycode_command(command: str) -> bool: + return any(marker in command for marker in _CYCODE_COMMAND_MARKERS) + + +def is_cycode_hook_entry(entry: dict) -> bool: + """Detect Cycode hook entries in both Cursor (flat) and Claude Code (nested) shapes.""" + command = entry.get('command', '') + if _is_cycode_command(command): + return True + + for hook in entry.get('hooks', []): + if isinstance(hook, dict) and _is_cycode_command(hook.get('command', '')): + return True + + return False -def load_hooks_file(hooks_path: Path) -> Optional[dict]: - """Load existing hooks.json file.""" +def _load_hooks_file(hooks_path: Path) -> Optional[dict]: if not hooks_path.exists(): return None try: - content = hooks_path.read_text(encoding='utf-8') - return json.loads(content) + return json.loads(hooks_path.read_text(encoding='utf-8')) 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.""" +def _save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: try: hooks_path.parent.mkdir(parents=True, exist_ok=True) hooks_path.write_text(json.dumps(hooks_config, indent=2), encoding='utf-8') @@ -62,39 +60,7 @@ def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: return False -_CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',) - - -def _is_cycode_command(command: str) -> bool: - return any(marker in command for marker in _CYCODE_COMMAND_MARKERS) - - -def is_cycode_hook_entry(entry: dict) -> bool: - """Check if a hook entry is from cycode-cli. - - 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', '') - if _is_cycode_command(command): - return True - - # Check Claude Code format (nested hooks array) - hooks = entry.get('hooks', []) - for hook in hooks: - if isinstance(hook, dict): - hook_command = hook.get('command', '') - if _is_cycode_command(hook_command): - return True - - return False - - -def _load_policy(policy_path: Path) -> dict: - """Load existing policy file merged with defaults, or return defaults if not found.""" +def _load_policy_dict(policy_path: Path) -> dict: if not policy_path.exists(): return copy.deepcopy(DEFAULT_POLICY) try: @@ -107,22 +73,13 @@ def _load_policy(policy_path: Path) -> dict: def create_policy_file(scope: str, mode: PolicyMode, repo_path: Optional[Path] = None) -> tuple[bool, str]: """Create or update the ai-guardrails.yaml policy file. - If the file already exists, only the mode field is updated. - If it doesn't exist, a new file is created from the default policy. - - Args: - scope: 'user' for user-level, 'repo' for repository-level - mode: The policy mode to set - repo_path: Repository path (required if scope is 'repo') - - Returns: - Tuple of (success, message) + If the file already exists, only the mode field is updated; otherwise a new + file is created from the default policy. """ config_dir = repo_path / '.cycode' if scope == 'repo' and repo_path else Path.home() / '.cycode' policy_path = config_dir / POLICY_FILE_NAME - policy = _load_policy(policy_path) - + policy = _load_policy_dict(policy_path) policy['mode'] = mode.value try: @@ -135,35 +92,21 @@ def create_policy_file(scope: str, mode: PolicyMode, repo_path: Optional[Path] = def install_hooks( + ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None, - ide: AIIDEType = DEFAULT_IDE, report_mode: bool = False, ) -> tuple[bool, str]: - """ - Install Cycode AI guardrails hooks. - - 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) - report_mode: If True, install hooks in async mode (non-blocking) + """Install Cycode AI guardrails hooks for ``ide``.""" + hooks_path = ide.settings_path(scope, repo_path) - 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 = _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, async_mode=report_mode) + rendered = ide.render_hooks_config(async_mode=report_mode) - # Add/update Cycode hooks - for event, entries in hooks_config['hooks'].items(): + for event, entries in rendered['hooks'].items(): existing['hooks'].setdefault(event, []) # Remove any existing Cycode entries for this event @@ -173,47 +116,31 @@ def install_hooks( for entry in entries: existing['hooks'][event].append(entry) - # Save - if save_hooks_file(hooks_path, existing): + 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) +def uninstall_hooks(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Remove Cycode AI guardrails hooks for ``ide``.""" + hooks_path = ide.settings_path(scope, repo_path) - existing = load_hooks_file(hooks_path) + 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] 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() @@ -222,48 +149,35 @@ def uninstall_hooks( 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): + 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) +def get_hooks_status(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> dict: + """Return installation status of Cycode hooks for ``ide``.""" + hooks_path = ide.settings_path(scope, repo_path) - status = { + status: dict = { 'scope': scope, - 'ide': ide.value, - 'ide_name': IDE_CONFIGS[ide].name, + 'ide': ide.name, + 'ide_name': ide.display_name, 'hooks_path': str(hooks_path), 'file_exists': hooks_path.exists(), 'cycode_installed': False, 'hooks': {}, } - existing = load_hooks_file(hooks_path) + existing = _load_hooks_file(hooks_path) if existing is None: return status - # Check each hook event for this IDE - ide_config = IDE_CONFIGS[ide] has_cycode_hooks = False - for event in ide_config.hook_events: - # Handle event:matcher format + for event in ide.hook_events: + # ':' filters entries to a specific tool/matcher. 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, []) @@ -278,5 +192,4 @@ def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide: } status['cycode_installed'] = has_cycode_hooks - return status diff --git a/cycode/cli/apps/ai_guardrails/ides/__init__.py b/cycode/cli/apps/ai_guardrails/ides/__init__.py new file mode 100644 index 00000000..92859701 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/__init__.py @@ -0,0 +1,44 @@ +"""Registry of supported AI guardrails IDE integrations. + +Adding a new IDE: create `ides/.py` with a subclass of `IDE`, import it +here, and include an instance in the `IDES` tuple. Nothing else in the package +needs to change. +""" + +import typer + +from cycode.cli.apps.ai_guardrails.ides.base import IDE +from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode +from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor + +# Single source of truth: name → singleton instance. +# `--ide` choices and install/uninstall/status iteration both derive from this. +IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode())} + +# Default IDE used when `--ide` is omitted. Kept here so the value is colocated +# with the registry; no module outside `ides/` needs to know which IDE wins. +DEFAULT_IDE_NAME = 'cursor' + + +def get_ide(name: str) -> IDE: + """Look up the IDE integration registered under ``name``. + + Raises ``typer.BadParameter`` when the name is unknown — surfaces as a + user-friendly CLI error rather than a KeyError stack trace. + """ + ide = IDES.get(name.lower()) + if ide is None: + valid = ', '.join(IDES.keys()) + raise typer.BadParameter(f'Unknown IDE "{name}". Supported: {valid}.') + return ide + + +def resolve_ides(name: str) -> list[IDE]: + """Resolve an ``--ide`` argument to one or all IDE instances. + + ``"all"`` returns every registered IDE; anything else returns a single + matching IDE (raising ``typer.BadParameter`` for unknown names). + """ + if name.lower() == 'all': + return list(IDES.values()) + return [get_ide(name)] diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py new file mode 100644 index 00000000..55d5fb05 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -0,0 +1,156 @@ +"""Base abstractions for AI guardrails IDE integrations. + +Each AI IDE (Cursor, Claude Code, …) is represented by a subclass of `IDE` +that consolidates every IDE-specific concern in a single module: settings file +paths, hooks template rendering, payload parsing, response building, and any +IDE-specific session-context lookup. + +Adding a new IDE is a matter of: + 1. Subclassing `IDE` and implementing the abstract methods. + 2. Registering the instance in `cycode/cli/apps/ai_guardrails/ides/__init__.py`. + +The `HookDecision` dataclass is the canonical, IDE-agnostic return type for +event handlers; `IDE.build_hook_response` translates it into the IDE-specific +JSON response shape that the IDE expects on stdout. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import ClassVar, Optional + +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +class DecisionAction(str, Enum): + """Canonical decision action returned by event handlers.""" + + ALLOW = 'allow' + DENY = 'deny' + ASK = 'ask' + + +@dataclass(frozen=True) +class HookDecision: + """Canonical, IDE-agnostic decision returned by event handlers. + + Carries the event type so `IDE.build_hook_response` can pick the right + IDE-specific response shape (Cursor's "permission" style for tool events + vs. "continue" style for prompts; Claude Code's "hookSpecificOutput" + vs. "decision: block"). + """ + + action: DecisionAction + event_type: AiHookEventType + user_message: Optional[str] = None + agent_message: Optional[str] = None + + @classmethod + def allow(cls, event_type: AiHookEventType) -> 'HookDecision': + return cls(action=DecisionAction.ALLOW, event_type=event_type) + + @classmethod + def deny( + cls, event_type: AiHookEventType, user_message: str, agent_message: Optional[str] = None + ) -> 'HookDecision': + return cls( + action=DecisionAction.DENY, + event_type=event_type, + user_message=user_message, + agent_message=agent_message, + ) + + @classmethod + def ask(cls, event_type: AiHookEventType, user_message: str, agent_message: Optional[str] = None) -> 'HookDecision': + return cls( + action=DecisionAction.ASK, + event_type=event_type, + user_message=user_message, + agent_message=agent_message, + ) + + +class IDE(ABC): + """Per-IDE integration. Owns every IDE-specific concern in a single module. + + Subclasses declare identity via class attributes and implement the abstract + methods. Defaults are provided for `get_user_email` and `get_session_context` + so IDEs without those capabilities (e.g. no plugin system, no local + account file) can skip them. + """ + + # CLI value passed to --ide (e.g. 'cursor', 'claude-code'). + name: ClassVar[str] + # Human-friendly name for output ('Cursor', 'Claude Code'). + display_name: ClassVar[str] + # Event names for status display. Use ':' for IDEs that + # qualify a single hook by a sub-matcher (e.g. Claude Code's PreToolUse:Read). + hook_events: ClassVar[list[str]] + + # --- install / status --- + + @abstractmethod + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + """Return the hooks/settings file path for the given scope. + + `scope` is 'user' or 'repo'. `repo_path` is required when scope == 'repo'. + """ + + @abstractmethod + def render_hooks_config(self, async_mode: bool = False) -> dict: + """Return the settings blob to merge into the IDE's settings file. + + Shape is IDE-specific (Cursor uses a flat ``{event: [{command}]}`` dict; + Claude Code uses a nested ``{event: [{hooks: [{type, command}]}]}`` + dict). Both share the outer ``{"hooks": ...}`` wrapper so + ``hooks_manager`` can treat them uniformly. + """ + + # --- runtime scan --- + + @abstractmethod + def matches_payload(self, raw_payload: dict) -> bool: + """Return True if ``raw_payload`` originated from this IDE. + + Prevents double-processing when an IDE forwards another IDE's hook + event (e.g. Cursor reading Claude Code hooks from ~/.claude/settings.json). + """ + + @abstractmethod + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + """Normalize a raw stdin payload into the canonical ``AIHookPayload``.""" + + @abstractmethod + def build_hook_response(self, decision: HookDecision) -> dict: + """Translate a canonical ``HookDecision`` into the IDE-specific JSON. + + The result is what ``scan_command`` writes to stdout for the IDE to + act on. + """ + + # --- session lifecycle (optional; sensible defaults) --- + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + """Build a session-start payload from the raw stdin payload. + + Default: a minimal payload tagged with this IDE's ``name``. IDEs + that need to enrich with transcript/version info should override. + """ + return AIHookPayload(ide_provider=self.name) + + def get_user_email(self) -> Optional[str]: + """Best-effort read of the user's email from IDE-specific config. + + Default: None. Override if the IDE stores a usable account locally. + """ + return None + + def get_session_context(self) -> tuple[dict, dict]: + """Return ``(mcp_servers, enabled_plugins)`` for session-context reporting. + + Default: empty dicts (no plugin system, no discoverable MCP config). + Override to surface MCP/plugin inventory. + """ + return {}, {} diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py new file mode 100644 index 00000000..519914b9 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -0,0 +1,389 @@ +"""Claude Code IDE integration for AI guardrails.""" + +import json +from collections.abc import Iterator +from copy import deepcopy +from pathlib import Path +from typing import ClassVar, Optional + +from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Claude Code') + +_CLAUDE_CODE_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'}) + +_USER_HOOKS_DIR = Path.home() / '.claude' +_HOOKS_FILE_NAME = 'settings.json' +_REPO_SUBDIR = '.claude' +_HOOK_EVENTS = ['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp'] + +_CLAUDE_CONFIG_PATH = Path.home() / '.claude.json' +_CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json' + +_SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code' +_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide claude-code' + + +# --- transcript JSONL parsing ------------------------------------------------- + + +def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]: + """Yield lines of `path` from end to start without loading the file. + + The Claude Code transcript can be very large; reading from the tail keeps + memory bounded since we only care about the most recent entries. + """ + with path.open('rb') as f: + f.seek(0, 2) + file_size = f.tell() + if file_size == 0: + return + + remaining = file_size + buffer = b'' + + while remaining > 0: + read_size = min(buf_size, remaining) + remaining -= read_size + f.seek(remaining) + chunk = f.read(read_size) + buffer = chunk + buffer + + while b'\n' in buffer: + newline_pos = buffer.rfind(b'\n') + if newline_pos == len(buffer) - 1: + newline_pos = buffer.rfind(b'\n', 0, newline_pos) + if newline_pos == -1: + break + line = buffer[newline_pos + 1 :] + buffer = buffer[: newline_pos + 1] + if line.strip(): + yield line.decode('utf-8', errors='replace') + + if buffer.strip(): + yield buffer.decode('utf-8', errors='replace') + + +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, generation_id)`` from a transcript. + + The transcript is a JSONL file scanned from end → start so the most recent + entries are read first. Any field may come back ``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) + 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 + except OSError: + pass + + return ide_version, model, generation_id + + +# --- ~/.claude.json + ~/.claude/settings.json parsing ------------------------- + + +def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse `~/.claude.json`. Returns None if missing/invalid.""" + path = config_path or _CLAUDE_CONFIG_PATH + if not path.exists(): + logger.debug('Claude config file not found', extra={'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load Claude config file', exc_info=e) + return None + + +def _email_from_config(config: dict) -> Optional[str]: + """Read ``oauthAccount.emailAddress`` from a parsed Claude config.""" + return config.get('oauthAccount', {}).get('emailAddress') + + +def get_mcp_servers(config: dict) -> Optional[dict]: + """Read ``mcpServers`` from a parsed Claude config.""" + return config.get('mcpServers') + + +def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse `~/.claude/settings.json`. Returns None if missing/invalid.""" + path = settings_path or _CLAUDE_SETTINGS_PATH + if not path.exists(): + logger.debug('Claude settings file not found', extra={'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load Claude settings file', exc_info=e) + return None + + +def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]: + """Resolve filesystem path for a directory-type marketplace.""" + source = marketplace.get('source', {}) + if source.get('source') != 'directory': + return None + raw = source.get('path') + if not raw: + return None + path = Path(raw) + return path if path.is_dir() else None + + +def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]: + """Load and parse a JSON file inside a plugin directory. + + Returns None if the file is missing, unreadable, or has invalid JSON. + """ + target = plugin_path / relative_path + if not target.exists(): + return None + try: + return json.loads(target.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e) + return None + + +def resolve_plugins(settings: dict) -> tuple[dict, dict]: + """Resolve enabled plugins to their MCP servers and metadata. + + Walks ``enabledPlugins`` from claude settings, resolves each plugin's + marketplace directory via ``extraKnownMarketplaces``, and reads: + - ``/.mcp.json`` for MCP servers (merged into a flat dict) + - ``/.claude-plugin/plugin.json`` for metadata (name, version, description) + + Returns ``(merged_mcp_servers, enriched_plugins)``. + """ + enabled = settings.get('enabledPlugins') or {} + marketplaces = settings.get('extraKnownMarketplaces') or {} + merged_mcp: dict = {} + enriched: dict = {} + + for plugin_key, is_enabled in enabled.items(): + if not is_enabled: + continue + + entry: dict = {'enabled': True} + enriched[plugin_key] = entry + + if '@' not in plugin_key: + continue + + _plugin_name, marketplace_name = plugin_key.split('@', 1) + marketplace = marketplaces.get(marketplace_name) + if not marketplace: + continue + + plugin_path = _resolve_marketplace_path(marketplace) + if plugin_path is None: + continue + + metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {} + for field in ('name', 'version', 'description'): + if field in metadata: + entry[field] = metadata[field] + + mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {} + plugin_server_names = [] + for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items(): + merged_mcp[server_name] = server_cfg + plugin_server_names.append(server_name) + if plugin_server_names: + entry['mcp_server_names'] = plugin_server_names + + return merged_mcp, enriched + + +# --- IDE integration ---------------------------------------------------------- + + +class ClaudeCode(IDE): + name: ClassVar[str] = 'claude-code' + display_name: ClassVar[str] = 'Claude Code' + hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS) + + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + if scope == 'repo' and repo_path: + return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME + return _USER_HOOKS_DIR / _HOOKS_FILE_NAME + + def render_hooks_config(self, async_mode: bool = False) -> dict: + # Claude Code uses a nested hook structure with optional async/timeout. + hook_entry: dict = {'type': 'command', 'command': _SCAN_COMMAND} + if async_mode: + hook_entry['async'] = True + hook_entry['timeout'] = 20 + + return { + 'hooks': { + 'SessionStart': [ + { + 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}], + } + ], + 'UserPromptSubmit': [ + { + 'hooks': [deepcopy(hook_entry)], + } + ], + 'PreToolUse': [ + { + 'matcher': 'Read', + 'hooks': [deepcopy(hook_entry)], + }, + { + 'matcher': 'mcp__.*', + 'hooks': [deepcopy(hook_entry)], + }, + ], + }, + } + + def matches_payload(self, raw_payload: dict) -> bool: + return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES + + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + hook_event_name = raw_payload.get('hook_event_name', '') + tool_name = raw_payload.get('tool_name', '') + tool_input = raw_payload.get('tool_input') + + if hook_event_name == 'UserPromptSubmit': + canonical_event: AiHookEventType | str = AiHookEventType.PROMPT + elif hook_event_name == 'PreToolUse': + canonical_event = AiHookEventType.FILE_READ if tool_name == 'Read' else AiHookEventType.MCP_EXECUTION + else: + canonical_event = hook_event_name + + # Extract file_path from tool_input for the 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 + + # MCP 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] + + ide_version, model, generation_id = extract_from_claude_transcript(raw_payload.get('transcript_path')) + + config = load_claude_config() + ide_user_email = _email_from_config(config) if config else None + + return AIHookPayload( + event_name=canonical_event, + conversation_id=raw_payload.get('session_id'), + generation_id=generation_id, + ide_user_email=ide_user_email, + model=model, + ide_provider=self.name, + ide_version=ide_version, + prompt=raw_payload.get('prompt', ''), + file_path=file_path, + mcp_server_name=mcp_server_name, + mcp_tool_name=mcp_tool_name, + mcp_arguments=mcp_arguments, + ) + + def build_hook_response(self, decision: HookDecision) -> dict: + if decision.event_type == AiHookEventType.PROMPT: + if decision.action == DecisionAction.ALLOW: + return {} + # Both DENY and (unexpected) ASK on prompts collapse to a block. + return {'decision': 'block', 'reason': decision.user_message or ''} + + # FILE_READ / MCP_EXECUTION → hookSpecificOutput shape. + if decision.action == DecisionAction.ALLOW: + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'allow', + } + } + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': decision.action.value, # 'deny' or 'ask' + 'permissionDecisionReason': decision.user_message or '', + } + } + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + config = load_claude_config() + ide_user_email = _email_from_config(config) if config else None + ide_version, _, _ = extract_from_claude_transcript(raw_payload.get('transcript_path')) + + return AIHookPayload( + conversation_id=raw_payload.get('session_id'), + ide_user_email=ide_user_email, + model=raw_payload.get('model'), + ide_provider=self.name, + ide_version=ide_version, + source=raw_payload.get('source'), + ) + + def get_user_email(self) -> Optional[str]: + config = load_claude_config() + return _email_from_config(config) if config else None + + def get_session_context(self) -> tuple[dict, dict]: + config = load_claude_config() + mcp_servers: dict = dict(get_mcp_servers(config) or {}) if config else {} + + settings = load_claude_settings() + if settings: + plugin_mcp, enriched_plugins = resolve_plugins(settings) + mcp_servers.update(plugin_mcp) + else: + enriched_plugins = {} + + return mcp_servers, enriched_plugins diff --git a/cycode/cli/apps/ai_guardrails/ides/cursor.py b/cycode/cli/apps/ai_guardrails/ides/cursor.py new file mode 100644 index 00000000..4f6be1eb --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/cursor.py @@ -0,0 +1,119 @@ +"""Cursor IDE integration for AI guardrails.""" + +import json +import platform +from pathlib import Path +from typing import ClassVar, Optional + +from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Cursor') + +_CURSOR_EVENT_MAPPING: dict[str, AiHookEventType] = { + 'beforeSubmitPrompt': AiHookEventType.PROMPT, + 'beforeReadFile': AiHookEventType.FILE_READ, + 'beforeMCPExecution': AiHookEventType.MCP_EXECUTION, +} + +_HOOKS_FILE_NAME = 'hooks.json' +_REPO_SUBDIR = '.cursor' +_MCP_CONFIG_FILENAME = 'mcp.json' + +# Cursor was the original default IDE — its scan command omits --ide to stay +# byte-identical with already-installed hooks.json files. Session-start is +# always explicit because it was introduced after Claude Code support. +_SCAN_COMMAND = CYCODE_SCAN_PROMPT_COMMAND +_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide cursor' + + +def _user_hooks_dir() -> Path: + """Per-platform Cursor user-scope settings directory.""" + if platform.system() == 'Darwin': + return Path.home() / '.cursor' + if platform.system() == 'Windows': + return Path.home() / 'AppData' / 'Roaming' / 'Cursor' + return Path.home() / '.config' / 'Cursor' + + +def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid.""" + path = config_path or (Path.home() / '.cursor' / _MCP_CONFIG_FILENAME) + if not path.exists(): + logger.debug('Cursor MCP config file not found', extra={'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load Cursor MCP config file', exc_info=e) + return None + + +class Cursor(IDE): + name: ClassVar[str] = 'cursor' + display_name: ClassVar[str] = 'Cursor' + hook_events: ClassVar[list[str]] = list(_CURSOR_EVENT_MAPPING) + + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + if scope == 'repo' and repo_path: + return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME + return _user_hooks_dir() / _HOOKS_FILE_NAME + + def render_hooks_config(self, async_mode: bool = False) -> dict: + command = f'{_SCAN_COMMAND} &' if async_mode else _SCAN_COMMAND + hooks = {event: [{'command': command}] for event in self.hook_events} + hooks['sessionStart'] = [{'command': _SESSION_START_COMMAND}] + return {'version': 1, 'hooks': hooks} + + def matches_payload(self, raw_payload: dict) -> bool: + return raw_payload.get('hook_event_name', '') in _CURSOR_EVENT_MAPPING + + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + cursor_event_name = raw_payload.get('hook_event_name', '') + canonical_event = _CURSOR_EVENT_MAPPING.get(cursor_event_name, cursor_event_name) + return AIHookPayload( + event_name=canonical_event, + conversation_id=raw_payload.get('conversation_id'), + generation_id=raw_payload.get('generation_id'), + ide_user_email=raw_payload.get('user_email'), + model=raw_payload.get('model'), + ide_provider=self.name, + ide_version=raw_payload.get('cursor_version'), + prompt=raw_payload.get('prompt', ''), + file_path=raw_payload.get('file_path') or raw_payload.get('path'), + mcp_server_name=raw_payload.get('command'), + mcp_tool_name=raw_payload.get('tool_name') or raw_payload.get('tool'), + mcp_arguments=(raw_payload.get('arguments') or raw_payload.get('tool_input') or raw_payload.get('input')), + ) + + def build_hook_response(self, decision: HookDecision) -> dict: + if decision.event_type == AiHookEventType.PROMPT: + if decision.action == DecisionAction.ALLOW: + return {'continue': True} + return {'continue': False, 'user_message': decision.user_message or ''} + + # FILE_READ / MCP_EXECUTION → permission shape + if decision.action == DecisionAction.ALLOW: + return {'permission': 'allow'} + return { + 'permission': decision.action.value, # 'deny' or 'ask' + 'user_message': decision.user_message or '', + 'agent_message': decision.agent_message or '', + } + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + return AIHookPayload( + conversation_id=raw_payload.get('conversation_id'), + ide_user_email=raw_payload.get('user_email'), + model=raw_payload.get('model'), + ide_provider=self.name, + ide_version=raw_payload.get('cursor_version'), + ) + + def get_session_context(self) -> tuple[dict, dict]: + config = _load_cursor_mcp_config() + mcp_servers = dict((config or {}).get('mcpServers') or {}) if config else {} + return mcp_servers, {} diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index a92a978f..0ee5aacb 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -5,14 +5,10 @@ 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, AIIDEType, InstallMode, PolicyMode +from cycode.cli.apps.ai_guardrails.command_utils import console, resolve_repo_path, validate_scope +from cycode.cli.apps.ai_guardrails.consts import InstallMode, PolicyMode from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, install_hooks +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides def install_command( @@ -29,9 +25,9 @@ def install_command( str, typer.Option( '--ide', - help='IDE to install hooks for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', + help=f'IDE to install hooks for ({", ".join(IDES)}, or "all" for every supported IDE).', ), - ] = AIIDEType.CURSOR.value, + ] = DEFAULT_IDE_NAME, repo_path: Annotated[ Optional[Path], typer.Option( @@ -55,35 +51,30 @@ def install_command( ) -> 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. + Configures the specified IDE to use Cycode for scanning prompts, file reads, + and MCP tool calls for secrets before they reach the AI model. Examples: cycode ai-guardrails install # Install in report mode (default) cycode ai-guardrails install --mode block # Install in block mode cycode ai-guardrails install --scope repo # Install for current repo only - cycode ai-guardrails install --ide cursor # Install for Cursor IDE - cycode ai-guardrails install --ide all # Install for all supported IDEs - cycode ai-guardrails install --scope repo --repo-path /path/to/repo + cycode ai-guardrails install --ide claude-code # Install for a specific IDE + cycode ai-guardrails install --ide all # Install for every supported IDE """ - # Validate inputs validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) - ide_type = validate_and_parse_ide(ide) + ides_to_install = resolve_ides(ide) - ides_to_install: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] + report_mode = mode == InstallMode.REPORT results: list[tuple[str, bool, str]] = [] for current_ide in ides_to_install: - ide_name = IDE_CONFIGS[current_ide].name - report_mode = mode == InstallMode.REPORT - success, message = install_hooks(scope, repo_path, ide=current_ide, report_mode=report_mode) - results.append((ide_name, success, message)) + success, message = install_hooks(current_ide, scope, repo_path, report_mode=report_mode) + results.append((current_ide.display_name, success, message)) - # Report results for each IDE any_success = False all_success = True - for _ide_name, success, message in results: + for _name, success, message in results: if success: console.print(f'[green]✓[/] {message}') any_success = True diff --git a/cycode/cli/apps/ai_guardrails/scan/claude_config.py b/cycode/cli/apps/ai_guardrails/scan/claude_config.py deleted file mode 100644 index 4b547427..00000000 --- a/cycode/cli/apps/ai_guardrails/scan/claude_config.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Reader for ~/.claude.json configuration file. - -Extracts user email from the Claude Code global config file -for use in AI guardrails scan enrichment. -""" - -import json -from pathlib import Path -from typing import Optional - -from cycode.logger import get_logger - -logger = get_logger('AI Guardrails Claude Config') - -_CLAUDE_CONFIG_PATH = Path.home() / '.claude.json' -_CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json' - - -def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]: - """Load and parse ~/.claude.json. - - Args: - config_path: Override path for testing. Defaults to ~/.claude.json. - - Returns: - Parsed dict or None if file is missing or invalid. - """ - path = config_path or _CLAUDE_CONFIG_PATH - if not path.exists(): - logger.debug('Claude config file not found', extra={'path': str(path)}) - return None - try: - content = path.read_text(encoding='utf-8') - return json.loads(content) - except Exception as e: - logger.debug('Failed to load Claude config file', exc_info=e) - return None - - -def get_user_email(config: dict) -> Optional[str]: - """Extract user email from Claude config. - - Reads oauthAccount.emailAddress from the config dict. - """ - return config.get('oauthAccount', {}).get('emailAddress') - - -def get_mcp_servers(config: dict) -> Optional[dict]: - """Extract MCP servers from Claude config. - - Reads mcpServers from the config dict. - """ - return config.get('mcpServers') - - -def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]: - """Load and parse ~/.claude/settings.json. - - Args: - settings_path: Override path for testing. Defaults to ~/.claude/settings.json. - - Returns: - Parsed dict or None if file is missing or invalid. - """ - path = settings_path or _CLAUDE_SETTINGS_PATH - if not path.exists(): - logger.debug('Claude settings file not found', extra={'path': str(path)}) - return None - try: - content = path.read_text(encoding='utf-8') - return json.loads(content) - except Exception as e: - logger.debug('Failed to load Claude settings file', exc_info=e) - return None - - -def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]: - """ - Resolve filesystem path for a directory-type marketplace. - """ - source = marketplace.get('source', {}) - if source.get('source') != 'directory': - return None - raw = source.get('path') - if not raw: - return None - path = Path(raw) - return path if path.is_dir() else None - - -def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]: - """Load and parse a JSON file inside a plugin directory. - - Returns None if the file is missing, unreadable, or has invalid JSON. - """ - target = plugin_path / relative_path - if not target.exists(): - return None - try: - return json.loads(target.read_text(encoding='utf-8')) - except Exception as e: - logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e) - return None - - -def resolve_plugins(settings: dict) -> tuple[dict, dict]: - """Resolve enabled plugins to their MCP servers and metadata. - - Walks enabledPlugins from claude settings, resolves each plugin's 'marketplace' directory - via the 'extraKnownMarketplaces' field, and reads: - - /.mcp.json for MCP servers (merged into a flat dict) - - /.claude-plugin/plugin.json for metadata (name, version, description) - - Args: - settings: Parsed ~/.claude/settings.json dict. - - Returns: - Tuple of (merged_mcp_servers, enriched_plugins): - - merged_mcp_servers: {server_name: server_config, ...} - - enriched_plugins: {plugin_key: {"enabled": True, "name": ..., ...}, ...} - """ - enabled = settings.get('enabledPlugins') or {} - marketplaces = settings.get('extraKnownMarketplaces') or {} - merged_mcp: dict = {} - enriched: dict = {} - - for plugin_key, is_enabled in enabled.items(): - if not is_enabled: - continue - - entry: dict = {'enabled': True} - enriched[plugin_key] = entry - - if '@' not in plugin_key: - continue - - _plugin_name, marketplace_name = plugin_key.split('@', 1) - marketplace = marketplaces.get(marketplace_name) - if not marketplace: - continue - - plugin_path = _resolve_marketplace_path(marketplace) - if plugin_path is None: - continue - - metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {} - for field in ('name', 'version', 'description'): - if field in metadata: - entry[field] = metadata[field] - - mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {} - plugin_server_names = [] - for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items(): - merged_mcp[server_name] = server_cfg - plugin_server_names.append(server_name) - if plugin_server_names: - entry['mcp_server_names'] = plugin_server_names - - return merged_mcp, enriched diff --git a/cycode/cli/apps/ai_guardrails/scan/cursor_config.py b/cycode/cli/apps/ai_guardrails/scan/cursor_config.py deleted file mode 100644 index 9a174a7a..00000000 --- a/cycode/cli/apps/ai_guardrails/scan/cursor_config.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Reader for ~/.cursor/mcp.json configuration file. - -Extracts MCP server definitions from the Cursor global config file -for use in AI guardrails session-context reporting. -""" - -import json -from pathlib import Path -from typing import Optional - -from cycode.logger import get_logger - -logger = get_logger('AI Guardrails Cursor Config') - -_CURSOR_MCP_CONFIG_PATH = Path.home() / '.cursor' / 'mcp.json' - - -def load_cursor_config(config_path: Optional[Path] = None) -> Optional[dict]: - """Load and parse ~/.cursor/mcp.json. - - Args: - config_path: Override path for testing. Defaults to ~/.cursor/mcp.json. - - Returns: - Parsed dict or None if file is missing or invalid. - """ - path = config_path or _CURSOR_MCP_CONFIG_PATH - if not path.exists(): - logger.debug('Cursor MCP config file not found', extra={'path': str(path)}) - return None - try: - content = path.read_text(encoding='utf-8') - return json.loads(content) - except Exception as e: - logger.debug('Failed to load Cursor MCP config file', exc_info=e) - return None diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index fa0bddee..4a56a179 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -1,8 +1,11 @@ -""" -Hook handlers for AI IDE events. +"""Hook handlers for AI IDE events. + +Each handler receives a unified payload and policy, applies the scan + policy +logic, and returns a canonical ``HookDecision``. ``scan_command`` translates +that decision into the IDE-specific JSON response via ``IDE.build_hook_response``. -Each handler receives a unified payload from an IDE, applies policy rules, -and returns a response that either allows or blocks the action. +Handlers are agent-agnostic by design — adding a new IDE doesn't require +touching any handler in this module. """ import json @@ -14,9 +17,9 @@ import typer from cycode.cli.apps.ai_guardrails.consts import PolicyMode +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision 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 @@ -30,21 +33,17 @@ logger = get_logger('AI Guardrails') -def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict: - """ - Handle beforeSubmitPrompt hook. +HandlerFn = Callable[[typer.Context, AIHookPayload, dict], HookDecision] - Scans prompt text for secrets before it's sent to the AI model. - Returns {"continue": False} to block, {"continue": True} to allow. - """ + +def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: + """Scan prompt text for secrets before it's sent to the AI model.""" 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={}) if not get_policy_value(prompt_config, 'enabled', default=True): ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED) - return response_builder.allow_prompt() + return HookDecision.allow(AiHookEventType.PROMPT) mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) prompt = payload.prompt or '' @@ -66,9 +65,9 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli 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) + return HookDecision.deny(AiHookEventType.PROMPT, user_message) outcome = AIHookOutcome.WARNED - return response_builder.allow_prompt() + return HookDecision.allow(AiHookEventType.PROMPT) except Exception as e: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED @@ -87,21 +86,14 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli ) -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. - """ +def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: + """Block sensitive paths and scan file content for secrets.""" 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={}) 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() + return HookDecision.allow(AiHookEventType.FILE_READ) mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) file_path = payload.file_path or '' @@ -113,20 +105,19 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: error_message = None try: - # Check path-based denylist first is_sensitive_path = is_denied_path(file_path, policy) if is_sensitive_path: block_reason = BlockReason.SENSITIVE_PATH 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( + return HookDecision.deny( + AiHookEventType.FILE_READ, user_message, 'This file path is classified as sensitive; do not read/send it to the model.', ) - # Warn mode - if content scan is enabled, emit a separate event for the + # Warn mode: if content scan is enabled, emit a separate event for the # sensitive path so the finally block can independently track the scan result. - # If content scan is disabled, a single event (from finally) is enough. outcome = AIHookOutcome.WARNED if get_policy_value(file_read_config, 'scan_content', default=True): ai_client.create_event( @@ -136,11 +127,9 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: block_reason=BlockReason.SENSITIVE_PATH, file_path=payload.file_path, ) - # Reset for the content scan result tracked by the finally block block_reason = None outcome = AIHookOutcome.ALLOWED - # 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: @@ -148,27 +137,28 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: 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( + return HookDecision.deny( + AiHookEventType.FILE_READ, 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( + return HookDecision.ask( + AiHookEventType.FILE_READ, user_message, 'Possible secrets detected; proceed with caution.', ) - # If path was sensitive but content scan found no secrets (or scan disabled), still warn if is_sensitive_path: user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?' - return response_builder.ask_permission( + return HookDecision.ask( + AiHookEventType.FILE_READ, user_message, 'This file path is classified as sensitive; proceed with caution.', ) - return response_builder.allow_permission() + return HookDecision.allow(AiHookEventType.FILE_READ) except Exception as e: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED @@ -188,22 +178,14 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: ) -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. - """ +def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: + """Scan MCP tool arguments for secrets before execution.""" 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={}) 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() + return HookDecision.allow(AiHookEventType.MCP_EXECUTION) mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) tool = payload.mcp_tool_name or 'unknown' @@ -227,17 +209,19 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli 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( + return HookDecision.deny( + AiHookEventType.MCP_EXECUTION, user_message, 'Do not pass secrets to tools. Use secret references (name/id) instead.', ) outcome = AIHookOutcome.WARNED - return response_builder.ask_permission( + return HookDecision.ask( + AiHookEventType.MCP_EXECUTION, f'{violation_summary} in MCP tool call "{tool}". Allow execution?', 'Possible secrets detected in tool arguments; proceed with caution.', ) - return response_builder.allow_permission() + return HookDecision.allow(AiHookEventType.MCP_EXECUTION) except Exception as e: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED @@ -256,16 +240,9 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli ) -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: - event_type: Canonical event type string (from AiHookEventType enum) - - Returns: - Handler function or None if event type is not recognized - """ - handlers = { +def get_handler_for_event(event_type: str) -> Optional[HandlerFn]: + """Look up the handler for a canonical event type.""" + handlers: dict[str, HandlerFn] = { AiHookEventType.PROMPT.value: handle_before_submit_prompt, AiHookEventType.FILE_READ.value: handle_before_read_file, AiHookEventType.MCP_EXECUTION.value: handle_before_mcp_execution, @@ -275,32 +252,24 @@ def get_handler_for_event(event_type: str) -> Optional[Callable[[typer.Context, 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 - 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.obj['sync'] = True + ctx.obj['scan_type'] = ScanTypeOption.SECRET + ctx.obj['severity_threshold'] = SeverityOption.INFO ctx.info_name = 'ai_guardrails' - 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. + """Run a scan on documents, returning (violation_summary, scan_id). - 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). + Raises on scan failure / timeout so the fail-open policy can take over. """ 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 ) @@ -324,7 +293,6 @@ def _perform_scan( 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 @@ -333,12 +301,7 @@ def _perform_scan( 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. - """ + """Scan text content for secrets using Cycode CLI.""" if not text: return None, None @@ -349,12 +312,7 @@ def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tu 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. - """ + """Scan a file path for secrets.""" if not file_path or not os.path.isfile(file_path): return None, None @@ -363,7 +321,6 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> with open(file_path, encoding='utf-8', errors='replace') as f: content = f.read(max_bytes) - # Get timeout from policy timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) timeout_seconds = timeout_ms / 1000.0 diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index d8fd4c53..19845601 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -1,275 +1,34 @@ -"""Unified payload object for AI hook events from different tools.""" +"""Canonical AI hook payload shared across IDE integrations. + +The dataclass is populated by `IDE.parse_hook_payload` (see +`cycode/cli/apps/ai_guardrails/ides/`). Per-IDE parsing logic lives on the +respective IDE class. +""" -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.consts import AIIDEType -from cycode.cli.apps.ai_guardrails.scan.claude_config import get_user_email, load_claude_config -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_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. - - 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) - 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 - except OSError: - pass - - return ide_version, model, generation_id - @dataclass class AIHookPayload: - """Unified payload object that normalizes field names from different AI tools.""" + """Unified payload that normalizes field names across IDEs.""" # Event identification - event_name: Optional[str] = None # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution') + event_name: Optional[str] = None # Canonical event type from AiHookEventType 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 # AIIDEType value (e.g., 'cursor', 'claude-code') + ide_provider: Optional[str] = None # Matches IDE.name (e.g. 'cursor', 'claude-code') ide_version: Optional[str] = None source: Optional[str] = None # 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 - - @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=AIIDEType.CURSOR.value, - 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'), - ) - - @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')) - - # Extract user email from ~/.claude.json - claude_config = load_claude_config() - ide_user_email = get_user_email(claude_config) if claude_config else None - - return cls( - event_name=canonical_event, - conversation_id=payload.get('session_id'), - generation_id=generation_id, - ide_user_email=ide_user_email, - model=model, - ide_provider=AIIDEType.CLAUDE_CODE.value, - 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 or AIIDEType enum value - - Returns: - True if the payload matches the IDE, False otherwise. - """ - hook_event_name = payload.get('hook_event_name', '') - - if ide == AIIDEType.CLAUDE_CODE: - return hook_event_name in CLAUDE_CODE_EVENT_NAMES - 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 = AIIDEType.CURSOR.value) -> 'AIHookPayload': - """Create AIHookPayload from any tool's payload. - - Args: - payload: The raw payload from the IDE - tool: The IDE/tool name or AIIDEType enum value - - Returns: - AIHookPayload instance - - Raises: - ValueError: If the tool is not supported - """ - if tool == AIIDEType.CURSOR: - return cls.from_cursor_payload(payload) - if tool == AIIDEType.CLAUDE_CODE: - return cls.from_claude_code_payload(payload) - raise ValueError(f'Unsupported IDE/tool: {tool}') + prompt: Optional[str] = None # PROMPT events + file_path: Optional[str] = None # FILE_READ events + mcp_server_name: Optional[str] = None # MCP_EXECUTION events + mcp_tool_name: Optional[str] = None + mcp_arguments: Optional[dict] = None diff --git a/cycode/cli/apps/ai_guardrails/scan/response_builders.py b/cycode/cli/apps/ai_guardrails/scan/response_builders.py deleted file mode 100644 index ff0a6aa4..00000000 --- a/cycode/cli/apps/ai_guardrails/scan/response_builders.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -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 - -from cycode.cli.apps.ai_guardrails.consts import AIIDEType - - -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.""" - - @abstractmethod - def deny_permission(self, user_message: str, agent_message: str) -> dict: - """Build response to deny file read or MCP execution.""" - - @abstractmethod - def ask_permission(self, user_message: str, agent_message: str) -> dict: - """Build response to ask user for permission (warn mode).""" - - @abstractmethod - def allow_prompt(self) -> dict: - """Build response to allow prompt submission.""" - - @abstractmethod - def deny_prompt(self, user_message: str) -> dict: - """Build response to deny prompt submission.""" - - -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} - - -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 type -_RESPONSE_BUILDERS: dict[str, IDEResponseBuilder] = { - AIIDEType.CURSOR: CursorResponseBuilder(), - AIIDEType.CLAUDE_CODE: ClaudeCodeResponseBuilder(), -} - - -def get_response_builder(ide: str = AIIDEType.CURSOR.value) -> IDEResponseBuilder: - """Get the response builder for a specific IDE. - - Args: - ide: The IDE name (e.g., 'cursor', 'claude-code') or AIIDEType enum - - 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/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index add2bb83..bd31d33e 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -1,26 +1,22 @@ -""" -Scan command for AI guardrails. +"""Scan command for AI guardrails IDE hooks. -This command handles AI IDE hooks by reading JSON from stdin and outputting -a JSON response to stdout. It scans prompts, file reads, and MCP tool calls -for secrets before they are sent to AI models. +Reads a JSON payload from stdin, routes it through the IDE-specific parser and +the shared event handlers, then writes an IDE-specific 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). +The handlers in ``handlers.py`` are agent-agnostic (they return +``HookDecision``); ``IDE.build_hook_response`` is the per-IDE translation step. """ import sys -from typing import Annotated +from typing import Annotated, Optional, Union import click import typer -from cycode.cli.apps.ai_guardrails.consts import AIIDEType +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision 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 @@ -31,7 +27,7 @@ def _get_auth_error_message(error: Exception) -> str: - """Get user-friendly message for authentication errors.""" + """User-friendly message for authentication errors.""" if isinstance(error, click.ClickException): # Missing credentials return f'{error.message} Please run `cycode auth` to set up your credentials.' @@ -47,6 +43,23 @@ def _get_auth_error_message(error: Exception) -> str: return 'Authentication failed. Please run `cycode auth` to set up your credentials.' +def _deny_for_event( + event_name: Optional[Union[str, AiHookEventType]], + user_message: str, + agent_message: Optional[str] = None, +) -> HookDecision: + """Build a deny decision matched to ``event_name``'s response shape. + + PROMPT events use the prompt-block shape (no agent_message). For anything + else — including unknown event names — fall back to FILE_READ since + FILE_READ and MCP_EXECUTION share the same response shape on both IDEs. + """ + if event_name == AiHookEventType.PROMPT: + return HookDecision.deny(AiHookEventType.PROMPT, user_message) + target = event_name if isinstance(event_name, AiHookEventType) else AiHookEventType.FILE_READ + return HookDecision.deny(target, user_message, agent_message) + + def _initialize_clients(ctx: typer.Context) -> None: """Initialize API clients. @@ -69,44 +82,36 @@ def scan_command( help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.', hidden=True, ), - ] = AIIDEType.CURSOR.value, + ] = DEFAULT_IDE_NAME, ) -> None: """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. - - 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 ai-guardrails scan" } + Reads a JSON payload from stdin and outputs a JSON response to stdout + indicating whether to allow or block the action. """ + ide_integration = get_ide(ide) + stdin_data = sys.stdin.read().strip() payload = safe_json_parse(stdin_data) - tool = ide.lower() - response_builder = get_response_builder(tool) - if not payload: logger.debug('Empty or invalid JSON payload received') - output_json(response_builder.allow_prompt()) + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.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): + # Prevent cross-IDE processing (e.g. Cursor reading Claude Code hooks + # from ~/.claude/settings.json). + if not ide_integration.matches_payload(payload): logger.debug( 'Payload event does not match expected IDE, skipping', - extra={'hook_event_name': payload.get('hook_event_name'), 'expected_ide': tool}, + extra={'hook_event_name': payload.get('hook_event_name'), 'expected_ide': ide_integration.name}, ) - output_json(response_builder.allow_prompt()) + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) return - unified_payload = AIHookPayload.from_payload(payload, tool=tool) + unified_payload = ide_integration.parse_hook_payload(payload) event_name = unified_payload.event_name - logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'tool': tool}) + logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'ide': ide_integration.name}) workspace_roots = payload.get('workspace_roots', ['.']) policy = load_policy(workspace_roots[0]) @@ -117,26 +122,33 @@ def scan_command( 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()) + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) return - response = handler(ctx, unified_payload, policy) - logger.debug('Hook handler completed', extra={'event_name': event_name, 'response': response}) - output_json(response) + decision = handler(ctx, unified_payload, policy) + logger.debug('Hook handler completed', extra={'event_name': event_name, 'action': decision.action.value}) + output_json(ide_integration.build_hook_response(decision)) 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')) + output_json( + ide_integration.build_hook_response( + _deny_for_event(event_name, _get_auth_error_message(e), 'Authentication required') + ) + ) except Exception as e: logger.error('Hook handler failed', exc_info=e) if policy.get('fail_open', True): - 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(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) return - output_json(response_builder.deny_permission('Cycode guardrails error', 'Blocking due to fail-closed policy')) + output_json( + ide_integration.build_hook_response( + _deny_for_event( + event_name, + 'Cycode guardrails error - blocking due to fail-closed policy' + if event_name == AiHookEventType.PROMPT + else 'Cycode guardrails error', + 'Blocking due to fail-closed policy', + ) + ) + ) diff --git a/cycode/cli/apps/ai_guardrails/scan/types.py b/cycode/cli/apps/ai_guardrails/scan/types.py index 585c7820..da42ed23 100644 --- a/cycode/cli/apps/ai_guardrails/scan/types.py +++ b/cycode/cli/apps/ai_guardrails/scan/types.py @@ -1,4 +1,9 @@ -"""Type definitions for AI guardrails.""" +"""Canonical event types and outcome enums for AI guardrails. + +Per-IDE event-name mappings live on the IDE class (in +`cycode/cli/apps/ai_guardrails/ides/`); only the IDE-agnostic enums are kept +here. +""" import sys @@ -13,36 +18,13 @@ def __str__(self) -> str: 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. - """ + """Canonical, IDE-agnostic hook event types.""" PROMPT = 'Prompt' FILE_READ = 'FileRead' MCP_EXECUTION = 'McpExecution' -# IDE-specific event name mappings to canonical types -CURSOR_EVENT_MAPPING = { - 'beforeSubmitPrompt': AiHookEventType.PROMPT, - 'beforeReadFile': AiHookEventType.FILE_READ, - '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.""" @@ -52,11 +34,7 @@ class AIHookOutcome(StrEnum): 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. - """ + """Categorical reason for blocking (sent to backend for tracking).""" SECRETS_IN_PROMPT = 'secrets_in_prompt' SECRETS_IN_FILE = 'secrets_in_file' diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index f2d5031c..cda53c62 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -1,18 +1,12 @@ +"""Handle AI guardrails session start: auth, conversation creation, session context.""" + import sys from typing import TYPE_CHECKING, Annotated, Optional import typer -from cycode.cli.apps.ai_guardrails.consts import AIIDEType -from cycode.cli.apps.ai_guardrails.scan.claude_config import ( - get_mcp_servers, - get_user_email, - load_claude_config, - load_claude_settings, - resolve_plugins, -) -from cycode.cli.apps.ai_guardrails.scan.cursor_config import load_cursor_config -from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload, extract_from_claude_transcript +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide +from cycode.cli.apps.ai_guardrails.ides.base import IDE from cycode.cli.apps.ai_guardrails.scan.utils import safe_json_parse from cycode.cli.apps.auth.auth_common import get_authorization_info from cycode.cli.apps.auth.auth_manager import AuthManager @@ -26,69 +20,10 @@ logger = get_logger('AI Guardrails') -def _build_session_payload(payload: dict, ide: str) -> AIHookPayload: - """Build an AIHookPayload from a session-start stdin payload.""" - if ide == AIIDEType.CLAUDE_CODE: - claude_config = load_claude_config() - ide_user_email = get_user_email(claude_config) if claude_config else None - ide_version, _, _ = extract_from_claude_transcript(payload.get('transcript_path')) - - return AIHookPayload( - conversation_id=payload.get('session_id'), - ide_user_email=ide_user_email, - model=payload.get('model'), - ide_provider=AIIDEType.CLAUDE_CODE.value, - ide_version=ide_version, - source=payload.get('source'), - ) - - # Cursor - return AIHookPayload( - conversation_id=payload.get('conversation_id'), - ide_user_email=payload.get('user_email'), - model=payload.get('model'), - ide_provider=AIIDEType.CURSOR.value, - ide_version=payload.get('cursor_version'), - ) - - -def _get_claude_code_session_context() -> tuple[dict, dict]: - """Return (mcp_servers, enabled_plugins) for Claude Code. - - Merges MCP servers from ~/.claude.json (user-configured) with those contributed - by enabled plugins. Plugin metadata (name, version, description) is included in - the enabled_plugins dict when resolvable. - """ - config = load_claude_config() - mcp_servers = dict(get_mcp_servers(config) or {}) if config else {} - - settings = load_claude_settings() - if settings: - plugin_mcp, enriched_plugins = resolve_plugins(settings) - mcp_servers.update(plugin_mcp) - else: - enriched_plugins = {} - - return mcp_servers, enriched_plugins - - -def _get_cursor_session_context() -> tuple[dict, dict]: - """Return (mcp_servers, enabled_plugins) for Cursor. Cursor has no plugin system.""" - config = load_cursor_config() - mcp_servers = dict(get_mcp_servers(config) or {}) if config else {} - return mcp_servers, {} - - -def _report_session_context(ai_client: 'AISecurityManagerClient', ide: str, user_email: Optional[str]) -> None: +def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None: """Report IDE session context to the AI security manager. Never raises.""" try: - if ide == AIIDEType.CLAUDE_CODE: - mcp_servers, enabled_plugins = _get_claude_code_session_context() - elif ide == AIIDEType.CURSOR: - mcp_servers, enabled_plugins = _get_cursor_session_context() - else: - return - + mcp_servers, enabled_plugins = ide.get_session_context() if not mcp_servers and not enabled_plugins: return ai_client.report_session_context( @@ -109,16 +44,17 @@ def session_start_command( help='IDE that triggered the session start.', hidden=True, ), - ] = AIIDEType.CURSOR.value, + ] = DEFAULT_IDE_NAME, ) -> None: """Handle session start: ensure auth, create conversation, report session context.""" + ide_integration = get_ide(ide) + # Step 1: Ensure authentication auth_info = get_authorization_info(ctx) if auth_info is None: logger.debug('Not authenticated, starting authentication') try: - auth_manager = AuthManager() - auth_manager.authenticate() + AuthManager().authenticate() except Exception as err: handle_auth_exception(ctx, err) return @@ -136,8 +72,8 @@ def session_start_command( logger.debug('Empty or invalid stdin payload, skipping session initialization') return - # Step 3: Build session payload and initialize API client - session_payload = _build_session_payload(payload, ide) + # Step 3: Build session payload + initialize API client + session_payload = ide_integration.build_session_payload(payload) try: ai_client = get_ai_security_manager_client(ctx) @@ -151,5 +87,5 @@ def session_start_command( except Exception as e: logger.debug('Failed to create conversation during session start', exc_info=e) - # Step 5: Report session context (MCP servers) - _report_session_context(ai_client, ide, session_payload.ide_user_email) + # Step 5: Report session context (MCP servers, enabled plugins) + _report_session_context(ai_client, ide_integration, session_payload.ide_user_email) diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py index ee1e5bcf..da201545 100644 --- a/cycode/cli/apps/ai_guardrails/status_command.py +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -7,9 +7,9 @@ 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.consts import IDE_CONFIGS, AIIDEType +from cycode.cli.apps.ai_guardrails.command_utils import console, validate_scope from cycode.cli.apps.ai_guardrails.hooks_manager import get_hooks_status +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides def status_command( @@ -26,9 +26,9 @@ def status_command( str, typer.Option( '--ide', - help='IDE to check status for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', + help=f'IDE to check status for ({", ".join(IDES)}, or "all").', ), - ] = AIIDEType.CURSOR.value, + ] = DEFAULT_IDE_NAME, repo_path: Annotated[ Optional[Path], typer.Option( @@ -43,32 +43,30 @@ def status_command( ) -> 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 - cycode ai-guardrails status --ide all # Check status for all supported IDEs + cycode ai-guardrails status --ide claude-code + cycode ai-guardrails status --ide all # Check every supported IDE """ - # 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) - - ides_to_check: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] + ides_to_check = resolve_ides(ide) 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 cyan]═══ {ide_name} ═══[/]') + console.print(f'[bold cyan]═══ {current_ide.display_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) + status = get_hooks_status( + current_ide, + check_scope, + repo_path if check_scope == 'repo' else None, + ) console.print() console.print(f'[bold]{check_scope.upper()} SCOPE[/]') @@ -83,7 +81,6 @@ def status_command( 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') diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py index f7b8341c..f9a995f3 100644 --- a/cycode/cli/apps/ai_guardrails/uninstall_command.py +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -5,14 +5,9 @@ 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, AIIDEType +from cycode.cli.apps.ai_guardrails.command_utils import console, resolve_repo_path, validate_scope from cycode.cli.apps.ai_guardrails.hooks_manager import uninstall_hooks +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides def uninstall_command( @@ -29,9 +24,9 @@ def uninstall_command( str, typer.Option( '--ide', - help='IDE to uninstall hooks from (e.g., "cursor", "claude-code", "all"). Defaults to cursor.', + help=f'IDE to uninstall hooks from ({", ".join(IDES)}, or "all").', ), - ] = AIIDEType.CURSOR.value, + ] = DEFAULT_IDE_NAME, repo_path: Annotated[ Optional[Path], typer.Option( @@ -46,32 +41,27 @@ def uninstall_command( ) -> 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. + Removes Cycode hooks from the IDE's hooks configuration. Other hooks + (if any) are 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 - cycode ai-guardrails uninstall --ide all # Uninstall from all supported IDEs + cycode ai-guardrails uninstall --ide claude-code # Uninstall from a specific IDE + cycode ai-guardrails uninstall --ide all # Uninstall from every supported IDE """ - # Validate inputs validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) - ide_type = validate_and_parse_ide(ide) - - ides_to_uninstall: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] + ides_to_uninstall = resolve_ides(ide) 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)) + success, message = uninstall_hooks(current_ide, scope, repo_path) + results.append((current_ide.display_name, success, message)) - # Report results for each IDE any_success = False all_success = True - for _ide_name, success, message in results: + for _name, success, message in results: if success: console.print(f'[green]✓[/] {message}') any_success = True diff --git a/tests/cli/commands/ai_guardrails/ides/__init__.py b/tests/cli/commands/ai_guardrails/ides/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py new file mode 100644 index 00000000..f997abe3 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -0,0 +1,262 @@ +"""Claude Code IDE integration tests.""" + +import json +from pathlib import Path +from unittest.mock import patch + +from pyfakefs.fake_filesystem import FakeFilesystem +from pytest_mock import MockerFixture + +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode, _email_from_config, load_claude_config +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +def test_matches_payload_only_claude_events() -> None: + claude = ClaudeCode() + assert claude.matches_payload({'hook_event_name': 'UserPromptSubmit'}) is True + assert claude.matches_payload({'hook_event_name': 'PreToolUse'}) is True + assert claude.matches_payload({'hook_event_name': 'beforeSubmitPrompt'}) is False + assert claude.matches_payload({'hook_event_name': 'beforeReadFile'}) is False + + +def test_parse_prompt_payload() -> None: + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'Test prompt', + } + ) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == 'session-123' + assert unified.ide_provider == 'claude-code' + assert unified.prompt == 'Test prompt' + + +def test_parse_file_read_payload() -> None: + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'PreToolUse', + 'session_id': 'session-456', + 'tool_name': 'Read', + 'tool_input': {'file_path': '/path/to/secret.env'}, + } + ) + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/path/to/secret.env' + assert unified.mcp_tool_name is None + + +def test_parse_mcp_execution_payload() -> None: + args = {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'} + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'PreToolUse', + 'tool_name': 'mcp__gitlab__discussion_list', + 'tool_input': args, + } + ) + + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'gitlab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == args + + +def test_parse_empty_payload_defaults() -> None: + unified = ClaudeCode().parse_hook_payload({'hook_event_name': 'UserPromptSubmit'}) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id is None + assert unified.prompt == '' + assert unified.ide_provider == 'claude-code' + + +def test_build_prompt_responses() -> None: + claude = ClaudeCode() + assert claude.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)) == {} + assert claude.build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'no!')) == { + 'decision': 'block', + 'reason': 'no!', + } + + +def test_build_permission_responses() -> None: + claude = ClaudeCode() + allow = claude.build_hook_response(HookDecision.allow(AiHookEventType.FILE_READ)) + assert allow == {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'allow'}} + + deny = claude.build_hook_response(HookDecision.deny(AiHookEventType.FILE_READ, 'user!', 'agent!')) + assert deny == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'user!', + } + } + + ask = claude.build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'u')) + assert ask == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'ask', + 'permissionDecisionReason': 'u', + } + } + + +# Transcript extraction + + +def test_extract_from_transcript(mocker: MockerFixture) -> None: + """version, model, generation_id from a Claude Code transcript JSONL.""" + 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.ides.claude_code.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 + + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test prompt', + 'transcript_path': '/mock/transcript.jsonl', + } + ) + + assert unified.ide_version == '2.1.20' + assert unified.model == 'claude-opus-4-5-20251101' + assert unified.generation_id == 'user-uuid-2' + + +def test_missing_transcript_does_not_break_parsing(mocker: MockerFixture) -> None: + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.ides.claude_code.Path') + mock_path.return_value.exists.return_value = False + + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + 'transcript_path': '/nonexistent/path/transcript.jsonl', + } + ) + + 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_absent_transcript_path() -> None: + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + ) + assert unified.ide_version is None + assert unified.model is None + assert unified.generation_id is None + + +# Email extraction from ~/.claude.json + + +def test_email_from_config(mocker: MockerFixture) -> None: + mocker.patch( + 'cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', + return_value={'oauthAccount': {'emailAddress': 'user@example.com'}}, + ) + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + } + ) + assert unified.ide_user_email == 'user@example.com' + + +def test_email_none_when_config_missing(mocker: MockerFixture) -> None: + mocker.patch( + 'cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', + return_value=None, + ) + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + } + ) + assert unified.ide_user_email is None + + +def test_email_none_when_no_oauth(mocker: MockerFixture) -> None: + mocker.patch( + 'cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', + return_value={'someOtherKey': 'value'}, + ) + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + } + ) + assert unified.ide_user_email is None + + +# Session context + + +def test_session_context_no_config() -> None: + with ( + patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', return_value=None), + patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_settings', return_value=None), + ): + servers, plugins = ClaudeCode().get_session_context() + assert servers == {} + assert plugins == {} + + +# Claude config parsing (load_claude_config + _email_from_config) + + +def test_load_claude_config_valid(fs: FakeFilesystem) -> None: + config = {'oauthAccount': {'emailAddress': 'user@example.com'}} + config_path = Path.home() / '.claude.json' + fs.create_file(config_path, contents=json.dumps(config)) + + assert load_claude_config(config_path) == config + + +def test_load_claude_config_missing_file(fs: FakeFilesystem) -> None: + fs.create_dir(Path.home()) + assert load_claude_config(Path.home() / '.claude.json') is None + + +def test_load_claude_config_corrupt_file(fs: FakeFilesystem) -> None: + config_path = Path.home() / '.claude.json' + fs.create_file(config_path, contents='not valid json {{{') + + assert load_claude_config(config_path) is None + + +def test_email_from_config_present() -> None: + assert _email_from_config({'oauthAccount': {'emailAddress': 'user@example.com'}}) == 'user@example.com' + + +def test_email_from_config_missing_oauth_account() -> None: + assert _email_from_config({'someOtherKey': 'value'}) is None + + +def test_email_from_config_missing_email_address() -> None: + assert _email_from_config({'oauthAccount': {'someOtherField': 'value'}}) is None diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py new file mode 100644 index 00000000..9714dbfa --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -0,0 +1,141 @@ +"""IDE contract tests, parameterized over the entire IDES registry. + +Every concrete IDE registered in `ides/__init__.py` must satisfy these +assertions. Adding a new IDE without updating these tests means the new +IDE inherits the same baseline guarantees (and fails fast if it doesn't). +""" + +from pathlib import Path + +import pytest + +from cycode.cli.apps.ai_guardrails.ides import IDES +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +def test_ides_registry_is_non_empty() -> None: + """Sanity check: the refactor isn't useful with zero registered IDEs.""" + assert len(IDES) >= 1 + + +@pytest.fixture(params=sorted(IDES), ids=sorted(IDES)) +def ide(request: pytest.FixtureRequest) -> IDE: + return IDES[request.param] + + +def test_identity_attributes_set(ide: IDE) -> None: + """Every IDE must declare name, display_name, hook_events.""" + assert isinstance(ide.name, str) + assert ide.name + assert isinstance(ide.display_name, str) + assert ide.display_name + assert isinstance(ide.hook_events, list) + assert ide.hook_events + + +def test_registry_key_matches_name(ide: IDE) -> None: + """The registry key must equal the IDE's own `name` attribute.""" + assert IDES[ide.name] is ide + + +def test_settings_path_user_scope(ide: IDE) -> None: + """User scope must return a Path (without requiring a repo_path).""" + path = ide.settings_path('user') + assert isinstance(path, Path) + + +def test_settings_path_repo_scope(ide: IDE, tmp_path: Path) -> None: + """Repo scope path must live under the supplied repo directory.""" + path = ide.settings_path('repo', tmp_path) + assert isinstance(path, Path) + assert str(path).startswith(str(tmp_path)) + + +def test_render_hooks_config_has_hooks_key(ide: IDE) -> None: + """All IDEs share the outer `{"hooks": ...}` wrapper so hooks_manager can merge.""" + rendered = ide.render_hooks_config() + assert isinstance(rendered, dict) + assert 'hooks' in rendered + assert isinstance(rendered['hooks'], dict) + + +def test_render_hooks_config_async_changes_output(ide: IDE) -> None: + """async_mode must influence the rendered output (e.g. & suffix, async flag).""" + assert ide.render_hooks_config(async_mode=False) != ide.render_hooks_config(async_mode=True) + + +def test_matches_payload_rejects_empty(ide: IDE) -> None: + """Empty payloads can't legitimately come from any IDE.""" + assert ide.matches_payload({}) is False + assert ide.matches_payload({'hook_event_name': ''}) is False + + +def test_matches_payload_rejects_unrelated_event_names(ide: IDE) -> None: + """Unknown event names from other IDEs must be ignored to avoid double-processing.""" + assert ide.matches_payload({'hook_event_name': 'completely-fabricated-event'}) is False + + +@pytest.mark.parametrize('event_type', list(AiHookEventType)) +def test_build_hook_response_allow_returns_dict(ide: IDE, event_type: AiHookEventType) -> None: + """ALLOW for every canonical event type yields a serializable dict.""" + response = ide.build_hook_response(HookDecision.allow(event_type)) + assert isinstance(response, dict) + + +@pytest.mark.parametrize('event_type', list(AiHookEventType)) +def test_build_hook_response_deny_carries_message(ide: IDE, event_type: AiHookEventType) -> None: + """DENY must surface the user message somewhere in the response (any key).""" + response = ide.build_hook_response(HookDecision.deny(event_type, 'A unique deny reason', 'agent msg')) + # Search recursively — IDEs use different key names for the message. + assert _contains_value(response, 'A unique deny reason'), response + + +@pytest.mark.parametrize('event_type', [AiHookEventType.FILE_READ, AiHookEventType.MCP_EXECUTION]) +def test_build_hook_response_ask_carries_message(ide: IDE, event_type: AiHookEventType) -> None: + """ASK is meaningful for permission events. Message must propagate.""" + response = ide.build_hook_response(HookDecision.ask(event_type, 'A unique ask reason')) + assert _contains_value(response, 'A unique ask reason'), response + + +def test_build_session_payload_tags_ide(ide: IDE) -> None: + """Session payload must identify the originating IDE.""" + session = ide.build_session_payload({}) + assert session.ide_provider == ide.name + + +def test_get_session_context_returns_pair(ide: IDE) -> None: + """Session context must always be a ``(mcp_servers, plugins)`` 2-tuple of dicts.""" + mcp_servers, plugins = ide.get_session_context() + assert isinstance(mcp_servers, dict) + assert isinstance(plugins, dict) + + +# HookDecision helpers + + +def test_hook_decision_helpers() -> None: + allow = HookDecision.allow(AiHookEventType.PROMPT) + assert allow.action == DecisionAction.ALLOW + assert allow.event_type == AiHookEventType.PROMPT + assert allow.user_message is None + + deny = HookDecision.deny(AiHookEventType.FILE_READ, 'why', 'agent') + assert deny.action == DecisionAction.DENY + assert deny.user_message == 'why' + assert deny.agent_message == 'agent' + + ask = HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'maybe?') + assert ask.action == DecisionAction.ASK + assert ask.user_message == 'maybe?' + + +def _contains_value(obj: object, needle: str) -> bool: + """Recursively search a nested dict/list for a string value.""" + if isinstance(obj, str): + return needle in obj + if isinstance(obj, dict): + return any(_contains_value(v, needle) for v in obj.values()) + if isinstance(obj, list): + return any(_contains_value(v, needle) for v in obj) + return False diff --git a/tests/cli/commands/ai_guardrails/ides/test_cursor.py b/tests/cli/commands/ai_guardrails/ides/test_cursor.py new file mode 100644 index 00000000..bb058f6f --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_cursor.py @@ -0,0 +1,154 @@ +"""Cursor IDE integration tests (payload parsing, response building, MCP context).""" + +import json +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +def test_matches_payload_only_cursor_events() -> None: + cursor = Cursor() + assert cursor.matches_payload({'hook_event_name': 'beforeSubmitPrompt'}) is True + assert cursor.matches_payload({'hook_event_name': 'beforeReadFile'}) is True + assert cursor.matches_payload({'hook_event_name': 'beforeMCPExecution'}) is True + assert cursor.matches_payload({'hook_event_name': 'UserPromptSubmit'}) is False + assert cursor.matches_payload({'hook_event_name': 'PreToolUse'}) is False + + +def test_parse_prompt_payload() -> None: + 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 = Cursor().parse_hook_payload(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_parse_file_read_payload() -> None: + unified = Cursor().parse_hook_payload({'hook_event_name': 'beforeReadFile', 'file_path': '/path/to/secret.env'}) + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/path/to/secret.env' + + +def test_parse_mcp_execution_payload() -> None: + args: dict[str, Any] = {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'} + unified = Cursor().parse_hook_payload( + { + 'hook_event_name': 'beforeMCPExecution', + 'command': 'GitLab', + 'tool_name': 'discussion_list', + 'arguments': args, + } + ) + + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'GitLab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == args + + +def test_parse_alternative_field_names() -> None: + """Cursor's payload has alternative names for some fields.""" + fr = Cursor().parse_hook_payload({'hook_event_name': 'beforeReadFile', 'path': '/alt/path.txt'}) + assert fr.file_path == '/alt/path.txt' + + mcp = Cursor().parse_hook_payload( + { + 'hook_event_name': 'beforeMCPExecution', + 'tool': 'my_tool', + 'tool_input': {'key': 'value'}, + } + ) + assert mcp.mcp_tool_name == 'my_tool' + assert mcp.mcp_arguments == {'key': 'value'} + + +def test_parse_unknown_event_name_falls_through() -> None: + """Unknown event names pass through as the raw string.""" + unified = Cursor().parse_hook_payload({'hook_event_name': 'unknownEvent'}) + assert unified.event_name == 'unknownEvent' + + +def test_parse_empty_payload_defaults() -> None: + unified = Cursor().parse_hook_payload({'hook_event_name': 'beforeSubmitPrompt'}) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id is None + assert unified.prompt == '' + assert unified.ide_provider == 'cursor' + + +def test_build_prompt_responses() -> None: + cursor = Cursor() + assert cursor.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)) == {'continue': True} + assert cursor.build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'no!')) == { + 'continue': False, + 'user_message': 'no!', + } + + +def test_build_permission_responses() -> None: + cursor = Cursor() + assert cursor.build_hook_response(HookDecision.allow(AiHookEventType.FILE_READ)) == {'permission': 'allow'} + assert cursor.build_hook_response(HookDecision.deny(AiHookEventType.FILE_READ, 'user!', 'agent!')) == { + 'permission': 'deny', + 'user_message': 'user!', + 'agent_message': 'agent!', + } + assert cursor.build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'u', 'a')) == { + 'permission': 'ask', + 'user_message': 'u', + 'agent_message': 'a', + } + + +def test_session_payload_carries_cursor_fields() -> None: + payload = { + 'conversation_id': 'conv-456', + 'user_email': 'cursor-user@example.com', + 'model': 'gpt-4', + 'cursor_version': '0.42.0', + } + session = Cursor().build_session_payload(payload) + assert session.conversation_id == 'conv-456' + assert session.model == 'gpt-4' + assert session.ide_user_email == 'cursor-user@example.com' + assert session.ide_version == '0.42.0' + assert session.ide_provider == 'cursor' + + +def test_session_context_loads_mcp_servers(tmp_path: Path) -> None: + """Cursor reads MCP servers from ~/.cursor/mcp.json.""" + mcp_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} + config_path = tmp_path / 'mcp.json' + config_path.write_text(json.dumps({'mcpServers': mcp_servers})) + + with patch('cycode.cli.apps.ai_guardrails.ides.cursor._load_cursor_mcp_config') as load: + load.return_value = {'mcpServers': mcp_servers} + servers, plugins = Cursor().get_session_context() + + assert servers == mcp_servers + assert plugins == {} + + +def test_session_context_no_config_returns_empty() -> None: + with patch('cycode.cli.apps.ai_guardrails.ides.cursor._load_cursor_mcp_config', return_value=None): + servers, plugins = Cursor().get_session_context() + assert servers == {} + assert plugins == {} diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 57c25b92..36352a38 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -6,13 +6,14 @@ import pytest import typer +from cycode.cli.apps.ai_guardrails.ides.base import DecisionAction, HookDecision 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.ai_guardrails.scan.payload import AIHookPayload -from cycode.cli.apps.ai_guardrails.scan.types import AIHookOutcome, BlockReason +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason @pytest.fixture @@ -30,7 +31,7 @@ def mock_ctx() -> MagicMock: def mock_payload() -> AIHookPayload: """Create a mock AIHookPayload.""" return AIHookPayload( - event_name='prompt', + event_name='Prompt', conversation_id='test-conv-id', generation_id='test-gen-id', ide_user_email='test@example.com', @@ -65,7 +66,7 @@ def test_handle_before_submit_prompt_disabled( result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) - assert result == {'continue': True} + assert result == HookDecision.allow(AiHookEventType.PROMPT) mock_ctx.obj['ai_security_client'].create_event.assert_called_once() mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called() @@ -79,11 +80,10 @@ def test_handle_before_submit_prompt_no_secrets( result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) - assert result == {'continue': True} + assert result == HookDecision.allow(AiHookEventType.PROMPT) mock_ctx.obj['ai_security_client'].create_event.assert_called_once() mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called() 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 @@ -98,8 +98,9 @@ def test_handle_before_submit_prompt_with_secrets_blocked( 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'] + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.PROMPT + 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 @@ -116,7 +117,7 @@ def test_handle_before_submit_prompt_with_secrets_warned( result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) - assert result == {'continue': True} + assert result == HookDecision.allow(AiHookEventType.PROMPT) 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 @@ -133,11 +134,9 @@ def test_handle_before_submit_prompt_scan_failure_fail_open( 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 - # block_reason is set for tracking even when fail_open allows the action assert call_args.kwargs['block_reason'] == BlockReason.SCAN_FAILURE @@ -152,7 +151,6 @@ def test_handle_before_submit_prompt_scan_failure_fail_closed( 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 @@ -166,14 +164,14 @@ def test_handle_before_read_file_disabled(mock_ctx: MagicMock, default_policy: d """Test that disabled file read scanning allows the file.""" default_policy['file_read']['enabled'] = False payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/file.txt', ) result = handle_before_read_file(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.FILE_READ) @patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') @@ -183,15 +181,16 @@ def test_handle_before_read_file_sensitive_path( """Test that sensitive path is blocked.""" mock_is_denied.return_value = True payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', 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'] + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.FILE_READ + 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 @@ -208,14 +207,14 @@ def test_handle_before_read_file_no_secrets( mock_is_denied.return_value = False mock_scan.return_value = (None, 'scan-id-123') payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/file.txt', ) result = handle_before_read_file(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.FILE_READ) call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.ALLOWED assert call_args.kwargs['file_path'] == '/path/to/file.txt' @@ -230,15 +229,16 @@ def test_handle_before_read_file_with_secrets( mock_is_denied.return_value = False mock_scan.return_value = ('Found 1 secret: password', 'scan-id-456') payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', 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'] + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.FILE_READ + 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 @@ -254,14 +254,14 @@ def test_handle_before_read_file_scan_disabled( mock_is_denied.return_value = False default_policy['file_read']['scan_content'] = False payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/file.txt', ) result = handle_before_read_file(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.FILE_READ) mock_scan.assert_not_called() @@ -275,20 +275,18 @@ def test_handle_before_read_file_sensitive_path_warn_mode_scans_content( mock_scan.return_value = (None, 'scan-id-123') default_policy['mode'] = 'warn' payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/.env', ) result = handle_before_read_file(mock_ctx, payload, default_policy) - # Content was scanned even though path is sensitive mock_scan.assert_called_once() - # Still warns about sensitive path since no secrets found - assert result['permission'] == 'ask' - assert '.env' in result['user_message'] + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.FILE_READ + assert '.env' in result.user_message - # Two events: sensitive path warn + content scan result (allowed, no secrets found) assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2 first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0] assert first_event.args[2] == AIHookOutcome.WARNED @@ -308,7 +306,7 @@ def test_handle_before_read_file_sensitive_path_warn_mode_with_secrets( mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-456') default_policy['mode'] = 'warn' payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/.env', ) @@ -316,10 +314,10 @@ def test_handle_before_read_file_sensitive_path_warn_mode_with_secrets( result = handle_before_read_file(mock_ctx, payload, default_policy) mock_scan.assert_called_once() - assert result['permission'] == 'ask' - assert 'Found 1 secret: API key' in result['user_message'] + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.FILE_READ + assert 'Found 1 secret: API key' in result.user_message - # Two events: sensitive path warn + secrets warn assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2 first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0] assert first_event.args[2] == AIHookOutcome.WARNED @@ -339,7 +337,7 @@ def test_handle_before_read_file_sensitive_path_scan_disabled_warns( default_policy['mode'] = 'warn' default_policy['file_read']['scan_content'] = False payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/.env', ) @@ -347,10 +345,10 @@ def test_handle_before_read_file_sensitive_path_scan_disabled_warns( result = handle_before_read_file(mock_ctx, payload, default_policy) mock_scan.assert_not_called() - assert result['permission'] == 'ask' - assert '.env' in result['user_message'] + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.FILE_READ + assert '.env' in result.user_message - # Single event: sensitive path warn (no separate scan event when scan is disabled) 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 @@ -375,7 +373,7 @@ def test_handle_before_mcp_execution_disabled(mock_ctx: MagicMock, default_polic """Test that disabled MCP scanning allows the execution.""" default_policy['mcp']['enabled'] = False payload = AIHookPayload( - event_name='mcp_execution', + event_name='McpExecution', ide_provider='cursor', mcp_tool_name='test_tool', mcp_arguments={'arg1': 'value1'}, @@ -383,7 +381,7 @@ def test_handle_before_mcp_execution_disabled(mock_ctx: MagicMock, default_polic result = handle_before_mcp_execution(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.MCP_EXECUTION) @patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') @@ -393,7 +391,7 @@ def test_handle_before_mcp_execution_no_secrets( """Test that MCP execution with no secrets is allowed.""" mock_scan.return_value = (None, 'scan-id-123') payload = AIHookPayload( - event_name='mcp_execution', + event_name='McpExecution', ide_provider='cursor', mcp_tool_name='test_tool', mcp_arguments={'arg1': 'value1'}, @@ -401,7 +399,7 @@ def test_handle_before_mcp_execution_no_secrets( result = handle_before_mcp_execution(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.MCP_EXECUTION) call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.ALLOWED @@ -413,7 +411,7 @@ def test_handle_before_mcp_execution_with_secrets_blocked( """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', + event_name='McpExecution', ide_provider='cursor', mcp_tool_name='test_tool', mcp_arguments={'arg1': 'secret_token_12345'}, @@ -421,8 +419,9 @@ def test_handle_before_mcp_execution_with_secrets_blocked( result = handle_before_mcp_execution(mock_ctx, payload, default_policy) - assert result['permission'] == 'deny' - assert 'Found 1 secret: token' in result['user_message'] + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.MCP_EXECUTION + 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 @@ -436,7 +435,7 @@ def test_handle_before_mcp_execution_with_secrets_warned( mock_scan.return_value = ('Found 1 secret: token', 'scan-id-789') default_policy['mcp']['action'] = 'warn' payload = AIHookPayload( - event_name='mcp_execution', + event_name='McpExecution', ide_provider='cursor', mcp_tool_name='test_tool', mcp_arguments={'arg1': 'secret_token_12345'}, @@ -444,8 +443,9 @@ def test_handle_before_mcp_execution_with_secrets_warned( result = handle_before_mcp_execution(mock_ctx, payload, default_policy) - assert result['permission'] == 'ask' - assert 'Found 1 secret: token' in result['user_message'] + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.MCP_EXECUTION + 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 @@ -457,7 +457,7 @@ def test_handle_before_mcp_execution_scan_disabled( """Test that MCP execution is allowed when argument scanning is disabled.""" default_policy['mcp']['scan_arguments'] = False payload = AIHookPayload( - event_name='mcp_execution', + event_name='McpExecution', ide_provider='cursor', mcp_tool_name='test_tool', mcp_arguments={'arg1': 'value1'}, @@ -465,5 +465,5 @@ def test_handle_before_mcp_execution_scan_disabled( result = handle_before_mcp_execution(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.MCP_EXECUTION) mock_scan.assert_not_called() diff --git a/tests/cli/commands/ai_guardrails/scan/test_payload.py b/tests/cli/commands/ai_guardrails/scan/test_payload.py deleted file mode 100644 index 1ef5fad0..00000000 --- a/tests/cli/commands/ai_guardrails/scan/test_payload.py +++ /dev/null @@ -1,432 +0,0 @@ -"""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 - - -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' - assert type(unified.ide_provider) is str - - -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', - '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_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: - """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' - - -# 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' - assert type(unified.ide_provider) is str - - -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' - - -# Claude Code email extraction tests - - -def test_from_claude_code_payload_extracts_email_from_config(mocker: MockerFixture) -> None: - """Test that ide_user_email is populated from ~/.claude.json.""" - mocker.patch( - 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', - return_value={'oauthAccount': {'emailAddress': 'user@example.com'}}, - ) - - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - 'session_id': 'session-123', - 'prompt': 'test', - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - assert unified.ide_user_email == 'user@example.com' - - -def test_from_claude_code_payload_email_none_when_config_missing(mocker: MockerFixture) -> None: - """Test that ide_user_email is None when ~/.claude.json is missing.""" - mocker.patch( - 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', - return_value=None, - ) - - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - 'session_id': 'session-123', - 'prompt': 'test', - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - assert unified.ide_user_email is None - - -def test_from_claude_code_payload_email_none_when_no_oauth(mocker: MockerFixture) -> None: - """Test that ide_user_email is None when oauthAccount is missing from config.""" - mocker.patch( - 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', - return_value={'someOtherKey': 'value'}, - ) - - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - 'session_id': 'session-123', - 'prompt': 'test', - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - assert unified.ide_user_email is None - - -# 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 deleted file mode 100644 index 45f80829..00000000 --- a/tests/cli/commands/ai_guardrails/scan/test_response_builders.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Tests for IDE response builders.""" - -import pytest - -from cycode.cli.apps.ai_guardrails.scan.response_builders import ( - ClaudeCodeResponseBuilder, - 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 - - -# 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 index 4bcb35f2..35f7e4fa 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -9,7 +9,9 @@ from typer.testing import CliRunner from cycode.cli.apps.ai_guardrails import app as ai_guardrails_app +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @pytest.fixture @@ -129,7 +131,7 @@ def test_claude_code_payload_with_claude_code_ide( 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_handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) mock_scan_command_deps['get_handler'].return_value = mock_handler scan_command(mock_ctx, ide='claude-code') @@ -146,15 +148,15 @@ class TestDefaultIdeParameterViaCli: def test_scan_command_default_ide_via_cli(self, mocker: MockerFixture) -> None: """Test scan_command works with default --ide when invoked via CLI. - This test catches issues where Typer converts enum defaults to strings - incorrectly (e.g., AIIDEType.CURSOR becomes 'AIIDEType.CURSOR' instead of 'cursor'). + Catches regressions where the default value would no longer match a + registered IDE name (e.g. after renaming `DEFAULT_IDE_NAME`). """ mocker.patch('cycode.cli.apps.ai_guardrails.scan.scan_command._initialize_clients') mocker.patch( 'cycode.cli.apps.ai_guardrails.scan.scan_command.load_policy', return_value={'fail_open': True}, ) - mock_handler = MagicMock(return_value={'continue': True}) + mock_handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) mocker.patch( 'cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event', return_value=mock_handler, diff --git a/tests/cli/commands/ai_guardrails/test_claude_config.py b/tests/cli/commands/ai_guardrails/test_claude_config.py deleted file mode 100644 index 6bbdbcab..00000000 --- a/tests/cli/commands/ai_guardrails/test_claude_config.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Tests for Claude Code config file reader.""" - -import json -from pathlib import Path - -from pyfakefs.fake_filesystem import FakeFilesystem - -from cycode.cli.apps.ai_guardrails.scan.claude_config import get_user_email, load_claude_config - - -def test_load_claude_config_valid(fs: FakeFilesystem) -> None: - """Test loading a valid ~/.claude.json file.""" - config = {'oauthAccount': {'emailAddress': 'user@example.com'}} - config_path = Path.home() / '.claude.json' - fs.create_file(config_path, contents=json.dumps(config)) - - result = load_claude_config(config_path) - assert result == config - - -def test_load_claude_config_missing_file(fs: FakeFilesystem) -> None: - """Test loading when ~/.claude.json does not exist.""" - fs.create_dir(Path.home()) - config_path = Path.home() / '.claude.json' - - result = load_claude_config(config_path) - assert result is None - - -def test_load_claude_config_corrupt_file(fs: FakeFilesystem) -> None: - """Test loading when ~/.claude.json contains invalid JSON.""" - config_path = Path.home() / '.claude.json' - fs.create_file(config_path, contents='not valid json {{{') - - result = load_claude_config(config_path) - assert result is None - - -def test_get_user_email_present() -> None: - """Test extracting email when oauthAccount.emailAddress exists.""" - config = {'oauthAccount': {'emailAddress': 'user@example.com'}} - assert get_user_email(config) == 'user@example.com' - - -def test_get_user_email_missing_oauth_account() -> None: - """Test extracting email when oauthAccount key is missing.""" - config = {'someOtherKey': 'value'} - assert get_user_email(config) is None - - -def test_get_user_email_missing_email_address() -> None: - """Test extracting email when oauthAccount exists but emailAddress is missing.""" - config = {'oauthAccount': {'someOtherField': 'value'}} - assert get_user_email(config) is None diff --git a/tests/cli/commands/ai_guardrails/test_command_utils.py b/tests/cli/commands/ai_guardrails/test_command_utils.py deleted file mode 100644 index 5d8d224b..00000000 --- a/tests/cli/commands/ai_guardrails/test_command_utils.py +++ /dev/null @@ -1,60 +0,0 @@ -"""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 - 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: - """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/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index a5732bca..4ff8d575 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -1,4 +1,4 @@ -"""Tests for AI guardrails hooks manager.""" +"""Tests for AI guardrails hooks manager and per-IDE hooks rendering.""" from pathlib import Path @@ -8,27 +8,22 @@ from cycode.cli.apps.ai_guardrails.consts import ( CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND, - AIIDEType, PolicyMode, - get_hooks_config, ) from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, is_cycode_hook_entry +from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode +from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor 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 + """Detect Cycode hook in Cursor's flat command format.""" + assert is_cycode_hook_entry({'command': 'cycode ai-guardrails scan'}) is True + assert is_cycode_hook_entry({'command': 'cycode ai-guardrails scan --some-flag'}) 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'}], - } + """Detect Cycode hook in Claude Code's nested format.""" + entry = {'hooks': [{'type': 'command', 'command': 'cycode ai-guardrails scan --ide claude-code'}]} assert is_cycode_hook_entry(entry) is True entry = { @@ -39,57 +34,44 @@ def test_is_cycode_hook_entry_claude_code_format() -> None: 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 + """Non-Cycode hooks must not be detected.""" + assert is_cycode_hook_entry({'command': 'some-other-command'}) is False + assert is_cycode_hook_entry({'hooks': [{'type': 'command', 'command': 'some-other-command'}]}) is False + assert is_cycode_hook_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 + """Detection is substring-based: full paths and trailing flags still count.""" + assert is_cycode_hook_entry({'command': '/usr/local/bin/cycode ai-guardrails scan'}) is True + assert is_cycode_hook_entry({'command': 'cycode ai-guardrails scan --verbose'}) is True - entry = {'command': 'cycode ai-guardrails scan --verbose'} - assert is_cycode_hook_entry(entry) is True + +# Per-IDE hook config tests (now exposed via IDE.render_hooks_config) -def test_get_hooks_config_cursor_sync() -> None: - """Test Cursor hooks config in default (sync) mode.""" - config = get_hooks_config(AIIDEType.CURSOR) - hooks = config['hooks'] - scan_hooks = {k: v for k, v in hooks.items() if k != 'sessionStart'} +def test_cursor_render_hooks_sync() -> None: + """Cursor sync hooks: no '&' in scan commands.""" + config = Cursor().render_hooks_config() + scan_hooks = {k: v for k, v in config['hooks'].items() if k != 'sessionStart'} for entries in scan_hooks.values(): for entry in entries: assert entry['command'] == CYCODE_SCAN_PROMPT_COMMAND assert '&' not in entry['command'] -def test_get_hooks_config_cursor_async() -> None: - """Test Cursor hooks config in async mode appends & to command.""" - config = get_hooks_config(AIIDEType.CURSOR, async_mode=True) - hooks = config['hooks'] - scan_hooks = {k: v for k, v in hooks.items() if k != 'sessionStart'} +def test_cursor_render_hooks_async() -> None: + """Cursor async hooks: '&' suffix on scan commands.""" + config = Cursor().render_hooks_config(async_mode=True) + scan_hooks = {k: v for k, v in config['hooks'].items() if k != 'sessionStart'} for entries in scan_hooks.values(): for entry in entries: assert entry['command'].endswith('&') assert CYCODE_SCAN_PROMPT_COMMAND in entry['command'] -def test_get_hooks_config_cursor_session_start() -> None: - """Test Cursor hooks config includes sessionStart with --ide flag.""" - config = get_hooks_config(AIIDEType.CURSOR) +def test_cursor_render_hooks_session_start() -> None: + """Cursor session_start carries the --ide flag explicitly.""" + config = Cursor().render_hooks_config() assert 'sessionStart' in config['hooks'] entries = config['hooks']['sessionStart'] assert len(entries) == 1 @@ -97,9 +79,9 @@ def test_get_hooks_config_cursor_session_start() -> None: assert '--ide cursor' in entries[0]['command'] -def test_get_hooks_config_claude_code_sync() -> None: - """Test Claude Code hooks config in default (sync) mode.""" - config = get_hooks_config(AIIDEType.CLAUDE_CODE) +def test_claude_code_render_hooks_sync() -> None: + """Claude Code sync hooks: no async/timeout fields.""" + config = ClaudeCode().render_hooks_config() scan_events = {k: v for k, v in config['hooks'].items() if k != 'SessionStart'} for event_entries in scan_events.values(): for event_entry in event_entries: @@ -108,9 +90,9 @@ def test_get_hooks_config_claude_code_sync() -> None: assert 'timeout' not in hook -def test_get_hooks_config_claude_code_async() -> None: - """Test Claude Code hooks config in async mode adds async and timeout.""" - config = get_hooks_config(AIIDEType.CLAUDE_CODE, async_mode=True) +def test_claude_code_render_hooks_async() -> None: + """Claude Code async hooks: 'async' flag + timeout.""" + config = ClaudeCode().render_hooks_config(async_mode=True) scan_events = {k: v for k, v in config['hooks'].items() if k != 'SessionStart'} for event_entries in scan_events.values(): for event_entry in event_entries: @@ -118,9 +100,9 @@ def test_get_hooks_config_claude_code_async() -> None: assert hook['async'] is True -def test_get_hooks_config_claude_code_session_start() -> None: - """Test Claude Code hooks config includes SessionStart with --ide flag.""" - config = get_hooks_config(AIIDEType.CLAUDE_CODE) +def test_claude_code_render_hooks_session_start() -> None: + """Claude Code SessionStart carries the --ide flag explicitly.""" + config = ClaudeCode().render_hooks_config() assert 'SessionStart' in config['hooks'] entries = config['hooks']['SessionStart'] assert len(entries) == 1 @@ -128,8 +110,11 @@ def test_get_hooks_config_claude_code_session_start() -> None: assert '--ide claude-code' in entries[0]['hooks'][0]['command'] +# Policy file tests + + def test_create_policy_file_warn(fs: FakeFilesystem) -> None: - """Test creating warn-mode policy file.""" + """Create a warn-mode policy file.""" fs.create_dir(Path.home()) success, message = create_policy_file('user', PolicyMode.WARN) @@ -138,13 +123,11 @@ def test_create_policy_file_warn(fs: FakeFilesystem) -> None: policy_path = Path.home() / '.cycode' / 'ai-guardrails.yaml' assert policy_path.exists() - - policy = yaml.safe_load(policy_path.read_text()) - assert policy['mode'] == 'warn' + assert yaml.safe_load(policy_path.read_text())['mode'] == 'warn' def test_create_policy_file_block(fs: FakeFilesystem) -> None: - """Test creating block-mode policy file.""" + """Create a block-mode policy file.""" fs.create_dir(Path.home()) success, message = create_policy_file('user', PolicyMode.BLOCK) @@ -152,12 +135,11 @@ def test_create_policy_file_block(fs: FakeFilesystem) -> None: assert 'block mode' in message policy_path = Path.home() / '.cycode' / 'ai-guardrails.yaml' - policy = yaml.safe_load(policy_path.read_text()) - assert policy['mode'] == 'block' + assert yaml.safe_load(policy_path.read_text())['mode'] == 'block' def test_create_policy_file_updates_existing(fs: FakeFilesystem) -> None: - """Test that re-running only updates mode and preserves other customizations.""" + """Re-running updates only the mode field and preserves customizations.""" policy_dir = Path.home() / '.cycode' fs.create_dir(policy_dir) policy_path = policy_dir / 'ai-guardrails.yaml' @@ -172,15 +154,13 @@ def test_create_policy_file_updates_existing(fs: FakeFilesystem) -> None: def test_create_policy_file_repo_scope(fs: FakeFilesystem) -> None: - """Test creating policy file in repo scope.""" + """Create a policy file in repo scope.""" repo_path = Path('/my-repo') fs.create_dir(repo_path) - success, message = create_policy_file('repo', PolicyMode.WARN, repo_path=repo_path) + success, _ = create_policy_file('repo', PolicyMode.WARN, repo_path=repo_path) assert success is True policy_path = repo_path / '.cycode' / 'ai-guardrails.yaml' assert policy_path.exists() - - policy = yaml.safe_load(policy_path.read_text()) - assert policy['mode'] == 'warn' + assert yaml.safe_load(policy_path.read_text())['mode'] == 'warn' diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py index 82a13043..0ae57226 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -9,6 +9,8 @@ import typer from cycode.cli.apps.ai_guardrails import session_start_command as _session_start_mod +from cycode.cli.apps.ai_guardrails.ides import claude_code as _claude_mod +from cycode.cli.apps.ai_guardrails.ides import cursor as _cursor_mod from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command @@ -112,8 +114,8 @@ def test_invalid_json_stdin_skips_session_init( # Conversation creation tests -@patch.object(_session_start_mod, 'extract_from_claude_transcript') -@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_claude_mod, 'extract_from_claude_transcript') +@patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_claude_code_creates_conversation( @@ -176,7 +178,7 @@ def test_cursor_creates_conversation( assert call_payload.ide_provider == 'cursor' -@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_conversation_creation_failure_non_blocking( @@ -203,8 +205,8 @@ def test_conversation_creation_failure_non_blocking( # MCP server reporting tests -@patch.object(_session_start_mod, 'load_claude_settings') -@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_claude_mod, 'load_claude_settings') +@patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_claude_code_reports_mcp_servers( @@ -238,8 +240,8 @@ def test_claude_code_reports_mcp_servers( ) -@patch.object(_session_start_mod, 'load_claude_settings') -@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_claude_mod, 'load_claude_settings') +@patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_claude_code_merges_plugin_mcp_servers_and_metadata( @@ -298,8 +300,8 @@ def test_claude_code_merges_plugin_mcp_servers_and_metadata( ) -@patch.object(_session_start_mod, 'load_claude_settings') -@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_claude_mod, 'load_claude_settings') +@patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_claude_code_no_mcp_servers_no_plugins_skips_report( @@ -324,7 +326,7 @@ def test_claude_code_no_mcp_servers_no_plugins_skips_report( mock_ai_client.report_session_context.assert_not_called() -@patch.object(_session_start_mod, 'load_cursor_config') +@patch.object(_cursor_mod, '_load_cursor_mcp_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_cursor_reports_mcp_servers( @@ -350,7 +352,7 @@ def test_cursor_reports_mcp_servers( ) -@patch.object(_session_start_mod, 'load_cursor_config') +@patch.object(_cursor_mod, '_load_cursor_mcp_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_cursor_no_mcp_servers_skips_report(