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
9 changes: 9 additions & 0 deletions cycode/cli/apps/ai_guardrails/ides/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``."""
Expand Down
10 changes: 10 additions & 0 deletions cycode/cli/apps/ai_guardrails/ides/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ('<task-notification>',)

_USER_HOOKS_DIR = Path.home() / '.claude'
_HOOKS_FILE_NAME = 'settings.json'
_REPO_SUBDIR = '.claude'
Expand Down Expand Up @@ -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', '')
Expand Down
8 changes: 8 additions & 0 deletions cycode/cli/apps/ai_guardrails/scan/scan_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# <task-notification>); 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(
Expand Down
31 changes: 31 additions & 0 deletions tests/cli/commands/ai_guardrails/ides/test_claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-notification>Task dummy-task-1 completed</task-notification>',
}
assert claude.is_synthetic_prompt(payload) is True

payload['prompt'] = ' \n<task-notification>Task dummy-task-2 completed</task-notification>'
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': '<task-notification>not a prompt event</task-notification>',
}
assert claude.is_synthetic_prompt(payload) is False


def test_parse_prompt_payload() -> None:
unified = ClaudeCode().parse_hook_payload(
{
Expand Down
5 changes: 5 additions & 0 deletions tests/cli/commands/ai_guardrails/ides/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
27 changes: 27 additions & 0 deletions tests/cli/commands/ai_guardrails/scan/test_scan_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <task-notification> 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-notification>Task dummy-task-1 completed</task-notification>',
}
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."""

Expand Down