From 24b761029f68e8329a46c490b49bfd75cc1adb15 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Thu, 13 Aug 2026 15:16:00 +0300 Subject: [PATCH] CM-70843: fix Copilot hook payloads being skipped once VS Code sends transcript_path VS Code Copilot began attaching a per-session transcript_path to hook payloads once a workspace has chat history. matches_payload required that field to be absent, so every Copilot scan was skipped fail-open, with nothing surfaced to the user beyond a -v debug line. Discriminate on the top-level timestamp instead: Copilot always sends one and Claude Code never does, verified against all 29 hook event constructions in Claude Code 2.1.231, where transcript_path is unconditional. Also require timestamp to be absent in claude_code.matches_payload. VS Code executes Claude-registered hooks, so a Copilot payload can reach --ide claude-code, and transcript_path alone no longer tells the two dialects apart. Co-Authored-By: Claude Opus 5 (1M context) --- .../apps/ai_guardrails/ides/claude_code.py | 10 +++++++--- cycode/cli/apps/ai_guardrails/ides/copilot.py | 16 ++++++--------- .../ai_guardrails/ides/test_claude_code.py | 20 +++++++++++++++++-- .../ai_guardrails/ides/test_copilot.py | 11 +++++++++- 4 files changed, 41 insertions(+), 16 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 1b3f618b..79ba9e8f 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -284,9 +284,13 @@ 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) 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 + # 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 + ) 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 2cd6a427..cfb20f0a 100644 --- a/cycode/cli/apps/ai_guardrails/ides/copilot.py +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -7,8 +7,9 @@ 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 +``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. """ @@ -340,14 +341,9 @@ def entry(command: str) -> dict: } 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 - ) + # Structural discrimination, no magic strings: Copilot events carry a top-level + # timestamp, Claude Code events never do. + return raw_payload.get('hook_event_name', '') in _COPILOT_SCAN_EVENT_NAMES and 'timestamp' in raw_payload def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: hook_event_name = raw_payload.get('hook_event_name', '') 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 4dcab376..657a7b5c 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -28,8 +28,9 @@ 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, - but never a transcript_path — those events must not be claimed as Claude Code.""" + """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.""" claude = ClaudeCode() assert ( claude.matches_payload( @@ -48,6 +49,21 @@ 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 8141f74f..c4aec1e1 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_copilot.py +++ b/tests/cli/commands/ai_guardrails/ides/test_copilot.py @@ -27,6 +27,13 @@ 'prompt': 'test prompt', } +# VS Code attaches a per-session transcript_path once the workspace has chat history. +_VSCODE_PROMPT_PAYLOAD_WITH_TRANSCRIPT = { + **_VSCODE_PROMPT_PAYLOAD, + 'cwd': '/Users/user/project', + 'transcript_path': '/Users/user/Library/Application Support/Code/User/workspaceStorage/dummy/t.jsonl', +} + _VSCODE_READ_FILE_PAYLOAD = { 'timestamp': '2026-07-14T13:35:08.758Z', 'hook_event_name': 'PreToolUse', @@ -81,10 +88,12 @@ def test_matches_payload_accepts_vscode_events() -> None: 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 + assert copilot.matches_payload(_VSCODE_PROMPT_PAYLOAD_WITH_TRANSCRIPT) is True def test_matches_payload_rejects_claude_code_payloads() -> None: - # Same event names and dialect, but Claude Code always carries transcript_path. + # Same event names and dialect, and both carry transcript_path - only the + # top-level timestamp separates them, and Claude Code never sends one. assert Copilot().matches_payload(_CLAUDE_CODE_PAYLOAD) is False