Skip to content

Commit 1e64043

Browse files
Ilanlidoclaude
andauthored
CM-70843: fix Copilot hook payloads being skipped once VS Code sends transcript_path (#519)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 26a4bb2 commit 1e64043

4 files changed

Lines changed: 41 additions & 16 deletions

File tree

cycode/cli/apps/ai_guardrails/ides/claude_code.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,9 +284,13 @@ def render_hooks_config(self, async_mode: bool = False) -> dict:
284284
def matches_payload(self, raw_payload: dict) -> bool:
285285
# transcript_path is a documented Claude Code common field, present on every
286286
# hook event. VS Code Copilot emits near-identical payloads (same event names,
287-
# snake_case fields) without it — requiring it keeps those from being
288-
# processed as Claude Code events.
289-
return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES and 'transcript_path' in raw_payload
287+
# snake_case fields) — Copilot additionally carries a top-level
288+
# timestamp, which Claude Code never sends.
289+
return (
290+
raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES
291+
and 'transcript_path' in raw_payload
292+
and 'timestamp' not in raw_payload
293+
)
290294

291295
def is_synthetic_prompt(self, raw_payload: dict) -> bool:
292296
if raw_payload.get('hook_event_name') != 'UserPromptSubmit':

cycode/cli/apps/ai_guardrails/ides/copilot.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
are rejected by ``matches_payload`` and fall through to the allow-and-skip path.
88
99
VS Code sends Claude-style payloads (``hook_event_name``, ``tool_name``,
10-
``tool_input``) with structural differences that ``matches_payload`` keys on:
11-
a top-level ISO ``timestamp`` and no ``transcript_path``. Copilot hooks have no
10+
``tool_input``), told apart by the one field Claude Code never sends: a top-level
11+
ISO ``timestamp``. VS Code also sends a ``transcript_path`` of its own once a
12+
workspace has chat history, so that field cannot discriminate. Copilot hooks have no
1213
matchers, so ``preToolUse`` fires for every tool; tools we don't scan pass
1314
through as raw event names, which match no handler and allow immediately.
1415
"""
@@ -340,14 +341,9 @@ def entry(command: str) -> dict:
340341
}
341342

342343
def matches_payload(self, raw_payload: dict) -> bool:
343-
# Structural discrimination, no magic strings: VS Code Copilot events carry
344-
# a top-level ISO timestamp and no transcript_path; real Claude Code events
345-
# always carry transcript_path; Copilot CLI payloads have no hook_event_name.
346-
return (
347-
raw_payload.get('hook_event_name', '') in _COPILOT_SCAN_EVENT_NAMES
348-
and 'timestamp' in raw_payload
349-
and 'transcript_path' not in raw_payload
350-
)
344+
# Structural discrimination, no magic strings: Copilot events carry a top-level
345+
# timestamp, Claude Code events never do.
346+
return raw_payload.get('hook_event_name', '') in _COPILOT_SCAN_EVENT_NAMES and 'timestamp' in raw_payload
351347

352348
def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
353349
hook_event_name = raw_payload.get('hook_event_name', '')

tests/cli/commands/ai_guardrails/ides/test_claude_code.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ def test_matches_payload_only_claude_events() -> None:
2828

2929

3030
def test_matches_payload_rejects_vscode_copilot_payloads() -> None:
31-
"""VS Code Copilot sends the same event names in the same snake_case dialect,
32-
but never a transcript_path — those events must not be claimed as Claude Code."""
31+
"""VS Code Copilot sends the same event names in the same snake_case dialect, and
32+
now a transcript_path of its own — only the top-level timestamp, which Claude Code
33+
never sends, keeps those events from being claimed as Claude Code."""
3334
claude = ClaudeCode()
3435
assert (
3536
claude.matches_payload(
@@ -48,6 +49,21 @@ def test_matches_payload_rejects_vscode_copilot_payloads() -> None:
4849
claude.matches_payload({'timestamp': '2026-07-14T13:32:46.517Z', 'hook_event_name': 'UserPromptSubmit'})
4950
is False
5051
)
52+
# Carrying a transcript_path must not be enough to claim a Copilot event, or the
53+
# same prompt gets processed twice when both integrations are installed.
54+
assert (
55+
claude.matches_payload(
56+
{
57+
'timestamp': '2026-08-13T10:55:29.000Z',
58+
'hook_event_name': 'UserPromptSubmit',
59+
'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2',
60+
'cwd': '/Users/user/project',
61+
'prompt': 'test prompt',
62+
'transcript_path': '/Users/user/Library/Application Support/Code/User/workspaceStorage/d/t.jsonl',
63+
}
64+
)
65+
is False
66+
)
5167

5268

5369
def test_is_synthetic_prompt_task_notification() -> None:

tests/cli/commands/ai_guardrails/ides/test_copilot.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@
2727
'prompt': 'test prompt',
2828
}
2929

30+
# VS Code attaches a per-session transcript_path once the workspace has chat history.
31+
_VSCODE_PROMPT_PAYLOAD_WITH_TRANSCRIPT = {
32+
**_VSCODE_PROMPT_PAYLOAD,
33+
'cwd': '/Users/user/project',
34+
'transcript_path': '/Users/user/Library/Application Support/Code/User/workspaceStorage/dummy/t.jsonl',
35+
}
36+
3037
_VSCODE_READ_FILE_PAYLOAD = {
3138
'timestamp': '2026-07-14T13:35:08.758Z',
3239
'hook_event_name': 'PreToolUse',
@@ -81,10 +88,12 @@ def test_matches_payload_accepts_vscode_events() -> None:
8188
assert copilot.matches_payload(_VSCODE_PROMPT_PAYLOAD) is True
8289
assert copilot.matches_payload(_VSCODE_READ_FILE_PAYLOAD) is True
8390
assert copilot.matches_payload(_VSCODE_MCP_PAYLOAD) is True
91+
assert copilot.matches_payload(_VSCODE_PROMPT_PAYLOAD_WITH_TRANSCRIPT) is True
8492

8593

8694
def test_matches_payload_rejects_claude_code_payloads() -> None:
87-
# Same event names and dialect, but Claude Code always carries transcript_path.
95+
# Same event names and dialect, and both carry transcript_path - only the
96+
# top-level timestamp separates them, and Claude Code never sends one.
8897
assert Copilot().matches_payload(_CLAUDE_CODE_PAYLOAD) is False
8998

9099

0 commit comments

Comments
 (0)