diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 79ba9e8f..131e8e1a 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -282,15 +282,9 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: } def matches_payload(self, raw_payload: dict) -> bool: - # 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) — Copilot additionally carries a top-level - # timestamp, which Claude Code never sends. - return ( - raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES - and 'transcript_path' in raw_payload - and 'timestamp' not in raw_payload - ) + # transcript_path is a documented Claude Code field, present on every hook event. + # Positive test by design: an absence check breaks silently when a vendor adds a field. + return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES and 'transcript_path' in raw_payload def is_synthetic_prompt(self, raw_payload: dict) -> bool: if raw_payload.get('hook_event_name') != 'UserPromptSubmit': diff --git a/cycode/cli/apps/ai_guardrails/ides/copilot.py b/cycode/cli/apps/ai_guardrails/ides/copilot.py index cfb20f0a..5f3bc76a 100644 --- a/cycode/cli/apps/ai_guardrails/ides/copilot.py +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -1,17 +1,24 @@ -"""GitHub Copilot (VS Code extension) integration for AI guardrails. +"""GitHub Copilot 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``), told apart by the one field Claude Code never sends: a top-level -ISO ``timestamp``. VS Code also sends a ``transcript_path`` of its own once a -workspace has chat history, so that field cannot discriminate. 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. +(user scope) or ``/.github/hooks/cycode.json`` (repo scope). One file, but +two runtimes are known to execute it: VS Code's own chat runtime, and the Copilot +agent runtime (Copilot CLI, and VS Code agent sessions). The repo-scope location +is also read by the Copilot cloud coding agent, whose dialect is untested here. + +Both deliver Claude-style payloads (``hook_event_name``, ``tool_name``, +``tool_input``) when the event keys are registered in PascalCase; the agent runtime +answers camelCase keys with its own dialect (``sessionId``, no event name) instead. +Copilot payloads are told apart from Claude Code's by the one field Claude Code +never sends, a top-level ``timestamp``; ``transcript_path`` cannot discriminate, +since VS Code sends one of its own whenever a folder is open. + +The tool vocabulary still differs by runtime — VS Code reads files with +``read_file``/``filePath`` and names MCP tools ``mcp__``, the agent +runtime uses ``Read``/``path`` and ``-`` — so both are accepted. +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 @@ -37,20 +44,36 @@ logger = get_logger('AI Guardrails Copilot') -# Payload dialect (VS Code sends Claude-style PascalCase event names). +# Payload dialect (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). + +# Two tool vocabularies reach us through one hooks file: VS Code's own runtime +# names file reads `read_file` with a `filePath` argument, while the Copilot agent +# runtime (Copilot CLI, and VS Code agent sessions) names them `Read` with `path`. +# The names are disjoint, so both are accepted rather than switched between. +_READ_FILE_TOOLS = frozenset({'read_file', 'Read'}) +_READ_PATH_KEYS = ('path', 'filePath') + +# VS Code names MCP tools `mcp__` (single underscores); the agent +# runtime uses `-` with no prefix (its SDK documents that wire form), +# leaving a hyphen as the only marker of an MCP call there. Every built-in agent +# tool observed is lower snake_case (`view`, `glob`, `str_replace`, `ask_user`) or +# PascalCase (`Read`), so this holds for them — but SDK- or custom-agent-registered +# tools may be named freely. A hyphenated custom tool would be scanned as an MCP +# call with no resolvable server: an extra scan, never a missed one, which is the +# safe direction to err for a guardrail. _MCP_TOOL_PREFIX = 'mcp_' +_MCP_AGENT_SEPARATOR = '-' -# Hooks-file dialect (Copilot-native camelCase event names). -_HOOK_EVENTS = ['userPromptSubmitted', 'preToolUse'] +# Hooks-file event keys. Their case selects the agent runtime's payload dialect. +_HOOK_EVENTS = ['UserPromptSubmit', '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' +_AGENT_MCP_CONFIG_FILENAME = 'mcp-config.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; @@ -68,13 +91,9 @@ 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' +# One command for both events: every runtime self-describes via hook_event_name once +# the events are registered in PascalCase, so --event is no longer passed. +_SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide copilot' _SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide copilot' @@ -253,7 +272,11 @@ def _collect_installed_plugins() -> dict: def _known_mcp_server_names() -> list[str]: - """Config-declared MCP server names: user-level ``mcp.json`` + plugin configs. + """Config-declared MCP server names, across both runtimes' config files. + + VS Code declares them in its user-level ``mcp.json`` under ``servers``; the + agent runtime uses ``~/.copilot/mcp-config.json`` under ``mcpServers``. Both are + read because one hooks file serves both, and plugin configs contribute to either. Best-effort inventory: servers contributed by extensions, ``chat.mcp.discovery`` imports, dev containers, or non-default profiles are not discoverable from disk. @@ -261,6 +284,12 @@ def _known_mcp_server_names() -> list[str]: config = _load_vscode_mcp_config() servers = (config or {}).get('servers') names = list(servers.keys()) if isinstance(servers, dict) else [] + + agent_config = _load_jsonc(_copilot_home() / _AGENT_MCP_CONFIG_FILENAME) or {} + agent_servers = agent_config.get('mcpServers') + if isinstance(agent_servers, dict): + names.extend(agent_servers.keys()) + for plugin in _collect_installed_plugins().values(): names.extend(plugin.get('mcp_server_names') or []) return names @@ -279,22 +308,57 @@ def _server_name_variants(server_name: str) -> set[str]: 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)``. +def _read_file_path(tool_name: str, tool_input: object) -> Optional[str]: + """Path of a file-read tool call, or None when this isn't one. + + The agent runtime reuses its read tool for directory listings, with a payload + identical to a file read, so the path has to be stat-ed to tell them apart — + VS Code has no such ambiguity (`read_file` vs `list_dir`). A path that isn't an + existing file (a directory, or already deleted) has nothing to scan. + """ + if tool_name not in _READ_FILE_TOOLS or not isinstance(tool_input, dict): + return None + + raw_path = next((tool_input[key] for key in _READ_PATH_KEYS if tool_input.get(key)), None) + if not isinstance(raw_path, str): + return None + + try: + if not Path(raw_path).is_file(): + return None + except OSError as e: + logger.debug('Failed to stat read path, %s', {'path': raw_path}, exc_info=e) + return None + return raw_path + - 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. +def is_mcp_tool_name(tool_name: str) -> bool: + """Whether a tool name is an MCP call in either runtime's naming scheme.""" + return tool_name.startswith(_MCP_TOOL_PREFIX) or _MCP_AGENT_SEPARATOR in tool_name + + +def split_mcp_tool_name(tool_name: str, server_names: Iterable[str]) -> tuple[Optional[str], Optional[str]]: + """Split an MCP tool name into ``(server, tool)``. + + Handles both naming schemes: VS Code's ``mcp__`` and the agent + runtime's prefix-less ``-``. In the VS Code form the ```` + part is a sanitized (and possibly truncated) form of the server's SELF-REPORTED + handshake name rather than the config key, so matching against known config + names (and their normalized variants) is best-effort. Server names may + themselves contain the separator, hence the longest-match. 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) :] + if tool_name.startswith(_MCP_TOOL_PREFIX): + rest, separator = tool_name[len(_MCP_TOOL_PREFIX) :], '_' + else: + rest, separator = tool_name, _MCP_AGENT_SEPARATOR 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: + if (rest == variant or rest.startswith(f'{variant}{separator}')) and len(variant) > best_variant_len: best_server = server best_variant_len = len(variant) if best_server is not None: @@ -318,13 +382,17 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: 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. + # Copilot has no async hook flag; background via shell on unix. Both + # redirects are load-bearing. `<&0` keeps the payload flowing: a bare + # `cmd &` gets its stdin reattached to /dev/null by the shell (job + # control is off in hooks), so the scan reads nothing and allows. The + # stdout redirect is what actually makes it async: the backgrounded + # child inherits the hook's stdout and the runner waits on that pipe + # for EOF, so without it the scan blocks the response it was meant to + # run behind. Windows PowerShell has no trailing-&, so it stays sync. return { 'type': 'command', - 'bash': f'{command} <&0 &', + 'bash': f'{command} <&0 >/dev/null 2>&1 &', 'powershell': command, 'timeoutSec': _HOOK_TIMEOUT_SEC, } @@ -334,9 +402,9 @@ def entry(command: str) -> dict: return { 'version': 1, 'hooks': { - 'sessionStart': [{'type': 'command', 'command': _SESSION_START_COMMAND}], - 'userPromptSubmitted': [entry(_SCAN_PROMPT_COMMAND)], - 'preToolUse': [entry(_SCAN_TOOL_COMMAND)], + 'SessionStart': [{'type': 'command', 'command': _SESSION_START_COMMAND}], + 'UserPromptSubmit': [entry(_SCAN_COMMAND)], + 'PreToolUse': [entry(_SCAN_COMMAND)], }, } @@ -350,21 +418,21 @@ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: tool_name = raw_payload.get('tool_name', '') tool_input = raw_payload.get('tool_input') + read_path = _read_file_path(tool_name, 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: + elif hook_event_name == 'PreToolUse' and read_path is not None: canonical_event = AiHookEventType.FILE_READ - elif hook_event_name == 'PreToolUse' and tool_name.startswith(_MCP_TOOL_PREFIX): + elif hook_event_name == 'PreToolUse' and is_mcp_tool_name(tool_name): canonical_event = AiHookEventType.MCP_EXECUTION else: - # No matchers in Copilot hooks: preToolUse fires for every tool. Pass + # 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') + file_path = read_path if canonical_event == AiHookEventType.FILE_READ else None mcp_server_name = None mcp_tool_name = None diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index 1c0c42b8..5cf5da38 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -83,14 +83,6 @@ 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. @@ -132,7 +124,7 @@ def scan_command( event_name = unified_payload.event_name logger.debug( 'Processing AI guardrails hook', - extra={'event_name': event_name, 'ide': ide_integration.name, 'cli_event_hint': event}, + extra={'event_name': event_name, 'ide': ide_integration.name}, ) # Resolved before any policy/client work: Copilot hooks have no matchers, so 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 657a7b5c..743bb372 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -28,9 +28,8 @@ def test_matches_payload_only_claude_events() -> None: def test_matches_payload_rejects_vscode_copilot_payloads() -> None: - """VS Code Copilot sends the same event names in the same snake_case dialect, and - now a transcript_path of its own — only the top-level timestamp, which Claude Code - never sends, keeps those events from being claimed as Claude Code.""" + """VS Code Copilot sends the same event names in the same snake_case dialect, so + the documented transcript_path is what keeps its events from being claimed here.""" claude = ClaudeCode() assert ( claude.matches_payload( @@ -49,21 +48,6 @@ def test_matches_payload_rejects_vscode_copilot_payloads() -> None: claude.matches_payload({'timestamp': '2026-07-14T13:32:46.517Z', 'hook_event_name': 'UserPromptSubmit'}) is False ) - # Carrying a transcript_path must not be enough to claim a Copilot event, or the - # same prompt gets processed twice when both integrations are installed. - assert ( - claude.matches_payload( - { - 'timestamp': '2026-08-13T10:55:29.000Z', - 'hook_event_name': 'UserPromptSubmit', - 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', - 'cwd': '/Users/user/project', - 'prompt': 'test prompt', - 'transcript_path': '/Users/user/Library/Application Support/Code/User/workspaceStorage/d/t.jsonl', - } - ) - is False - ) def test_is_synthetic_prompt_task_notification() -> None: diff --git a/tests/cli/commands/ai_guardrails/ides/test_copilot.py b/tests/cli/commands/ai_guardrails/ides/test_copilot.py index c4aec1e1..5d5f6d4f 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_copilot.py +++ b/tests/cli/commands/ai_guardrails/ides/test_copilot.py @@ -27,7 +27,9 @@ 'prompt': 'test prompt', } -# VS Code attaches a per-session transcript_path once the workspace has chat history. +# VS Code attaches a per-session transcript_path whenever a folder is open: the +# transcript dir is derived from the extension's workspace storageUri, which is +# undefined only in an empty window. _VSCODE_PROMPT_PAYLOAD_WITH_TRANSCRIPT = { **_VSCODE_PROMPT_PAYLOAD, 'cwd': '/Users/user/project', @@ -60,7 +62,37 @@ 'model': 'auto', } -# Copilot CLI dialect: camelCase, epoch-ms timestamp, no event name, stringified args. +# Agent-runtime payloads (Copilot CLI, and VS Code agent sessions) under PascalCase +# event keys: same Claude-style dialect as VS Code, but a `cwd` and its own tool +# vocabulary (`Read`/`path`, `-`) rather than VS Code's. +_AGENT_PROMPT_PAYLOAD = { + 'cwd': '/Users/user/project', + 'hook_event_name': 'UserPromptSubmit', + 'prompt': 'test prompt', + 'session_id': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': '2026-08-14T08:49:22.000Z', +} + +_AGENT_READ_FILE_PAYLOAD = { + 'cwd': '/Users/user/project', + 'hook_event_name': 'PreToolUse', + 'session_id': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': '2026-08-14T08:51:17.000Z', + 'tool_name': 'Read', + 'tool_input': {'path': '/Users/user/.gitconfig'}, +} + +_AGENT_MCP_PAYLOAD = { + 'cwd': '/Users/user/project', + 'hook_event_name': 'PreToolUse', + 'session_id': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': '2026-08-14T08:51:17.000Z', + 'tool_name': 'gitlab-get_user', + 'tool_input': {'user_id': 'dummy-user'}, +} + +# Stale pre-PascalCase installs still emit this: camelCase, epoch-ms timestamp, +# no event name, stringified args. Rejected — they are corrected on reinstall. _COPILOT_CLI_TOOL_PAYLOAD = { 'sessionId': '826a14c1-cfb5-4946-9618-8b0bb7060466', 'timestamp': 1784038775604, @@ -98,7 +130,7 @@ def test_matches_payload_rejects_claude_code_payloads() -> None: def test_matches_payload_rejects_copilot_cli_payloads() -> None: - # CLI dialect is unsupported until its own parsing lands - must skip fail-open. + # Only reachable from a stale camelCase install; corrected by reinstalling hooks. assert Copilot().matches_payload(_COPILOT_CLI_TOOL_PAYLOAD) is False @@ -122,11 +154,25 @@ def test_parse_prompt_payload() -> None: 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_read_file_payload(fs: FakeFilesystem) -> None: + fs.create_file('/Users/user/.gitconfig') + # Agent-runtime naming (`Read` + `path`) must map identically to VS Code's + # (`read_file` + `filePath`): one hooks file serves both runtimes. + for payload in (_VSCODE_READ_FILE_PAYLOAD, _AGENT_READ_FILE_PAYLOAD): + unified = Copilot().parse_hook_payload(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_read_of_directory_is_not_a_file_read(fs: FakeFilesystem) -> None: + # The agent runtime reuses `Read` for directory listings with an identical + # payload shape, so only a stat separates them. + fs.create_dir('/Users/user/project') + payload = {**_AGENT_READ_FILE_PAYLOAD, 'tool_input': {'path': '/Users/user/project'}} + unified = Copilot().parse_hook_payload(payload) + assert unified.event_name == 'Read' + assert unified.file_path is None def test_parse_mcp_payload_without_known_servers_reports_raw(fs: FakeFilesystem) -> None: @@ -139,6 +185,33 @@ def test_parse_mcp_payload_without_known_servers_reports_raw(fs: FakeFilesystem) assert unified.mcp_arguments == {'user_id': 'dummy-user'} +def test_parse_agent_mcp_payload_uses_hyphenated_naming(fs: FakeFilesystem) -> None: + # The agent runtime names MCP tools `-` with no prefix, and + # declares its servers in its own config rather than VS Code's mcp.json. + fs.create_file( + Path.home() / '.copilot' / 'mcp-config.json', + contents=json.dumps({'mcpServers': {'gitlab': {'command': 'dummy-mcp'}}}), + ) + unified = Copilot().parse_hook_payload(_AGENT_MCP_PAYLOAD) + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'gitlab' + assert unified.mcp_tool_name == 'get_user' + assert unified.mcp_arguments == {'user_id': 'dummy-user'} + + +def test_parse_agent_mcp_payload_prefers_longest_hyphenated_server(fs: FakeFilesystem) -> None: + # Server names may themselves contain the separator, so the split must not be + # greedy on the first hyphen. + fs.create_file( + Path.home() / '.copilot' / 'mcp-config.json', + contents=json.dumps({'mcpServers': {'gitlab': {}, 'gitlab-selfhosted': {}}}), + ) + payload = {**_AGENT_MCP_PAYLOAD, 'tool_name': '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_mcp_payload_with_known_server_containing_underscores(fs: FakeFilesystem) -> None: fs.create_file( _vscode_mcp_config_path(), @@ -232,21 +305,23 @@ 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' + prompt_entry = rendered['hooks']['UserPromptSubmit'][0] + assert prompt_entry['command'] == 'cycode ai-guardrails scan --ide copilot' assert 'bash' not in prompt_entry - tool_entry = rendered['hooks']['preToolUse'][0] - assert tool_entry['command'] == 'cycode ai-guardrails scan --ide copilot --event PreToolUse' + tool_entry = rendered['hooks']['PreToolUse'][0] + assert tool_entry['command'] == 'cycode ai-guardrails scan --ide copilot' - session_entry = rendered['hooks']['sessionStart'][0] + 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('&') + tool_entry = rendered['hooks']['PreToolUse'][0] + # <&0 keeps the payload (a bare `cmd &` gets stdin from /dev/null and scans nothing); + # the stdout redirect releases the pipe the runner waits on, or it still blocks. + assert tool_entry['bash'].endswith('<&0 >/dev/null 2>&1 &') assert not tool_entry['powershell'].endswith('&') assert 'command' not in tool_entry diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index cf478fcc..1a7b7c2f 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -287,7 +287,7 @@ def test_copilot_dedicated_file_install_uninstall_lifecycle(fs: FakeFilesystem) assert success is True saved = json.loads(hooks_path.read_text()) assert saved['version'] == 1 - assert set(saved['hooks']) == {'sessionStart', 'userPromptSubmitted', 'preToolUse'} + assert set(saved['hooks']) == {'SessionStart', 'UserPromptSubmit', 'PreToolUse'} assert all(len(entries) == 1 for entries in saved['hooks'].values()) # Reinstall (also flipping mode) must replace, not duplicate. @@ -295,7 +295,7 @@ def test_copilot_dedicated_file_install_uninstall_lifecycle(fs: FakeFilesystem) 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('&') + assert saved['hooks']['PreToolUse'][0]['bash'].endswith('&') # Uninstall deletes the emptied dedicated file rather than leaving a husk. success, _ = uninstall_hooks(copilot)