Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions cycode/cli/apps/ai_guardrails/ides/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
16 changes: 6 additions & 10 deletions cycode/cli/apps/ai_guardrails/ides/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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', '')
Expand Down
20 changes: 18 additions & 2 deletions tests/cli/commands/ai_guardrails/ides/test_claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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:
Expand Down
11 changes: 10 additions & 1 deletion tests/cli/commands/ai_guardrails/ides/test_copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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


Expand Down