From 6e68f14bbc4b07ae9a4a8a4a15ff7f4ebef6cc43 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Wed, 22 Jul 2026 15:19:05 +0300 Subject: [PATCH] CM-65504: Skip synthetic task-notification prompts in Claude Code guardrails scan Fork/subagent completions are injected into the parent session as synthetic 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 --- cycode/cli/apps/ai_guardrails/ides/base.py | 9 ++++++ .../apps/ai_guardrails/ides/claude_code.py | 10 ++++++ .../apps/ai_guardrails/scan/scan_command.py | 8 +++++ .../ai_guardrails/ides/test_claude_code.py | 31 +++++++++++++++++++ .../ai_guardrails/ides/test_contract.py | 5 +++ .../ai_guardrails/scan/test_scan_command.py | 27 ++++++++++++++++ 6 files changed, 90 insertions(+) diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py index 84e4315f..29b4b200 100644 --- a/cycode/cli/apps/ai_guardrails/ides/base.py +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -157,6 +157,15 @@ def matches_payload(self, raw_payload: dict) -> bool: event (e.g. Cursor reading Claude Code hooks from ~/.claude/settings.json). """ + def is_synthetic_prompt(self, raw_payload: dict) -> bool: + """Return True when a prompt event carries IDE/harness-generated content + rather than text the user typed. + + Synthetic prompts are skipped without scanning or telemetry. + Default: False. Override for IDEs that inject synthetic user turns. + """ + return False + @abstractmethod def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: """Normalize a raw stdin payload into the canonical ``AIHookPayload``.""" diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index f48794ef..1b3f618b 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -22,6 +22,10 @@ _CLAUDE_CODE_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'}) +# When a fork/subagent completes, the harness injects its result into the parent +# session as a synthetic user turn, which fires UserPromptSubmit. +_SYNTHETIC_PROMPT_PREFIXES = ('',) + _USER_HOOKS_DIR = Path.home() / '.claude' _HOOKS_FILE_NAME = 'settings.json' _REPO_SUBDIR = '.claude' @@ -284,6 +288,12 @@ def matches_payload(self, raw_payload: dict) -> bool: # processed as Claude Code events. 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': + return False + prompt = raw_payload.get('prompt') or '' + return prompt.lstrip().startswith(_SYNTHETIC_PROMPT_PREFIXES) + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: hook_event_name = raw_payload.get('hook_event_name', '') tool_name = raw_payload.get('tool_name', '') diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index cad92263..1a389d5e 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -116,6 +116,14 @@ def scan_command( output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) return + # Fork/subagent completions arrive as synthetic user turns (e.g. Claude Code's + # ); they are agent-generated, not user prompts - skip before + # parse_hook_payload, which reads the transcript and IDE config from disk. + if ide_integration.is_synthetic_prompt(payload): + logger.debug('Synthetic prompt detected, skipping scan') + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) + return + unified_payload = ide_integration.parse_hook_payload(payload) event_name = unified_payload.event_name logger.debug( 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 60fb331e..4dcab376 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -50,6 +50,37 @@ def test_matches_payload_rejects_vscode_copilot_payloads() -> None: ) +def test_is_synthetic_prompt_task_notification() -> None: + claude = ClaudeCode() + payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'Task dummy-task-1 completed', + } + assert claude.is_synthetic_prompt(payload) is True + + payload['prompt'] = ' \nTask dummy-task-2 completed' + assert claude.is_synthetic_prompt(payload) is True + + +def test_is_synthetic_prompt_regular_prompt() -> None: + claude = ClaudeCode() + assert claude.is_synthetic_prompt({'hook_event_name': 'UserPromptSubmit', 'prompt': 'Test prompt'}) is False + assert claude.is_synthetic_prompt({'hook_event_name': 'UserPromptSubmit', 'prompt': ''}) is False + assert claude.is_synthetic_prompt({'hook_event_name': 'UserPromptSubmit'}) is False + + +def test_is_synthetic_prompt_ignores_tool_events() -> None: + claude = ClaudeCode() + payload = { + 'hook_event_name': 'PreToolUse', + 'tool_name': 'Read', + 'tool_input': {'file_path': '/path/to/file'}, + 'prompt': 'not a prompt event', + } + assert claude.is_synthetic_prompt(payload) is False + + def test_parse_prompt_payload() -> None: unified = ClaudeCode().parse_hook_payload( { diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py index 7d7ab773..0984c97a 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_contract.py +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -82,6 +82,11 @@ def test_matches_payload_rejects_unrelated_event_names(ide: IDE) -> None: assert ide.matches_payload({'hook_event_name': 'completely-fabricated-event'}) is False +def test_is_synthetic_prompt_rejects_empty(ide: IDE) -> None: + """The safe default: no payload is ever treated as synthetic unless an IDE opts in.""" + assert ide.is_synthetic_prompt({}) is False + + @pytest.mark.parametrize('event_type', list(AiHookEventType)) def test_build_hook_response_allow_returns_dict(ide: IDE, event_type: AiHookEventType) -> None: """ALLOW for every canonical event type yields a serializable dict.""" diff --git a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py index 349a1ee3..8b7b611e 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -81,6 +81,33 @@ def test_cursor_payload_with_claude_code_ide( assert response == {} # Claude Code allow_prompt returns empty dict +class TestSyntheticPromptSkipsProcessing: + """Tests that verify synthetic (harness-generated) prompts cause early exit without API calls.""" + + def test_task_notification_prompt_skipped( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Fork/subagent completions arrive as synthetic user turns + that fire UserPromptSubmit in the parent session; they must not be scanned.""" + payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'transcript_path': '/home/user/.claude/projects/transcript.jsonl', + 'prompt': 'Task dummy-task-1 completed', + } + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + scan_command(mock_ctx, ide='claude-code') + + _assert_no_api_calls(mock_scan_command_deps) + response = json.loads(capsys.readouterr().out) + assert response == {} # Claude Code allow_prompt returns empty dict + + class TestInvalidPayloadSkipsProcessing: """Tests that verify invalid payloads cause early exit without API calls."""