diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index 192bb9f3..b7e55b86 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -22,15 +22,22 @@ _CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',) +# Command-carrying fields of a flat hook entry. Copilot entries use per-OS +# `bash`/`powershell` fields instead of `command`. +_COMMAND_FIELDS = ('command', 'bash', 'powershell') + def _is_cycode_command(command: str) -> bool: return any(marker in command for marker in _CYCODE_COMMAND_MARKERS) +def _has_cycode_command_field(entry: dict) -> bool: + return any(_is_cycode_command(entry.get(field, '')) for field in _COMMAND_FIELDS) + + def is_cycode_hook_entry(entry: dict) -> bool: """True if any hook inside ``entry`` is owned by Cycode.""" - command = entry.get('command', '') - if _is_cycode_command(command): + if _has_cycode_command_field(entry): return True for hook in entry.get('hooks', []): @@ -47,9 +54,9 @@ def _strip_cycode_from_entry(entry: dict) -> Optional[dict]: every nested hook was Cycode). Non-Cycode hooks co-located in the same entry are preserved. """ - # Cursor format: the entry itself IS a single hook command. - if 'command' in entry and 'hooks' not in entry: - return None if _is_cycode_command(entry.get('command', '')) else entry + # Cursor/Copilot format: the entry itself IS a single hook command. + if 'hooks' not in entry and any(field in entry for field in _COMMAND_FIELDS): + return None if _has_cycode_command_field(entry) else entry # Claude Code / Codex format: nested `hooks` list inside the entry. nested = entry.get('hooks') diff --git a/cycode/cli/apps/ai_guardrails/ides/__init__.py b/cycode/cli/apps/ai_guardrails/ides/__init__.py index 127431ef..396074da 100644 --- a/cycode/cli/apps/ai_guardrails/ides/__init__.py +++ b/cycode/cli/apps/ai_guardrails/ides/__init__.py @@ -10,11 +10,12 @@ 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.codex import Codex +from cycode.cli.apps.ai_guardrails.ides.copilot import Copilot 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(), Codex())} +IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode(), Codex(), Copilot())} # 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. diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py index 28971db9..84e4315f 100644 --- a/cycode/cli/apps/ai_guardrails/ides/base.py +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -14,6 +14,7 @@ JSON response shape that the IDE expects on stdout. """ +import platform from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum @@ -24,6 +25,24 @@ from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +def shell_background_suffix(async_mode: bool) -> str: + """`' &'` when backgrounding is requested and the platform's shell supports it. + + Only valid for hooks whose runner is stdin-safe under backgrounding (zsh keeps + a backgrounded command's stdin; verified for Cursor/Codex). bash/sh reattach it + to /dev/null, silently emptying the payload — hooks that run under bash (e.g. + Copilot's `bash` field) must add an explicit `<&0` redirect instead. + + Windows gets no suffix: depending on the IDE, hooks may run under cmd (where a + trailing `&` is a no-op separator) or Windows PowerShell (where it's a parse + error that would fail the hook). Until the CLI can self-detach in report mode, + Windows hooks run synchronously. + """ + if not async_mode or platform.system() == 'Windows': + return '' + return ' &' + + class DecisionAction(str, Enum): """Canonical decision action returned by event handlers.""" diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 17e7563d..f48794ef 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -278,7 +278,11 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: } def matches_payload(self, raw_payload: dict) -> bool: - return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES + # transcript_path is a documented Claude Code common field, present on every + # hook event. VS Code Copilot emits near-identical payloads (same event names, + # snake_case fields) without it — requiring it keeps those from being + # processed as Claude Code events. + return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES and 'transcript_path' in raw_payload def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: hook_event_name = raw_payload.get('hook_event_name', '') diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py index e8049621..c9e48393 100644 --- a/cycode/cli/apps/ai_guardrails/ides/codex.py +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -20,7 +20,7 @@ resolve_cached_plugin_dir, walk_enabled_plugins, ) -from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision, shell_background_suffix from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType from cycode.cli.utils.jwt_utils import decode_jwt_unverified @@ -191,10 +191,9 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: def render_hooks_config(self, async_mode: bool = False) -> dict: # Codex's TOML `async: true` flag is unimplemented; shell-background via - # `&` is the working mechanism. SessionStart stays sync so the - # conversation context is registered before any scan hook fires. - bg = ' &' if async_mode else '' - scan_cmd = f'{_SCAN_COMMAND}{bg}' + # `&` is the working mechanism (unix only). SessionStart stays sync so + # the conversation context is registered before any scan hook fires. + scan_cmd = f'{_SCAN_COMMAND}{shell_background_suffix(async_mode)}' return { 'hooks': { 'SessionStart': [ diff --git a/cycode/cli/apps/ai_guardrails/ides/copilot.py b/cycode/cli/apps/ai_guardrails/ides/copilot.py new file mode 100644 index 00000000..12ef8f89 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -0,0 +1,436 @@ +"""GitHub Copilot (VS Code extension) integration for AI guardrails. + +Hooks are installed in Copilot's native format to ``~/.copilot/hooks/cycode.json`` +(user scope) or ``/.github/hooks/cycode.json`` (repo scope). Both locations +are also read by Copilot CLI and the Copilot cloud coding agent, but only the +VS Code payload dialect is parsed here — CLI payloads (camelCase, no event name) +are rejected by ``matches_payload`` and fall through to the allow-and-skip path. + +VS Code sends Claude-style payloads (``hook_event_name``, ``tool_name``, +``tool_input``) with structural differences that ``matches_payload`` keys on: +a top-level ISO ``timestamp`` and no ``transcript_path``. Copilot hooks have no +matchers, so ``preToolUse`` fires for every tool; tools we don't scan pass +through as raw event names, which match no handler and allow immediately. +""" + +import json +import os +import platform +import re +from collections.abc import Iterable +from pathlib import Path +from typing import ClassVar, Optional, Union +from urllib.parse import urlparse +from urllib.request import url2pathname + +from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( + build_global_config_file, + load_plugin_json, + walk_enabled_plugins, +) +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 Copilot') + +# Payload dialect (VS Code sends Claude-style PascalCase event names). +_COPILOT_SCAN_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'}) +_READ_FILE_TOOL = 'read_file' +# VS Code names MCP tools `mcp__` (single underscores). +_MCP_TOOL_PREFIX = 'mcp_' + +# Hooks-file dialect (Copilot-native camelCase event names). +_HOOK_EVENTS = ['userPromptSubmitted', 'preToolUse'] + +_COPILOT_HOME_ENV_VAR = 'COPILOT_HOME' +_HOOKS_FILE_NAME = 'cycode.json' +_REPO_HOOKS_SUBDIR = Path('.github') / 'hooks' +_HOOK_TIMEOUT_SEC = 20 +_MCP_CONFIG_FILENAME = 'mcp.json' + +# Plugin sources. CLI installs register in ~/.copilot/config.json and auto-surface +# in VS Code; VS Code UI installs register in ~/.vscode/agent-plugins/installed.json; +# local-directory plugins are declared via the chat.pluginLocations setting. +_VSCODE_PLUGINS_REGISTRY_NAME = 'installed.json' +_PLUGIN_LOCATIONS_SETTING = 'chat.pluginLocations' +_LOCAL_PLUGINS_MARKETPLACE = 'local' + +# Manifest locations in VS Code's documented detection order. Plugins may ship +# several manifest dialects at once — first hit wins, matching VS Code's probing. +_PLUGIN_MANIFEST_LOCATIONS = ( + Path('.plugin') / 'plugin.json', + Path('plugin.json'), + Path('.github') / 'plugin' / 'plugin.json', + Path('.claude-plugin') / 'plugin.json', +) + +# --event is ignored by the VS Code payload parsing (the payload self-describes) +# but Copilot CLI payloads carry no event name at all — baking the flag in now +# means CLI support won't require customers to re-install hooks. Values use the +# payload-dialect spelling so a future CLI path can inject them straight into +# hook_event_name and reuse the existing parsing. +_SCAN_PROMPT_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide copilot --event UserPromptSubmit' +_SCAN_TOOL_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide copilot --event PreToolUse' +_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide copilot' + + +def _copilot_home() -> Path: + """Resolve Copilot's user-scope home directory (honors ``$COPILOT_HOME``).""" + override = os.environ.get(_COPILOT_HOME_ENV_VAR) + if override: + return Path(override) + return Path.home() / '.copilot' + + +def _vscode_agent_plugins_dir() -> Path: + # Resolved at call time (not a module-level Path constant): on py<=3.10 a Path + # instance binds its filesystem accessor at creation, which breaks fake-fs tests + # and ignores home changes. + return Path.home() / '.vscode' / 'agent-plugins' + + +def _vscode_user_dir() -> Path: + """Per-platform VS Code user settings directory.""" + if platform.system() == 'Darwin': + return Path.home() / 'Library' / 'Application Support' / 'Code' / 'User' + if platform.system() == 'Windows': + return Path.home() / 'AppData' / 'Roaming' / 'Code' / 'User' + return Path.home() / '.config' / 'Code' / 'User' + + +def _vscode_mcp_config_path() -> Path: + return _vscode_user_dir() / _MCP_CONFIG_FILENAME + + +def _load_vscode_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse VS Code's user-level ``mcp.json``. Returns None if missing/invalid.""" + path = config_path or _vscode_mcp_config_path() + if not path.exists(): + logger.debug('VS Code MCP config file not found, %s', {'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load VS Code MCP config file', exc_info=e) + return None + + +def _load_jsonc(path: Path) -> Optional[dict]: + """Parse a JSON file tolerating //-comment lines (Copilot's config.json ships + with a comment header; VS Code's settings.json is JSONC). + + Best-effort: JSONC constructs beyond full-line comments (trailing commas, + inline comments) read as a missing file. + """ + if not path.exists(): + logger.debug('Config file not found, %s', {'path': str(path)}) + return None + try: + text = path.read_text(encoding='utf-8') + stripped = '\n'.join(line for line in text.splitlines() if not line.lstrip().startswith('//')) + return json.loads(stripped) + except Exception as e: + logger.debug('Failed to load config file, %s', {'path': str(path)}, exc_info=e) + return None + + +# --- plugins inventory ---------------------------------------------------------- + + +def _read_copilot_plugin(plugin_dir: Path) -> tuple[dict, dict]: + """Read one Copilot plugin's manifest + MCP servers. + + The manifest's ``mcpServers`` field, when present, is a path string to the MCP + file; otherwise the root ``.mcp.json`` convention applies (same as Claude + plugins). Both forms exist in marketplace plugins. + """ + manifest: dict = {} + for location in _PLUGIN_MANIFEST_LOCATIONS: + manifest = load_plugin_json(plugin_dir / location) or {} + if manifest: + break + + entry: dict = {} + for field in ('name', 'version', 'description'): + if field in manifest: + entry[field] = manifest[field] + + mcp_ref = manifest.get('mcpServers') + mcp_config_path = plugin_dir / mcp_ref if isinstance(mcp_ref, str) else plugin_dir / '.mcp.json' + mcp_doc = load_plugin_json(mcp_config_path) or {} + servers = mcp_doc.get('mcpServers') + if not isinstance(servers, dict): + servers = {} + if servers: + entry['mcp_server_names'] = list(servers.keys()) + entry['mcp_config_file_path'] = str(mcp_config_path) + entry['mcp_config_file'] = json.dumps({'mcpServers': servers}) + return entry, servers + + +def _walk_registry_plugins(entries: dict[str, dict], dirs: dict[str, Path], is_enabled: bool = True) -> dict: + """Walk plugins whose directories are known up front (registry-provided).""" + return walk_enabled_plugins( + plugin_entries=entries, + is_enabled=lambda p: p.get('enabled', True) if is_enabled else True, + locate_dir=lambda name, marketplace: dirs.get(f'{name}@{marketplace}'), + read_plugin=_read_copilot_plugin, + ) + + +def _cli_registry_plugins() -> dict: + """Plugins installed via Copilot CLI: ``~/.copilot/config.json`` → ``installedPlugins``.""" + config = _load_jsonc(_copilot_home() / 'config.json') or {} + entries: dict[str, dict] = {} + dirs: dict[str, Path] = {} + for plugin in config.get('installedPlugins') or []: + if not isinstance(plugin, dict) or not plugin.get('name'): + continue + key = f'{plugin["name"]}@{plugin.get("marketplace", "")}' + entries[key] = plugin + if plugin.get('cache_path'): + dirs[key] = Path(plugin['cache_path']) + return _walk_registry_plugins(entries, dirs) + + +def _vscode_registry_plugins() -> dict: + """Plugins installed via the VS Code UI (@agentPlugins): ``~/.vscode/agent-plugins/installed.json``. + + Registry-driven only — the directory also holds marketplace clones that are + not installed. ``pluginUri`` is the authoritative location (the registry's + ``marketplace`` label is unreliable); presence in the registry means enabled. + """ + registry = load_plugin_json(_vscode_agent_plugins_dir() / _VSCODE_PLUGINS_REGISTRY_NAME) or {} + entries: dict[str, dict] = {} + dirs: dict[str, Path] = {} + for plugin in registry.get('installed') or []: + if not isinstance(plugin, dict) or not plugin.get('name'): + continue + key = f'{plugin["name"]}@{plugin.get("marketplace", "")}' + entries[key] = plugin + uri = plugin.get('pluginUri', '') + if uri.startswith('file://'): + # url2pathname unquotes and handles Windows drive-letter URIs (file:///C:/...). + dirs[key] = Path(url2pathname(urlparse(uri).path)) + return _walk_registry_plugins(entries, dirs, is_enabled=False) + + +def _local_dir_plugins() -> dict: + """Local-directory plugins declared via the ``chat.pluginLocations`` setting.""" + settings = _load_jsonc(_vscode_user_dir() / 'settings.json') or {} + locations = settings.get(_PLUGIN_LOCATIONS_SETTING) + if not isinstance(locations, dict): + return {} + entries: dict[str, bool] = {} + dirs: dict[str, Path] = {} + for raw_path, enabled in locations.items(): + path = Path(raw_path).expanduser() + key = f'{path.name}@{_LOCAL_PLUGINS_MARKETPLACE}' + entries[key] = bool(enabled) + dirs[key] = path + return walk_enabled_plugins( + plugin_entries=entries, + is_enabled=bool, + locate_dir=lambda name, marketplace: dirs.get(f'{name}@{marketplace}'), + read_plugin=_read_copilot_plugin, + ) + + +def _collect_installed_plugins() -> dict: + """Merge the three plugin sources (first source wins on a duplicate key).""" + plugins: dict = {} + for source in (_cli_registry_plugins, _vscode_registry_plugins, _local_dir_plugins): + for key, entry in source().items(): + plugins.setdefault(key, entry) + return plugins + + +# --- MCP tool-name splitting ------------------------------------------------------ + + +def _known_mcp_server_names() -> list[str]: + """Config-declared MCP server names: user-level ``mcp.json`` + plugin configs. + + Best-effort inventory: servers contributed by extensions, ``chat.mcp.discovery`` + imports, dev containers, or non-default profiles are not discoverable from disk. + """ + config = _load_vscode_mcp_config() + servers = (config or {}).get('servers') + names = list(servers.keys()) if isinstance(servers, dict) else [] + for plugin in _collect_installed_plugins().values(): + names.extend(plugin.get('mcp_server_names') or []) + return names + + +def _server_name_variants(server_name: str) -> set[str]: + """Normalized forms a config name may take inside a VS Code tool-name prefix. + + The prefix derives from the server's self-reported handshake name, which often + resembles the config name modulo case and separators (a server configured as + ``dummy-tracker`` self-reporting ``DummyTracker`` yields prefix ``dummytracker``). + """ + lowered = server_name.lower() + underscored = re.sub(r'[^a-z0-9]+', '_', lowered).strip('_') + collapsed = re.sub(r'[^a-z0-9]', '', lowered) + return {v for v in (server_name, underscored, collapsed) if v} + + +def split_mcp_tool_name(tool_name: str, server_names: Iterable[str]) -> tuple[Optional[str], Optional[str]]: + """Split ``mcp__`` into ``(server, tool)``. + + The ```` part is VS Code's sanitized (and possibly truncated) form of + the server's SELF-REPORTED handshake name, not the config key — so matching + against known config names (and their normalized variants) is best-effort. + When nothing matches, return the unsplit remainder as the tool rather than + fabricating a server from a guessed split. + """ + rest = tool_name[len(_MCP_TOOL_PREFIX) :] + + best_server = None + best_variant_len = -1 + for server in server_names: + for variant in _server_name_variants(server): + if (rest == variant or rest.startswith(f'{variant}_')) and len(variant) > best_variant_len: + best_server = server + best_variant_len = len(variant) + if best_server is not None: + return best_server, rest[best_variant_len + 1 :] or None + + return None, rest or None + + +class Copilot(IDE): + name: ClassVar[str] = 'copilot' + display_name: ClassVar[str] = 'GitHub Copilot' + hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS) + + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + # Dedicated Cycode-owned file (Copilot reads every *.json in the hooks + # dir), unlike the shared settings files of other IDEs. + if scope == 'repo' and repo_path: + return repo_path / _REPO_HOOKS_SUBDIR / _HOOKS_FILE_NAME + return _copilot_home() / 'hooks' / _HOOKS_FILE_NAME + + def render_hooks_config(self, async_mode: bool = False) -> dict: + def entry(command: str) -> dict: + if async_mode: + # Copilot has no async hook flag; background via shell on unix. The + # explicit <&0 keeps the payload flowing: a bare `cmd &` gets its stdin + # reattached to /dev/null by the shell (job control is off in hooks). + # Windows PowerShell has no trailing-& operator, so it stays sync. + return { + 'type': 'command', + 'bash': f'{command} <&0 &', + 'powershell': command, + 'timeoutSec': _HOOK_TIMEOUT_SEC, + } + # Single cross-platform `command` field, copied to both shells by Copilot. + return {'type': 'command', 'command': command, 'timeoutSec': _HOOK_TIMEOUT_SEC} + + return { + 'version': 1, + 'hooks': { + 'sessionStart': [ + {'type': 'command', 'command': _SESSION_START_COMMAND, 'timeoutSec': _HOOK_TIMEOUT_SEC} + ], + 'userPromptSubmitted': [entry(_SCAN_PROMPT_COMMAND)], + 'preToolUse': [entry(_SCAN_TOOL_COMMAND)], + }, + } + + def matches_payload(self, raw_payload: dict) -> bool: + # Structural discrimination, no magic strings: VS Code Copilot events carry + # a top-level ISO timestamp and no transcript_path; real Claude Code events + # always carry transcript_path; Copilot CLI payloads have no hook_event_name. + return ( + raw_payload.get('hook_event_name', '') in _COPILOT_SCAN_EVENT_NAMES + and 'timestamp' in raw_payload + and 'transcript_path' not in raw_payload + ) + + 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: Union[AiHookEventType, str] = AiHookEventType.PROMPT + elif hook_event_name == 'PreToolUse' and tool_name == _READ_FILE_TOOL: + canonical_event = AiHookEventType.FILE_READ + elif hook_event_name == 'PreToolUse' and tool_name.startswith(_MCP_TOOL_PREFIX): + canonical_event = AiHookEventType.MCP_EXECUTION + else: + # No matchers in Copilot hooks: preToolUse fires for every tool. Pass + # the raw tool name through — it matches no handler, so scan_command + # answers with a neutral allow before any policy/network work. + canonical_event = tool_name or hook_event_name + + file_path = None + if canonical_event == AiHookEventType.FILE_READ and isinstance(tool_input, dict): + file_path = tool_input.get('filePath') + + mcp_server_name = None + mcp_tool_name = None + mcp_arguments = None + if canonical_event == AiHookEventType.MCP_EXECUTION: + mcp_server_name, mcp_tool_name = split_mcp_tool_name(tool_name, _known_mcp_server_names()) + mcp_arguments = tool_input if isinstance(tool_input, dict) else None + + return AIHookPayload( + event_name=canonical_event, + conversation_id=raw_payload.get('session_id'), + ide_provider=self.name, + 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.action == DecisionAction.ALLOW: + # Neutral allow: {} means "no objection", leaving VS Code's own + # permission flow intact. An explicit permissionDecision "allow" would + # pre-approve the tool past the user's confirmation prompts — and with + # no matchers that would cover every tool, not just scanned ones. + return {} + + if decision.event_type == AiHookEventType.PROMPT: + reason = decision.user_message or '' + # decision/reason is what VS Code acts on; continue/stopReason/systemMessage + # are the generic top-level fields — the combo is what was verified live. + return { + 'decision': 'block', + 'reason': reason, + 'continue': False, + 'stopReason': reason, + 'systemMessage': reason, + } + + 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: + return AIHookPayload( + conversation_id=raw_payload.get('session_id'), + model=raw_payload.get('model'), + ide_provider=self.name, + source=raw_payload.get('source'), + ) + + def get_session_context(self) -> tuple[Optional[dict], dict]: + # VS Code's mcp.json uses `servers` as its top-level key; normalized to the + # canonical mcpServers shape by build_global_config_file. + config = _load_vscode_mcp_config() + global_config_file = ( + build_global_config_file(_vscode_mcp_config_path(), config.get('servers')) if config else None + ) + return global_config_file, _collect_installed_plugins() diff --git a/cycode/cli/apps/ai_guardrails/ides/cursor.py b/cycode/cli/apps/ai_guardrails/ides/cursor.py index 950e15bb..01c65edb 100644 --- a/cycode/cli/apps/ai_guardrails/ides/cursor.py +++ b/cycode/cli/apps/ai_guardrails/ides/cursor.py @@ -7,7 +7,7 @@ from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND from cycode.cli.apps.ai_guardrails.ides._plugin_utils import build_global_config_file -from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision, shell_background_suffix 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 @@ -69,7 +69,7 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: 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 + command = f'{_SCAN_COMMAND}{shell_background_suffix(async_mode)}' hooks = {event: [{'command': command}] for event in self.hook_events} hooks['sessionStart'] = [{'command': _SESSION_START_COMMAND}] return {'version': 1, 'hooks': hooks} diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index e6f8b977..cad92263 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -82,6 +82,14 @@ def scan_command( hidden=True, ), ] = DEFAULT_IDE_NAME, + event: Annotated[ + Optional[str], + typer.Option( + '--event', + help='Hook event that triggered the scan, for IDEs whose payloads omit it (e.g. Copilot CLI).', + hidden=True, + ), + ] = None, ) -> None: """Scan content from AI IDE hooks for secrets. @@ -110,7 +118,18 @@ def scan_command( 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, 'ide': ide_integration.name}) + logger.debug( + 'Processing AI guardrails hook', + extra={'event_name': event_name, 'ide': ide_integration.name, 'cli_event_hint': event}, + ) + + # Resolved before any policy/client work: Copilot hooks have no matchers, so + # every tool call arrives here and unmatched tools must exit fast. + 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(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) + return # `or` (not a .get default) - Cursor sends workspace_roots=[] when no folder is open. workspace_roots = payload.get('workspace_roots') or ['.'] @@ -119,12 +138,6 @@ def scan_command( try: _initialize_clients(ctx) - 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(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) - return - 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)) diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py index dbaca44f..60fb331e 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -20,10 +20,34 @@ 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 + transcript = {'transcript_path': '/home/user/.claude/projects/transcript.jsonl'} + assert claude.matches_payload({'hook_event_name': 'UserPromptSubmit', **transcript}) is True + assert claude.matches_payload({'hook_event_name': 'PreToolUse', **transcript}) is True + assert claude.matches_payload({'hook_event_name': 'beforeSubmitPrompt', **transcript}) is False + assert claude.matches_payload({'hook_event_name': 'beforeReadFile', **transcript}) is False + + +def test_matches_payload_rejects_vscode_copilot_payloads() -> None: + """VS Code Copilot sends the same event names in the same snake_case dialect, + but never a transcript_path — those events must not be claimed as Claude Code.""" + claude = ClaudeCode() + assert ( + claude.matches_payload( + { + 'timestamp': '2026-07-14T13:33:24.387Z', + 'hook_event_name': 'PreToolUse', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'tool_name': 'read_file', + 'tool_input': {'filePath': '/Users/user/.gitconfig'}, + 'tool_use_id': 'call_KuiUJvNJ06uHlIdwKy16G9W6__vscode-1784034535752', + } + ) + is False + ) + assert ( + claude.matches_payload({'timestamp': '2026-07-14T13:32:46.517Z', 'hook_event_name': 'UserPromptSubmit'}) + is False + ) def test_parse_prompt_payload() -> None: diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py index 682739e6..33137311 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_codex.py +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -8,6 +8,7 @@ import pytest 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.codex import ( @@ -165,8 +166,9 @@ def test_render_hooks_never_emits_async_toml_flags() -> None: assert 'timeout' not in hook -def test_render_hooks_async_backgrounds_scan_hooks() -> None: - """In async mode, UserPromptSubmit + PreToolUse scan hooks shell-background.""" +def test_render_hooks_async_backgrounds_scan_hooks(mocker: MockerFixture) -> None: + """In async mode, UserPromptSubmit + PreToolUse scan hooks shell-background (unix).""" + mocker.patch('platform.system', return_value='Linux') rendered = Codex().render_hooks_config(async_mode=True) prompt_cmd = rendered['hooks']['UserPromptSubmit'][0]['hooks'][0]['command'] pretool_cmd = rendered['hooks']['PreToolUse'][0]['hooks'][0]['command'] @@ -174,6 +176,16 @@ def test_render_hooks_async_backgrounds_scan_hooks() -> None: assert pretool_cmd.endswith(' &') +def test_render_hooks_async_windows_stays_sync(mocker: MockerFixture) -> None: + """No '&' on Windows - nothing there detaches safely, so hooks run sync.""" + mocker.patch('platform.system', return_value='Windows') + rendered = Codex().render_hooks_config(async_mode=True) + prompt_cmd = rendered['hooks']['UserPromptSubmit'][0]['hooks'][0]['command'] + pretool_cmd = rendered['hooks']['PreToolUse'][0]['hooks'][0]['command'] + assert '&' not in prompt_cmd + assert '&' not in pretool_cmd + + def test_render_hooks_session_start_always_synchronous() -> None: """SessionStart registers the conversation context — never backgrounded.""" for mode in (False, True): diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py index 3bdbc19d..7d7ab773 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_contract.py +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -8,6 +8,7 @@ from pathlib import Path import pytest +from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.ides import IDES from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision @@ -60,8 +61,13 @@ def test_render_hooks_config_has_hooks_key(ide: IDE) -> None: assert isinstance(rendered['hooks'], dict) -def test_render_hooks_config_async_changes_output(ide: IDE) -> None: - """async_mode must influence the rendered output.""" +def test_render_hooks_config_async_changes_output(ide: IDE, mocker: MockerFixture) -> None: + """async_mode must influence the rendered output. + + Pinned to a unix platform: IDEs that background via a shell `&` render + identical sync/async configs on Windows, where no safe suffix exists. + """ + mocker.patch('platform.system', return_value='Linux') assert ide.render_hooks_config(async_mode=False) != ide.render_hooks_config(async_mode=True) diff --git a/tests/cli/commands/ai_guardrails/ides/test_copilot.py b/tests/cli/commands/ai_guardrails/ides/test_copilot.py new file mode 100644 index 00000000..8141f74f --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_copilot.py @@ -0,0 +1,416 @@ +"""GitHub Copilot (VS Code) IDE integration tests. + +Payload fixtures mirror real events captured from VS Code (built-in Copilot +Chat 0.56.0) and Copilot CLI, with identifying values swapped for dummies. +""" + +import json +import os +from pathlib import Path +from typing import Optional + +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.copilot import ( + Copilot, + _vscode_mcp_config_path, + split_mcp_tool_name, +) +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + +_VSCODE_PROMPT_PAYLOAD = { + 'timestamp': '2026-07-14T13:32:46.517Z', + 'hook_event_name': 'UserPromptSubmit', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'prompt': 'test prompt', +} + +_VSCODE_READ_FILE_PAYLOAD = { + 'timestamp': '2026-07-14T13:35:08.758Z', + 'hook_event_name': 'PreToolUse', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'tool_name': 'read_file', + 'tool_input': {'filePath': '/Users/user/.gitconfig', 'startLine': 1, 'endLine': 200}, + 'tool_use_id': 'call_dummyDummyDummyDummy__vscode-1784034535752', +} + +_VSCODE_MCP_PAYLOAD = { + 'timestamp': '2026-07-14T14:03:57.337Z', + 'hook_event_name': 'PreToolUse', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'tool_name': 'mcp_gitlab_get_user', + 'tool_input': {'user_id': 'dummy-user'}, + 'tool_use_id': 'call_dummyDummyDummyDummy__vscode-1784034535755', +} + +_VSCODE_SESSION_START_PAYLOAD = { + 'timestamp': '2026-07-14T13:32:46.474Z', + 'hook_event_name': 'SessionStart', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'source': 'new', + 'model': 'auto', +} + +# Copilot CLI dialect: camelCase, epoch-ms timestamp, no event name, stringified args. +_COPILOT_CLI_TOOL_PAYLOAD = { + 'sessionId': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': 1784038775604, + 'cwd': '/Users/user', + 'toolName': 'view', + 'toolArgs': '{"path": "/Users/user/.zshrc"}', +} + +_CLAUDE_CODE_PAYLOAD = { + 'session_id': 'session-123', + 'transcript_path': '/home/user/.claude/projects/transcript.jsonl', + 'cwd': '/Users/user/project', + 'hook_event_name': 'PreToolUse', + 'tool_name': 'Read', + 'tool_input': {'file_path': '/Users/user/.gitconfig'}, + 'tool_use_id': 'toolu_dummyDummyDummyDummy', +} + + +# --- matches_payload ------------------------------------------------------------ + + +def test_matches_payload_accepts_vscode_events() -> None: + copilot = Copilot() + assert copilot.matches_payload(_VSCODE_PROMPT_PAYLOAD) is True + assert copilot.matches_payload(_VSCODE_READ_FILE_PAYLOAD) is True + assert copilot.matches_payload(_VSCODE_MCP_PAYLOAD) is True + + +def test_matches_payload_rejects_claude_code_payloads() -> None: + # Same event names and dialect, but Claude Code always carries transcript_path. + assert Copilot().matches_payload(_CLAUDE_CODE_PAYLOAD) is False + + +def test_matches_payload_rejects_copilot_cli_payloads() -> None: + # CLI dialect is unsupported until its own parsing lands - must skip fail-open. + assert Copilot().matches_payload(_COPILOT_CLI_TOOL_PAYLOAD) is False + + +def test_matches_payload_rejects_cursor_payloads() -> None: + assert Copilot().matches_payload({'hook_event_name': 'beforeSubmitPrompt', 'prompt': 'test'}) is False + + +def test_matches_payload_requires_timestamp() -> None: + payload = {k: v for k, v in _VSCODE_PROMPT_PAYLOAD.items() if k != 'timestamp'} + assert Copilot().matches_payload(payload) is False + + +# --- parse_hook_payload --------------------------------------------------------- + + +def test_parse_prompt_payload() -> None: + unified = Copilot().parse_hook_payload(_VSCODE_PROMPT_PAYLOAD) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == '43cbad91-ea8b-4d4a-9acc-56561421c5d2' + assert unified.ide_provider == 'copilot' + assert unified.prompt == 'test prompt' + + +def test_parse_read_file_payload() -> None: + unified = Copilot().parse_hook_payload(_VSCODE_READ_FILE_PAYLOAD) + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/Users/user/.gitconfig' + assert unified.mcp_tool_name is None + + +def test_parse_mcp_payload_without_known_servers_reports_raw(fs: FakeFilesystem) -> None: + # No known servers on disk - honest fallback: no fabricated server, the full + # unsplit remainder as the tool. + unified = Copilot().parse_hook_payload(_VSCODE_MCP_PAYLOAD) + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name is None + assert unified.mcp_tool_name == 'gitlab_get_user' + assert unified.mcp_arguments == {'user_id': 'dummy-user'} + + +def test_parse_mcp_payload_with_known_server_containing_underscores(fs: FakeFilesystem) -> None: + fs.create_file( + _vscode_mcp_config_path(), + contents=json.dumps({'servers': {'gitlab_selfhosted': {'command': 'dummy-mcp'}}}), + ) + payload = {**_VSCODE_MCP_PAYLOAD, 'tool_name': 'mcp_gitlab_selfhosted_get_user'} + unified = Copilot().parse_hook_payload(payload) + assert unified.mcp_server_name == 'gitlab_selfhosted' + assert unified.mcp_tool_name == 'get_user' + + +def test_parse_unmatched_tool_passes_raw_tool_name_through() -> None: + # No matchers in Copilot hooks: unscanned tools must map to an event that + # matches no handler so scan_command answers with a neutral allow. + payload = {**_VSCODE_READ_FILE_PAYLOAD, 'tool_name': 'list_dir', 'tool_input': {'path': '/Users/user'}} + unified = Copilot().parse_hook_payload(payload) + assert unified.event_name == 'list_dir' + assert unified.file_path is None + assert unified.mcp_server_name is None + + +# --- split_mcp_tool_name -------------------------------------------------------- + + +def test_split_mcp_tool_name_prefers_longest_known_server() -> None: + servers = ['gitlab', 'gitlab_selfhosted'] + assert split_mcp_tool_name('mcp_gitlab_selfhosted_get_user', servers) == ('gitlab_selfhosted', 'get_user') + + +def test_split_mcp_tool_name_matches_normalized_config_name() -> None: + # The wire prefix is the sanitized SELF-REPORTED server name (`DummyTracker` -> + # `dummytracker`), which resembles the config name modulo separators. + assert split_mcp_tool_name('mcp_dummytracker_fetch_api', ['dummy-tracker']) == ('dummy-tracker', 'fetch_api') + + +def test_split_mcp_tool_name_unknown_server_reports_raw() -> None: + # Self-reported names can diverge entirely from config names (e.g. a server + # configured as `dummy-plugin` self-reporting `Vendor.DummyApp.Hybrid` yields + # a sanitized+truncated prefix like `vendor_du`) - never guess a split. + assert split_mcp_tool_name('mcp_vendor_du_search_instructions', ['dummy-plugin']) == ( + None, + 'vendor_du_search_instructions', + ) + + +def test_split_mcp_tool_name_server_only() -> None: + assert split_mcp_tool_name('mcp_gitlab', ['gitlab']) == ('gitlab', None) + + +# --- build_hook_response -------------------------------------------------------- + + +def test_allow_is_neutral_for_every_event_type() -> None: + """Allow must be {} - an explicit permissionDecision "allow" would pre-approve + tools past VS Code's own permission prompts (and with no matchers, that would + cover every tool, not just scanned ones).""" + copilot = Copilot() + for event_type in AiHookEventType: + assert copilot.build_hook_response(HookDecision.allow(event_type)) == {} + + +def test_deny_prompt_response_shape() -> None: + response = Copilot().build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'Secrets detected')) + assert response['decision'] == 'block' + assert response['reason'] == 'Secrets detected' + assert response['continue'] is False + assert response['stopReason'] == 'Secrets detected' + + +def test_deny_tool_response_shape() -> None: + response = Copilot().build_hook_response(HookDecision.deny(AiHookEventType.FILE_READ, 'Sensitive file')) + assert response == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'Sensitive file', + } + } + + +def test_ask_mcp_response_shape() -> None: + response = Copilot().build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'Allow execution?')) + assert response['hookSpecificOutput']['permissionDecision'] == 'ask' + assert response['hookSpecificOutput']['permissionDecisionReason'] == 'Allow execution?' + + +# --- render_hooks_config / settings_path ---------------------------------------- + + +def test_render_hooks_config_sync_uses_cross_platform_command() -> None: + rendered = Copilot().render_hooks_config() + assert rendered['version'] == 1 + + prompt_entry = rendered['hooks']['userPromptSubmitted'][0] + assert prompt_entry['command'] == 'cycode ai-guardrails scan --ide copilot --event UserPromptSubmit' + assert 'bash' not in prompt_entry + + tool_entry = rendered['hooks']['preToolUse'][0] + assert tool_entry['command'] == 'cycode ai-guardrails scan --ide copilot --event PreToolUse' + + session_entry = rendered['hooks']['sessionStart'][0] + assert session_entry['command'] == 'cycode ai-guardrails session-start --ide copilot' + + +def test_render_hooks_config_async_backgrounds_on_unix() -> None: + rendered = Copilot().render_hooks_config(async_mode=True) + tool_entry = rendered['hooks']['preToolUse'][0] + assert tool_entry['bash'].endswith('&') + assert not tool_entry['powershell'].endswith('&') + assert 'command' not in tool_entry + + +def test_settings_path_user_scope() -> None: + path = Copilot().settings_path('user') + assert path == Path.home() / '.copilot' / 'hooks' / 'cycode.json' + + +def test_settings_path_honors_copilot_home(mocker: MockerFixture) -> None: + mocker.patch.dict(os.environ, {'COPILOT_HOME': '/custom/copilot-home'}) + path = Copilot().settings_path('user') + assert path == Path('/custom/copilot-home') / 'hooks' / 'cycode.json' + + +def test_settings_path_repo_scope(tmp_path: Path) -> None: + path = Copilot().settings_path('repo', tmp_path) + assert path == tmp_path / '.github' / 'hooks' / 'cycode.json' + + +# --- session payload / context -------------------------------------------------- + + +def test_build_session_payload() -> None: + session = Copilot().build_session_payload(_VSCODE_SESSION_START_PAYLOAD) + assert session.ide_provider == 'copilot' + assert session.conversation_id == '43cbad91-ea8b-4d4a-9acc-56561421c5d2' + assert session.model == 'auto' + assert session.source == 'new' + + +def test_get_session_context_normalizes_servers_key(fs: FakeFilesystem) -> None: + config_path = _vscode_mcp_config_path() + fs.create_file( + config_path, + contents=json.dumps({'servers': {'gitlab': {'type': 'stdio', 'command': 'dummy-mcp'}}}), + ) + + global_config_file, plugins = Copilot().get_session_context() + + assert global_config_file is not None + assert global_config_file['path'] == str(config_path) + # VS Code's `servers` key is normalized to the canonical mcpServers shape. + assert json.loads(global_config_file['content']) == { + 'mcpServers': {'gitlab': {'type': 'stdio', 'command': 'dummy-mcp'}} + } + assert plugins == {} + + +def test_get_session_context_without_config(fs: FakeFilesystem) -> None: + assert Copilot().get_session_context() == (None, {}) + + +# --- plugins inventory ----------------------------------------------------------- + + +def _create_plugin_on_disk( + fs: FakeFilesystem, + plugin_dir: Path, + manifest_location: str = '.github/plugin/plugin.json', + manifest_extra: Optional[dict] = None, + mcp_file: str = '.mcp.json', + server_name: str = 'dummy-server', +) -> None: + manifest = {'name': plugin_dir.name, 'version': '1.0.0', 'description': 'Dummy plugin', **(manifest_extra or {})} + fs.create_file(plugin_dir / manifest_location, contents=json.dumps(manifest)) + fs.create_file( + plugin_dir / mcp_file, + contents=json.dumps({'mcpServers': {server_name: {'command': 'dummy-mcp'}}}), + ) + + +def test_cli_registry_plugins(fs: FakeFilesystem) -> None: + """CLI-installed plugins: comment-headed config.json registry, manifest with an + mcpServers path-ref.""" + plugin_dir = Path.home() / '.copilot' / 'installed-plugins' / 'dummy-marketplace' / 'dummy-plugin' + _create_plugin_on_disk(fs, plugin_dir, manifest_extra={'mcpServers': './.mcp.json'}) + fs.create_file( + Path.home() / '.copilot' / 'config.json', + contents='// This file is managed automatically.\n' + + json.dumps( + { + 'installedPlugins': [ + { + 'name': 'dummy-plugin', + 'marketplace': 'dummy-marketplace', + 'version': '1.0.0', + 'cache_path': str(plugin_dir), + 'enabled': True, + }, + { + 'name': 'disabled-plugin', + 'marketplace': 'dummy-marketplace', + 'cache_path': str(plugin_dir), + 'enabled': False, + }, + ] + } + ), + ) + + _, plugins = Copilot().get_session_context() + + assert set(plugins) == {'dummy-plugin@dummy-marketplace'} + entry = plugins['dummy-plugin@dummy-marketplace'] + assert entry['enabled'] is True + assert entry['version'] == '1.0.0' + assert entry['mcp_server_names'] == ['dummy-server'] + assert json.loads(entry['mcp_config_file']) == {'mcpServers': {'dummy-server': {'command': 'dummy-mcp'}}} + + +def test_vscode_registry_plugins(fs: FakeFilesystem) -> None: + """VS Code UI-installed plugins: installed.json registry with file:// pluginUri, + root .mcp.json convention without a manifest mcpServers field.""" + plugin_dir = ( + Path.home() / '.vscode' / 'agent-plugins' / 'github.com' / 'dummy-org' / 'repo' / 'plugins' / 'dummy-plugin' + ) + _create_plugin_on_disk(fs, plugin_dir, manifest_location='.claude-plugin/plugin.json') + fs.create_file( + Path.home() / '.vscode' / 'agent-plugins' / 'installed.json', + contents=json.dumps( + { + 'version': 1, + 'installed': [ + {'pluginUri': plugin_dir.as_uri(), 'marketplace': 'dummy-marketplace', 'name': 'dummy-plugin'} + ], + } + ), + ) + + _, plugins = Copilot().get_session_context() + + assert set(plugins) == {'dummy-plugin@dummy-marketplace'} + assert plugins['dummy-plugin@dummy-marketplace']['mcp_server_names'] == ['dummy-server'] + + +def test_local_dir_plugins_from_plugin_locations_setting(fs: FakeFilesystem) -> None: + """Local-directory plugins declared via chat.pluginLocations (JSONC settings).""" + enabled_dir = Path('/plugins/local-plugin') + disabled_dir = Path('/plugins/disabled-plugin') + _create_plugin_on_disk(fs, enabled_dir, manifest_location='plugin.json') + _create_plugin_on_disk(fs, disabled_dir, manifest_location='plugin.json') + # json.dumps escapes Windows path separators; the comment line exercises JSONC handling. + settings = json.dumps({'chat.pluginLocations': {str(enabled_dir): True, str(disabled_dir): False}}) + fs.create_file( + _vscode_mcp_config_path().parent / 'settings.json', + contents=f'// user settings\n{settings}', + ) + + _, plugins = Copilot().get_session_context() + + assert set(plugins) == {'local-plugin@local'} + assert plugins['local-plugin@local']['mcp_server_names'] == ['dummy-server'] + + +def test_parse_mcp_payload_matches_plugin_server_via_normalized_name(fs: FakeFilesystem) -> None: + """End-to-end split: a plugin-declared server named dummy-tracker attributes the + wire tool mcp_dummytracker_fetch_api (prefix = sanitized self-reported name).""" + plugin_dir = Path.home() / '.copilot' / 'installed-plugins' / 'dummy-marketplace' / 'dummy-tracker' + _create_plugin_on_disk(fs, plugin_dir, server_name='dummy-tracker') + fs.create_file( + Path.home() / '.copilot' / 'config.json', + contents=json.dumps( + { + 'installedPlugins': [ + {'name': 'dummy-tracker', 'marketplace': 'dummy-marketplace', 'cache_path': str(plugin_dir)} + ] + } + ), + ) + + payload = {**_VSCODE_MCP_PAYLOAD, 'tool_name': 'mcp_dummytracker_fetch_api', 'tool_input': {'key': 'payments'}} + unified = Copilot().parse_hook_payload(payload) + + assert unified.mcp_server_name == 'dummy-tracker' + assert unified.mcp_tool_name == 'fetch_api' 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 b7d7734a..349a1ee3 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -127,7 +127,12 @@ def test_claude_code_payload_with_claude_code_ide( mock_scan_command_deps: dict[str, MagicMock], ) -> None: """Test Claude Code payload is processed when --ide claude-code is specified.""" - payload = {'hook_event_name': 'UserPromptSubmit', 'session_id': 'session-123', 'prompt': 'test'} + payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + 'transcript_path': '/home/user/.claude/projects/transcript.jsonl', + } mocker.patch('sys.stdin', StringIO(json.dumps(payload))) mock_scan_command_deps['load_policy'].return_value = {'fail_open': True} @@ -166,6 +171,63 @@ def test_empty_workspace_roots_falls_back_to_cwd( mock_handler.assert_called_once() +class TestCopilotPayloadRouting: + """Copilot-specific routing through scan_command.""" + + def test_unmatched_tool_allows_without_policy_or_clients( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Copilot hooks have no matchers - tools we don't scan must skip fast. + + The handler lookup runs before load_policy/_initialize_clients, so an + unmatched tool costs neither file I/O nor network setup. + """ + payload = { + 'timestamp': '2026-07-14T13:33:24.387Z', + 'hook_event_name': 'PreToolUse', + 'session_id': 'session-123', + 'tool_name': 'list_dir', + 'tool_input': {'path': '/Users/user'}, + 'tool_use_id': 'call_abc__vscode-1', + } + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + mock_scan_command_deps['get_handler'].return_value = None + + scan_command(mock_ctx, ide='copilot') + + mock_scan_command_deps['get_handler'].assert_called_once_with('list_dir') + mock_scan_command_deps['load_policy'].assert_not_called() + mock_scan_command_deps['initialize_clients'].assert_not_called() + assert json.loads(capsys.readouterr().out) == {} + + def test_copilot_cli_payload_skipped( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Copilot CLI shares the hooks file but speaks camelCase without an event + name - until its dialect is supported, its events skip fail-open.""" + payload = { + 'sessionId': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': 1784038775604, + 'cwd': '/Users/user', + 'toolName': 'view', + 'toolArgs': '{"path": "/Users/user/file"}', + } + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + scan_command(mock_ctx, ide='copilot') + + _assert_no_api_calls(mock_scan_command_deps) + assert json.loads(capsys.readouterr().out) == {} + + class TestDefaultIdeParameterViaCli: """Tests that verify default IDE parameter works correctly via CLI invocation.""" diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index 2097780a..cf478fcc 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -1,5 +1,6 @@ """Tests for AI guardrails hooks manager and per-IDE hooks rendering.""" +import json from pathlib import Path from typing import TYPE_CHECKING @@ -8,6 +9,7 @@ if TYPE_CHECKING: import pytest + from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.consts import ( CYCODE_SCAN_PROMPT_COMMAND, @@ -22,6 +24,7 @@ ) from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode from cycode.cli.apps.ai_guardrails.ides.codex import Codex +from cycode.cli.apps.ai_guardrails.ides.copilot import Copilot from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor @@ -43,6 +46,13 @@ def test_is_cycode_hook_entry_claude_code_format() -> None: assert is_cycode_hook_entry(entry) is True +def test_is_cycode_hook_entry_copilot_shell_fields() -> None: + # Copilot async entries carry per-OS bash/powershell fields instead of `command`. + assert is_cycode_hook_entry({'type': 'command', 'bash': 'cycode ai-guardrails scan --ide copilot &'}) is True + assert is_cycode_hook_entry({'type': 'command', 'powershell': 'cycode ai-guardrails scan --ide copilot'}) is True + assert is_cycode_hook_entry({'type': 'command', 'bash': '/usr/local/bin/user-hook.sh'}) is False + + def test_is_cycode_hook_entry_non_cycode() -> None: """Non-Cycode hooks must not be detected.""" assert is_cycode_hook_entry({'command': 'some-other-command'}) is False @@ -69,8 +79,9 @@ def test_cursor_render_hooks_sync() -> None: assert '&' not in entry['command'] -def test_cursor_render_hooks_async() -> None: - """Cursor async hooks: '&' suffix on scan commands.""" +def test_cursor_render_hooks_async(mocker: 'MockerFixture') -> None: + """Cursor async hooks: '&' suffix on scan commands (unix).""" + mocker.patch('platform.system', return_value='Linux') 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(): @@ -79,6 +90,17 @@ def test_cursor_render_hooks_async() -> None: assert CYCODE_SCAN_PROMPT_COMMAND in entry['command'] +def test_cursor_render_hooks_async_windows_stays_sync(mocker: 'MockerFixture') -> None: + """No '&' on Windows: cmd treats it as a no-op separator and Windows + PowerShell rejects it outright - either way nothing detaches.""" + mocker.patch('platform.system', return_value='Windows') + 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 '&' not in entry['command'] + + def test_cursor_render_hooks_session_start() -> None: """Cursor session_start carries the --ide flag explicitly.""" config = Cursor().render_hooks_config() @@ -170,8 +192,6 @@ def test_install_preserves_user_hook_colocated_with_cycode( """install must not clobber a user-authored hook that shares an entry with a Cycode hook. The filter is hook-level, not entry-level. """ - import json - repo = Path('/repo') fs.create_dir(repo) hooks_path = repo / '.codex' / 'hooks.json' @@ -223,8 +243,6 @@ def test_uninstall_preserves_user_hook_colocated_with_cycode( fs: FakeFilesystem, monkeypatch: 'pytest.MonkeyPatch' ) -> None: """uninstall must strip only the Cycode hook from a mixed entry.""" - import json - repo = Path('/repo') fs.create_dir(repo) hooks_path = repo / '.codex' / 'hooks.json' @@ -259,6 +277,32 @@ def test_uninstall_preserves_user_hook_colocated_with_cycode( assert not any('cycode ai-guardrails' in c for c in commands) +def test_copilot_dedicated_file_install_uninstall_lifecycle(fs: FakeFilesystem) -> None: + """Copilot uses a dedicated Cycode-owned file: install creates it from + scratch, reinstall is idempotent, uninstall removes the file entirely.""" + copilot = Copilot() + hooks_path = copilot.settings_path('user') + + success, _ = install_hooks(copilot) + assert success is True + saved = json.loads(hooks_path.read_text()) + assert saved['version'] == 1 + assert set(saved['hooks']) == {'sessionStart', 'userPromptSubmitted', 'preToolUse'} + assert all(len(entries) == 1 for entries in saved['hooks'].values()) + + # Reinstall (also flipping mode) must replace, not duplicate. + success, _ = install_hooks(copilot, report_mode=True) + assert success is True + saved = json.loads(hooks_path.read_text()) + assert all(len(entries) == 1 for entries in saved['hooks'].values()) + assert saved['hooks']['preToolUse'][0]['bash'].endswith('&') + + # Uninstall deletes the emptied dedicated file rather than leaving a husk. + success, _ = uninstall_hooks(copilot) + assert success is True + assert not hooks_path.exists() + + def test_create_policy_file_repo_scope(fs: FakeFilesystem) -> None: """Create a policy file in repo scope.""" repo_path = Path('/my-repo') 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 6beaa615..eaec8531 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -12,6 +12,7 @@ from cycode.cli.apps.ai_guardrails.ides import IDES, collect_all_session_contexts from cycode.cli.apps.ai_guardrails.ides import claude_code as _claude_mod from cycode.cli.apps.ai_guardrails.ides import codex as _codex_mod +from cycode.cli.apps.ai_guardrails.ides import copilot as _copilot_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 @@ -285,19 +286,23 @@ def test_no_mcp_anywhere_still_reports_device( ) +@patch.object(_copilot_mod, '_collect_installed_plugins') +@patch.object(_copilot_mod, '_load_vscode_mcp_config') @patch.object(_codex_mod, '_load_codex_config') @patch.object(_cursor_mod, '_load_cursor_mcp_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_global_file_and_plugin_metadata( +def test_claude_code_reports_config_files_and_plugin_metadata( mock_get_auth: MagicMock, mock_get_client: MagicMock, mock_load_config: MagicMock, mock_load_settings: MagicMock, mock_load_cursor: MagicMock, mock_load_codex: MagicMock, + mock_load_vscode: MagicMock, + mock_collect_copilot_plugins: MagicMock, mock_ctx: MagicMock, tmp_path: Path, ) -> None: @@ -308,6 +313,8 @@ def test_claude_code_reports_global_file_and_plugin_metadata( mock_get_client.return_value = mock_ai_client mock_load_cursor.return_value = None mock_load_codex.return_value = None + mock_load_vscode.return_value = None + mock_collect_copilot_plugins.return_value = {} # Set up a fake plugin directory on disk. plugin_dir = tmp_path / 'dummy-plugin' @@ -360,6 +367,8 @@ def test_claude_code_reports_global_file_and_plugin_metadata( ) +@patch.object(_copilot_mod, '_collect_installed_plugins') +@patch.object(_copilot_mod, '_load_vscode_mcp_config') @patch.object(_codex_mod, '_load_codex_config') @patch.object(_claude_mod, 'load_claude_settings') @patch.object(_claude_mod, 'load_claude_config') @@ -373,6 +382,8 @@ def test_cursor_trigger_sweeps_other_ides( mock_load_config: MagicMock, mock_load_settings: MagicMock, mock_load_codex: MagicMock, + mock_load_vscode: MagicMock, + mock_collect_copilot_plugins: MagicMock, mock_ctx: MagicMock, ) -> None: """A Cursor-triggered session start also reports Claude's config via config_files.""" @@ -385,6 +396,8 @@ def test_cursor_trigger_sweeps_other_ides( mock_load_config.return_value = {'mcpServers': claude_servers} mock_load_settings.return_value = None mock_load_codex.return_value = None + mock_load_vscode.return_value = None + mock_collect_copilot_plugins.return_value = {} payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} @@ -549,6 +562,7 @@ def test_collect_all_session_contexts_merges_plugins_first_wins() -> None: patch.object(IDES['cursor'], 'get_session_context', return_value=(None, {})), patch.object(IDES['claude-code'], 'get_session_context', return_value=(None, {'plug@m': claude_plugin})), patch.object(IDES['codex'], 'get_session_context', return_value=(None, {'plug@m': codex_plugin})), + patch.object(IDES['copilot'], 'get_session_context', return_value=(None, {})), ): _, plugins = collect_all_session_contexts()