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."""