Skip to content

Commit 6e68f14

Browse files
Ilanlidoclaude
andcommitted
CM-65504: Skip synthetic task-notification prompts in Claude Code guardrails scan
Fork/subagent completions are injected into the parent session as synthetic <task-notification> user turns, which fire UserPromptSubmit and were scanned (and reported to telemetry) as if the user typed them. Skip them before payload parsing, policy load, and client init. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2d82352 commit 6e68f14

6 files changed

Lines changed: 90 additions & 0 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,15 @@ def matches_payload(self, raw_payload: dict) -> bool:
157157
event (e.g. Cursor reading Claude Code hooks from ~/.claude/settings.json).
158158
"""
159159

160+
def is_synthetic_prompt(self, raw_payload: dict) -> bool:
161+
"""Return True when a prompt event carries IDE/harness-generated content
162+
rather than text the user typed.
163+
164+
Synthetic prompts are skipped without scanning or telemetry.
165+
Default: False. Override for IDEs that inject synthetic user turns.
166+
"""
167+
return False
168+
160169
@abstractmethod
161170
def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
162171
"""Normalize a raw stdin payload into the canonical ``AIHookPayload``."""

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222

2323
_CLAUDE_CODE_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'})
2424

25+
# When a fork/subagent completes, the harness injects its result into the parent
26+
# session as a synthetic user turn, which fires UserPromptSubmit.
27+
_SYNTHETIC_PROMPT_PREFIXES = ('<task-notification>',)
28+
2529
_USER_HOOKS_DIR = Path.home() / '.claude'
2630
_HOOKS_FILE_NAME = 'settings.json'
2731
_REPO_SUBDIR = '.claude'
@@ -284,6 +288,12 @@ def matches_payload(self, raw_payload: dict) -> bool:
284288
# processed as Claude Code events.
285289
return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES and 'transcript_path' in raw_payload
286290

291+
def is_synthetic_prompt(self, raw_payload: dict) -> bool:
292+
if raw_payload.get('hook_event_name') != 'UserPromptSubmit':
293+
return False
294+
prompt = raw_payload.get('prompt') or ''
295+
return prompt.lstrip().startswith(_SYNTHETIC_PROMPT_PREFIXES)
296+
287297
def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
288298
hook_event_name = raw_payload.get('hook_event_name', '')
289299
tool_name = raw_payload.get('tool_name', '')

cycode/cli/apps/ai_guardrails/scan/scan_command.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@ def scan_command(
116116
output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)))
117117
return
118118

119+
# Fork/subagent completions arrive as synthetic user turns (e.g. Claude Code's
120+
# <task-notification>); they are agent-generated, not user prompts - skip before
121+
# parse_hook_payload, which reads the transcript and IDE config from disk.
122+
if ide_integration.is_synthetic_prompt(payload):
123+
logger.debug('Synthetic prompt detected, skipping scan')
124+
output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)))
125+
return
126+
119127
unified_payload = ide_integration.parse_hook_payload(payload)
120128
event_name = unified_payload.event_name
121129
logger.debug(

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,37 @@ def test_matches_payload_rejects_vscode_copilot_payloads() -> None:
5050
)
5151

5252

53+
def test_is_synthetic_prompt_task_notification() -> None:
54+
claude = ClaudeCode()
55+
payload = {
56+
'hook_event_name': 'UserPromptSubmit',
57+
'session_id': 'session-123',
58+
'prompt': '<task-notification>Task dummy-task-1 completed</task-notification>',
59+
}
60+
assert claude.is_synthetic_prompt(payload) is True
61+
62+
payload['prompt'] = ' \n<task-notification>Task dummy-task-2 completed</task-notification>'
63+
assert claude.is_synthetic_prompt(payload) is True
64+
65+
66+
def test_is_synthetic_prompt_regular_prompt() -> None:
67+
claude = ClaudeCode()
68+
assert claude.is_synthetic_prompt({'hook_event_name': 'UserPromptSubmit', 'prompt': 'Test prompt'}) is False
69+
assert claude.is_synthetic_prompt({'hook_event_name': 'UserPromptSubmit', 'prompt': ''}) is False
70+
assert claude.is_synthetic_prompt({'hook_event_name': 'UserPromptSubmit'}) is False
71+
72+
73+
def test_is_synthetic_prompt_ignores_tool_events() -> None:
74+
claude = ClaudeCode()
75+
payload = {
76+
'hook_event_name': 'PreToolUse',
77+
'tool_name': 'Read',
78+
'tool_input': {'file_path': '/path/to/file'},
79+
'prompt': '<task-notification>not a prompt event</task-notification>',
80+
}
81+
assert claude.is_synthetic_prompt(payload) is False
82+
83+
5384
def test_parse_prompt_payload() -> None:
5485
unified = ClaudeCode().parse_hook_payload(
5586
{

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ def test_matches_payload_rejects_unrelated_event_names(ide: IDE) -> None:
8282
assert ide.matches_payload({'hook_event_name': 'completely-fabricated-event'}) is False
8383

8484

85+
def test_is_synthetic_prompt_rejects_empty(ide: IDE) -> None:
86+
"""The safe default: no payload is ever treated as synthetic unless an IDE opts in."""
87+
assert ide.is_synthetic_prompt({}) is False
88+
89+
8590
@pytest.mark.parametrize('event_type', list(AiHookEventType))
8691
def test_build_hook_response_allow_returns_dict(ide: IDE, event_type: AiHookEventType) -> None:
8792
"""ALLOW for every canonical event type yields a serializable dict."""

tests/cli/commands/ai_guardrails/scan/test_scan_command.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,33 @@ def test_cursor_payload_with_claude_code_ide(
8181
assert response == {} # Claude Code allow_prompt returns empty dict
8282

8383

84+
class TestSyntheticPromptSkipsProcessing:
85+
"""Tests that verify synthetic (harness-generated) prompts cause early exit without API calls."""
86+
87+
def test_task_notification_prompt_skipped(
88+
self,
89+
mock_ctx: MagicMock,
90+
mocker: MockerFixture,
91+
capsys: pytest.CaptureFixture[str],
92+
mock_scan_command_deps: dict[str, MagicMock],
93+
) -> None:
94+
"""Fork/subagent completions arrive as synthetic <task-notification> user turns
95+
that fire UserPromptSubmit in the parent session; they must not be scanned."""
96+
payload = {
97+
'hook_event_name': 'UserPromptSubmit',
98+
'session_id': 'session-123',
99+
'transcript_path': '/home/user/.claude/projects/transcript.jsonl',
100+
'prompt': '<task-notification>Task dummy-task-1 completed</task-notification>',
101+
}
102+
mocker.patch('sys.stdin', StringIO(json.dumps(payload)))
103+
104+
scan_command(mock_ctx, ide='claude-code')
105+
106+
_assert_no_api_calls(mock_scan_command_deps)
107+
response = json.loads(capsys.readouterr().out)
108+
assert response == {} # Claude Code allow_prompt returns empty dict
109+
110+
84111
class TestInvalidPayloadSkipsProcessing:
85112
"""Tests that verify invalid payloads cause early exit without API calls."""
86113

0 commit comments

Comments
 (0)