Skip to content

Commit f18db6b

Browse files
Ilanlidoclaude
andauthored
CM-72834 show detection shas (#548)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ccf15f2 commit f18db6b

4 files changed

Lines changed: 119 additions & 29 deletions

File tree

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,14 @@
3030
AIHookOutcome,
3131
BlockReason,
3232
)
33-
from cycode.cli.apps.ai_guardrails.scan.utils import is_denied_path, truncate_utf8
33+
from cycode.cli.apps.ai_guardrails.scan.utils import build_violation_summary, is_denied_path, truncate_utf8
3434
from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func
3535
from cycode.cli.apps.scan.scan_parameters import get_scan_parameters
3636
from cycode.cli.cli_types import ScanTypeOption, SeverityOption
3737
from cycode.cli.files_collector.file_excluder import is_path_configured_in_exclusions
3838
from cycode.cli.models import Document
3939
from cycode.cli.utils.host_info import get_hostname, get_serial_number
4040
from cycode.cli.utils.progress_bar import DummyProgressBar, ScanProgressBarSection
41-
from cycode.cli.utils.scan_utils import build_violation_summary
4241
from cycode.logger import get_logger
4342

4443
logger = get_logger('AI Guardrails')
@@ -76,7 +75,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
7675
block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.PROMPT]
7776
if effective_mode == GuardrailsMode.BLOCK:
7877
outcome = AIHookOutcome.BLOCKED
79-
user_message = f'{violation_summary}. Remove secrets before sending.'
78+
user_message = f'Remove secrets before sending. {violation_summary}'
8079
return HookDecision.deny(AiHookEventType.PROMPT, user_message)
8180
outcome = AIHookOutcome.WARNED
8281
return HookDecision.allow(AiHookEventType.PROMPT)
@@ -283,7 +282,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
283282
event_type=AiHookEventType.MCP_EXECUTION,
284283
deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}',
285284
deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.',
286-
ask_message=lambda v: f'{v} in MCP tool call "{tool}". Allow execution?',
285+
ask_message=lambda v: f'Allow MCP tool call "{tool}"? {v}',
287286
ask_agent_message='Possible secrets detected in tool arguments; proceed with caution.',
288287
),
289288
scan_text=args_text,

‎cycode/cli/apps/ai_guardrails/scan/utils.py‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
"""
22
Utility functions for AI guardrails.
33
4-
Includes JSON parsing, path matching, and text handling utilities.
4+
Includes JSON parsing, path matching, text handling and hook-message utilities.
55
"""
66

77
import json
88
import os
99
import sys
10+
from collections import defaultdict
1011
from pathlib import Path
12+
from typing import TYPE_CHECKING
1113

1214
from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value
15+
from cycode.cli.cli_types import SeverityOption
16+
17+
if TYPE_CHECKING:
18+
from cycode.cli.models import LocalScanResult
19+
20+
# Keeps the hook message readable when a single file trips dozens of detections
21+
MAX_VIOLATION_DETAIL_LINES = 5
1322

1423

1524
def read_stdin_text() -> str:
@@ -87,3 +96,52 @@ def is_denied_path(file_path: str, policy: dict) -> bool:
8796
def output_json(obj: dict) -> None:
8897
"""Write JSON response to stdout (for IDE to read)."""
8998
print(json.dumps(obj), end='') # noqa: T201
99+
100+
101+
def _build_detection_lines(
102+
local_scan_results: list['LocalScanResult'], max_lines: int = MAX_VIOLATION_DETAIL_LINES
103+
) -> str:
104+
"""One line per distinct finding: what it is, and the value hash identifying it.
105+
106+
The value hash is safe to display; the value itself is not. Detections excluded by an existing
107+
ignore rule are already gone from `document_detections`, so only what actually blocked is listed.
108+
"""
109+
type_by_sha = {}
110+
for local_scan_result in local_scan_results:
111+
for document_detections in local_scan_result.document_detections:
112+
for detection in document_detections.detections:
113+
sha = detection.detection_details.get('sha512')
114+
if sha and sha not in type_by_sha:
115+
type_by_sha[sha] = detection.type or detection.message
116+
117+
if not type_by_sha:
118+
return ''
119+
120+
lines = [f' - {detection_type}: {sha}' for sha, detection_type in list(type_by_sha.items())[:max_lines]]
121+
remaining = len(type_by_sha) - len(lines)
122+
if remaining:
123+
lines.append(f' - ...and {remaining} more')
124+
125+
return '\n' + '\n'.join(lines)
126+
127+
128+
def build_violation_summary(local_scan_results: list['LocalScanResult']) -> str:
129+
"""Build violation summary string with severity breakdown and emojis."""
130+
detections_count = 0
131+
severity_counts = defaultdict(int)
132+
133+
for local_scan_result in local_scan_results:
134+
for document_detections in local_scan_result.document_detections:
135+
for detection in document_detections.detections:
136+
if detection.severity:
137+
detections_count += 1
138+
severity_counts[SeverityOption(detection.severity)] += 1
139+
140+
severity_parts = []
141+
for severity in reversed(SeverityOption):
142+
emoji = SeverityOption.get_member_unicode_emoji(severity)
143+
count = severity_counts[severity]
144+
severity_parts.append(f'{emoji} {severity.upper()} - {count}')
145+
146+
summary = f'Cycode found {detections_count} violations: {" | ".join(severity_parts)}'
147+
return summary + _build_detection_lines(local_scan_results)

