From 86e3da736d725acfb12fa09e69bbe2636c983938 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Sun, 16 Aug 2026 11:27:44 +0300 Subject: [PATCH 1/3] CM-71014: register Copilot hooks in PascalCase and support the agent tool vocabulary One hooks file is executed by more than one runtime: VS Code's chat runtime and the Copilot agent runtime (Copilot CLI, and VS Code agent sessions). The agent runtime keys its payload dialect off the CASE of the configured event key. Cycode registered camelCase, so it answered with its own dialect (sessionId, no hook_event_name), which matches_payload rejects - scans were skipped fail-open with nothing surfaced beyond a -v debug line. Confirmed against a customer capture where VS Code itself uses the agent runtime, so this was never CLI-only: their VS Code chat prompts were going unscanned. Register PascalCase event keys. Both runtimes then deliver the Claude-style dialect already parsed here, verified live on Copilot CLI 1.0.75 and VS Code 1.133, and against the customer's capture. The tool vocabulary still differs per runtime, so accept both rather than switch: VS Code reads files as read_file/filePath and names MCP tools mcp__; the agent runtime uses Read/path and -. Agent MCP servers are declared in ~/.copilot/mcp-config.json, so that is read alongside VS Code's mcp.json, and the server split is longest-match since server names may themselves contain the separator. The agent reuses its read tool for directory listings with an identical payload shape, so the path is stat-ed before it counts as a file read. Also drop the timestamp-absent condition from ClaudeCode.matches_payload. Matching on a field being absent is what broke Copilot scanning in the first place, and the documented transcript_path is a sufficient positive test on its own. Stale camelCase installs stay rejected; they are corrected by reinstalling hooks. Co-Authored-By: Claude Opus 5 (1M context) --- .../apps/ai_guardrails/ides/claude_code.py | 12 +- cycode/cli/apps/ai_guardrails/ides/copilot.py | 143 +++++++++++++----- .../ai_guardrails/ides/test_claude_code.py | 20 +-- .../ai_guardrails/ides/test_copilot.py | 97 ++++++++++-- .../ai_guardrails/test_hooks_manager.py | 4 +- 5 files changed, 199 insertions(+), 77 deletions(-) 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..90d825a6 100644 --- a/cycode/cli/apps/ai_guardrails/ides/copilot.py +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -1,17 +1,25 @@ -"""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``) provided the event keys are registered in PascalCase — the agent +runtime keys its dialect off the case of the key, and answers camelCase keys with +its own dialect (``sessionId``, no event name) that ``matches_payload`` rejects. +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 +45,39 @@ 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. The agent runtime keys its payload dialect off the CASE of +# these keys: PascalCase yields the Claude-style dialect parsed here, camelCase its +# own (`sessionId`, no `hook_event_name`), which matches_payload rejects. VS Code's +# own runtime normalizes either way, so PascalCase is correct for every routing. +_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; @@ -253,7 +280,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 +292,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 +316,56 @@ 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: + 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: @@ -334,9 +405,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_PROMPT_COMMAND)], + 'PreToolUse': [entry(_SCAN_TOOL_COMMAND)], }, } @@ -350,21 +421,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/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..8e0c6a48 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,20 +305,20 @@ 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] + prompt_entry = rendered['hooks']['UserPromptSubmit'][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] + tool_entry = rendered['hooks']['PreToolUse'][0] assert tool_entry['command'] == 'cycode ai-guardrails scan --ide copilot --event PreToolUse' - 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] + tool_entry = rendered['hooks']['PreToolUse'][0] assert tool_entry['bash'].endswith('&') 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) From 166df32d2559769b12ada5a3b3d267fb10454565 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Sun, 16 Aug 2026 13:13:32 +0300 Subject: [PATCH 2/3] CM-71014: make Copilot report mode actually async, and drop the --event flag Report mode was never asynchronous. The rendered command backgrounded the scan with `<&0 &`, but the backgrounded child inherits the hook's stdout and the runner reads that pipe until EOF, so the response waited for the scan anyway. Measured: the runner is released after 0s with the stdout redirect and after the full scan duration without it. `<&0` is still required - a bare `cmd &` has its stdin reattached to /dev/null, so the scan reads an empty payload and allows without scanning anything, which looks non-blocking only because it does nothing. Render `<&0 >/dev/null 2>&1 &` so the payload flows and the runner is released. Also stop passing --event and remove the flag. It was added when Copilot CLI payloads carried no event name, to avoid a reinstall when CLI support landed; registering the events in PascalCase means every runtime now self-describes via hook_event_name, so it never had a consumer beyond one debug field. NOTE: hooks installed before this change still pass --event, and the CLI now exits 2 on the unknown option. Copilot treats a non-zero PreToolUse exit as a denial of every tool call, so the MDM scripts must drop --event in the same rollout that ships this binary. Co-Authored-By: Claude Opus 5 (1M context) --- cycode/cli/apps/ai_guardrails/ides/copilot.py | 38 +++++++++---------- .../apps/ai_guardrails/scan/scan_command.py | 10 +---- .../ai_guardrails/ides/test_copilot.py | 8 ++-- 3 files changed, 23 insertions(+), 33 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/ides/copilot.py b/cycode/cli/apps/ai_guardrails/ides/copilot.py index 90d825a6..008d1a50 100644 --- a/cycode/cli/apps/ai_guardrails/ides/copilot.py +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -7,9 +7,8 @@ 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``) provided the event keys are registered in PascalCase — the agent -runtime keys its dialect off the case of the key, and answers camelCase keys with -its own dialect (``sessionId``, no event name) that ``matches_payload`` rejects. +``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. @@ -66,10 +65,7 @@ _MCP_TOOL_PREFIX = 'mcp_' _MCP_AGENT_SEPARATOR = '-' -# Hooks-file event keys. The agent runtime keys its payload dialect off the CASE of -# these keys: PascalCase yields the Claude-style dialect parsed here, camelCase its -# own (`sessionId`, no `hook_event_name`), which matches_payload rejects. VS Code's -# own runtime normalizes either way, so PascalCase is correct for every routing. +# Hooks-file event keys. Their case selects the agent runtime's payload dialect. _HOOK_EVENTS = ['UserPromptSubmit', 'PreToolUse'] _COPILOT_HOME_ENV_VAR = 'COPILOT_HOME' @@ -95,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' @@ -389,13 +381,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, } @@ -406,8 +402,8 @@ def entry(command: str) -> dict: 'version': 1, 'hooks': { 'SessionStart': [{'type': 'command', 'command': _SESSION_START_COMMAND}], - 'UserPromptSubmit': [entry(_SCAN_PROMPT_COMMAND)], - 'PreToolUse': [entry(_SCAN_TOOL_COMMAND)], + 'UserPromptSubmit': [entry(_SCAN_COMMAND)], + 'PreToolUse': [entry(_SCAN_COMMAND)], }, } 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_copilot.py b/tests/cli/commands/ai_guardrails/ides/test_copilot.py index 8e0c6a48..5d5f6d4f 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_copilot.py +++ b/tests/cli/commands/ai_guardrails/ides/test_copilot.py @@ -306,11 +306,11 @@ def test_render_hooks_config_sync_uses_cross_platform_command() -> None: assert rendered['version'] == 1 prompt_entry = rendered['hooks']['UserPromptSubmit'][0] - assert prompt_entry['command'] == 'cycode ai-guardrails scan --ide copilot --event UserPromptSubmit' + 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' + assert tool_entry['command'] == 'cycode ai-guardrails scan --ide copilot' session_entry = rendered['hooks']['SessionStart'][0] assert session_entry['command'] == 'cycode ai-guardrails session-start --ide copilot' @@ -319,7 +319,9 @@ def test_render_hooks_config_sync_uses_cross_platform_command() -> None: 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('&') + # <&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 From ef660b632ac9df6ddec60f011bf6851abb095ef5 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Mon, 17 Aug 2026 11:03:07 +0300 Subject: [PATCH 3/3] CM-71014: log the OSError when a read path cannot be stat-ed The stat guard swallowed OSError silently, so a file read that was skipped because its path could not be reached looked identical to one skipped for being a directory. Log it at debug with the path. Reachable on the older interpreters in the support matrix: Path.is_file() raises PermissionError on 3.9 and 3.11 (verified), while on 3.13+ it delegates to os.path.isfile and swallows the error itself. requires-python is >=3.9 and the Docker image and release builds run 3.9, so the branch is live for those installs and inert for the bundled executable. Co-Authored-By: Claude Opus 5 (1M context) --- cycode/cli/apps/ai_guardrails/ides/copilot.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cycode/cli/apps/ai_guardrails/ides/copilot.py b/cycode/cli/apps/ai_guardrails/ides/copilot.py index 008d1a50..5f3bc76a 100644 --- a/cycode/cli/apps/ai_guardrails/ides/copilot.py +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -326,7 +326,8 @@ def _read_file_path(tool_name: str, tool_input: object) -> Optional[str]: try: if not Path(raw_path).is_file(): return None - except OSError: + except OSError as e: + logger.debug('Failed to stat read path, %s', {'path': raw_path}, exc_info=e) return None return raw_path