Skip to content

Commit bf2d31f

Browse files
Altruistusclaude
andauthored
CM-71606: Mint and report a hook event id per guardrail hook (#526)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 435afef commit bf2d31f

6 files changed

Lines changed: 85 additions & 1 deletion

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ def build_ai_guardrails_scan_parameters(
334334
'device_hostname': get_hostname(),
335335
'conversation_id': payload.conversation_id,
336336
'generation_id': payload.generation_id,
337+
'hook_event_id': payload.hook_event_id,
337338
'ide_user_email': payload.ide_user_email,
338339
'mcp_server_name': payload.mcp_server_name,
339340
'mcp_tool_name': payload.mcp_tool_name,

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
respective IDE class.
66
"""
77

8-
from dataclasses import dataclass
8+
import uuid
9+
from dataclasses import dataclass, field
910
from typing import Optional
1011

1112

@@ -18,6 +19,11 @@ class AIHookPayload:
1819
conversation_id: Optional[str] = None
1920
generation_id: Optional[str] = None
2021

22+
# Minted here rather than by the server: the guardrail scan and the hook event are reported in two
23+
# separate requests, and both have to name the same event. A generation id can't stand in for it - the
24+
# IDE mints one per prompt, so several hook events share it, and some IDEs don't supply one at all.
25+
hook_event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
26+
2127
# User and IDE information
2228
ide_user_email: Optional[str] = None
2329
model: Optional[str] = None

cycode/cyclient/ai_security_manager_client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def create_event(
7373
return
7474

7575
body = {
76+
'id': payload.hook_event_id,
7677
'conversation_id': conversation_id,
7778
'event_type': event_type,
7879
'outcome': outcome,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,8 @@ def test_build_ai_guardrails_scan_parameters(
557557
'device_hostname': 'test-host',
558558
'conversation_id': 'test-conv-id',
559559
'generation_id': 'test-gen-id',
560+
# The same id the hook event is reported under, so the detection can point at that one event
561+
'hook_event_id': mock_payload.hook_event_id,
560562
'ide_user_email': 'test@example.com',
561563
'mcp_server_name': None,
562564
'mcp_tool_name': None,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from uuid import UUID
2+
3+
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
4+
5+
6+
def test_hook_event_id_is_a_uuid() -> None:
7+
payload = AIHookPayload(event_name='Prompt')
8+
9+
# Round-trips through UUID, so ai-security-manager can store it as the hook event's primary key
10+
assert UUID(payload.hook_event_id)
11+
12+
13+
def test_hook_event_id_is_unique_per_payload() -> None:
14+
"""One payload is one hook event. Sharing an id across events is what generation ids already do wrong."""
15+
first = AIHookPayload(event_name='Prompt', generation_id='same-gen')
16+
second = AIHookPayload(event_name='Prompt', generation_id='same-gen')
17+
18+
assert first.hook_event_id != second.hook_event_id
19+
20+
21+
def test_hook_event_id_survives_an_explicit_value() -> None:
22+
payload = AIHookPayload(event_name='Prompt', hook_event_id='fixed-id')
23+
24+
assert payload.hook_event_id == 'fixed-id'
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from unittest.mock import MagicMock
2+
3+
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
4+
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome
5+
from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient
6+
7+
8+
def _build_client() -> tuple[AISecurityManagerClient, MagicMock]:
9+
http_client = MagicMock()
10+
service_config = MagicMock()
11+
service_config.get_service_name.return_value = None
12+
13+
return AISecurityManagerClient(http_client, service_config), http_client
14+
15+
16+
def _posted_body(http_client: MagicMock) -> dict:
17+
return http_client.post.call_args.kwargs['body']
18+
19+
20+
def test_create_event_reports_the_payload_hook_event_id_as_the_event_id() -> None:
21+
"""The CLI owns the id so the guardrail detection, reported separately, can name this exact event."""
22+
client, http_client = _build_client()
23+
payload = AIHookPayload(event_name='Prompt', conversation_id='conv-1', generation_id='gen-1')
24+
25+
client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED)
26+
27+
assert _posted_body(http_client)['id'] == payload.hook_event_id
28+
29+
30+
def test_create_event_reports_a_distinct_id_per_hook_event() -> None:
31+
client, http_client = _build_client()
32+
conversation_id = 'conv-1'
33+
# Two hooks of the same prompt: the generation id is shared, the hook event id must not be
34+
first = AIHookPayload(event_name='Prompt', conversation_id=conversation_id, generation_id='gen-1')
35+
second = AIHookPayload(event_name='FileRead', conversation_id=conversation_id, generation_id='gen-1')
36+
37+
client.create_event(first, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED)
38+
client.create_event(second, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED)
39+
40+
reported_ids = [call.kwargs['body']['id'] for call in http_client.post.call_args_list]
41+
assert reported_ids == [first.hook_event_id, second.hook_event_id]
42+
assert len(set(reported_ids)) == 2
43+
44+
45+
def test_create_event_without_a_conversation_posts_nothing() -> None:
46+
client, http_client = _build_client()
47+
48+
client.create_event(AIHookPayload(event_name='Prompt'), AiHookEventType.PROMPT, AIHookOutcome.ALLOWED)
49+
50+
http_client.post.assert_not_called()

0 commit comments

Comments
 (0)