‎cycode/cli/utils/scan_utils.py‎

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import os
2-
from collections import defaultdict
32
from typing import TYPE_CHECKING, Optional
43
from uuid import UUID, uuid4
54

65
import typer
76

87
from cycode.cli import consts
9-
from cycode.cli.cli_types import SeverityOption
108

119
if TYPE_CHECKING:
1210
from cycode.cli.models import LocalScanResult
@@ -41,24 +39,3 @@ def generate_unique_scan_id() -> UUID:
4139
return UUID(os.environ['PYTEST_TEST_UNIQUE_ID'])
4240

4341
return uuid4()
44-
45-
46-
def build_violation_summary(local_scan_results: list['LocalScanResult']) -> str:
47-
"""Build violation summary string with severity breakdown and emojis."""
48-
detections_count = 0
49-
severity_counts = defaultdict(int)
50-
51-
for local_scan_result in local_scan_results:
52-
for document_detections in local_scan_result.document_detections:
53-
for detection in document_detections.detections:
54-
if detection.severity:
55-
detections_count += 1
56-
severity_counts[SeverityOption(detection.severity)] += 1
57-
58-
severity_parts = []
59-
for severity in reversed(SeverityOption):
60-
emoji = SeverityOption.get_member_unicode_emoji(severity)
61-
count = severity_counts[severity]
62-
severity_parts.append(f'{emoji} {severity.upper()} - {count}')
63-
64-
return f'Cycode found {detections_count} violations: {" | ".join(severity_parts)}'

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

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
)
2222
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
2323
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason
24-
from cycode.cli.models import Document, LocalScanResult
24+
from cycode.cli.apps.ai_guardrails.scan.utils import MAX_VIOLATION_DETAIL_LINES, build_violation_summary
25+
from cycode.cli.models import Document, DocumentDetections, LocalScanResult
26+
from cycode.cyclient.models import Detection
2527

2628

2729
@pytest.fixture
@@ -420,6 +422,60 @@ def test_perform_scan_no_violation_when_all_detections_excluded(mock_ctx: MagicM
420422
assert scan_id == 'scan-id-123'
421423

422424

425+
def _local_scan_result_with_detections(*shas: str) -> LocalScanResult:
426+
document = Document(path='prompt-content.txt', content='some content', is_git_diff_format=False)
427+
detections = [
428+
Detection(
429+
detection_type_id='type-id',
430+
type='GitHub Token',
431+
message='Hardcoded secret',
432+
detection_details={'sha512': sha},
433+
detection_rule_id='rule-id',
434+
severity='High',
435+
)
436+
for sha in shas
437+
]
438+
return LocalScanResult(
439+
scan_id='scan-id-123',
440+
report_url=None,
441+
document_detections=[DocumentDetections(document=document, detections=detections)],
442+
issue_detected=True,
443+
detections_count=len(detections),
444+
relevant_detections_count=len(detections),
445+
)
446+
447+
448+
def test_violation_summary_lists_one_line_per_distinct_sha() -> None:
449+
"""A blocked developer needs the value hash to act on the finding (e.g. `cycode ignore --by-sha`)."""
450+
summary = build_violation_summary([_local_scan_result_with_detections('sha-aaa', 'sha-bbb', 'sha-aaa')])
451+
452+
assert 'Cycode found 3 violations' in summary
453+
assert 'GitHub Token: sha-aaa' in summary
454+
assert 'GitHub Token: sha-bbb' in summary
455+
# Repeated values collapse to one line; the hash identifies the value, not the occurrence
456+
assert summary.count('sha-aaa') == 1
457+
458+
459+
def test_violation_summary_caps_the_detection_lines() -> None:
460+
"""A file full of detections must not turn the hook message into a wall of text."""
461+
shas = [f'sha-{index}' for index in range(MAX_VIOLATION_DETAIL_LINES + 3)]
462+
463+
summary = build_violation_summary([_local_scan_result_with_detections(*shas)])
464+
465+
assert summary.count('GitHub Token: ') == MAX_VIOLATION_DETAIL_LINES
466+
assert '...and 3 more' in summary
467+
468+
469+
def test_violation_summary_omits_lines_without_a_sha() -> None:
470+
"""Non-secret scan types carry no value hash, so there is nothing to list."""
471+
local_scan_result = _local_scan_result_with_detections('sha-aaa')
472+
local_scan_result.document_detections[0].detections[0].detection_details = {}
473+
474+
summary = build_violation_summary([local_scan_result])
475+
476+
assert 'GitHub Token' not in summary
477+
478+
423479
# Tests for handle_before_mcp_execution
424480

425481

0 commit comments

Comments
 (0)