Skip to content

Commit da20eba

Browse files
Ilanlidoclaude
andcommitted
CM-72834: Show the value hash of every guardrail finding
A blocked developer only saw severity counts, so a false positive was unactionable: nothing in the message identified which value tripped the guardrail. List each distinct finding's type and value hash instead, which is what `cycode ignore --by-sha` takes. The hash is safe to display; the value is not. Detections already dropped by an ignore rule are filtered out upstream in create_local_scan_result, so only what actually blocked is listed. The two hook messages that interpolated the summary mid-sentence now put it last, since it spans multiple lines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ccf15f2 commit da20eba

3 files changed

Lines changed: 93 additions & 4 deletions

File tree

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
7676
block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.PROMPT]
7777
if effective_mode == GuardrailsMode.BLOCK:
7878
outcome = AIHookOutcome.BLOCKED
79-
user_message = f'{violation_summary}. Remove secrets before sending.'
79+
# Summary last: it is multi-line, so it must not be interpolated mid-sentence
80+
user_message = f'Remove secrets before sending. {violation_summary}'
8081
return HookDecision.deny(AiHookEventType.PROMPT, user_message)
8182
outcome = AIHookOutcome.WARNED
8283
return HookDecision.allow(AiHookEventType.PROMPT)
@@ -283,7 +284,8 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
283284
event_type=AiHookEventType.MCP_EXECUTION,
284285
deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}',
285286
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?',
287+
# Summary last: it is multi-line, so it must not be interpolated mid-sentence
288+
ask_message=lambda v: f'Allow MCP tool call "{tool}"? {v}',
287289
ask_agent_message='Possible secrets detected in tool arguments; proceed with caution.',
288290
),
289291
scan_text=args_text,

‎cycode/cli/utils/scan_utils.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
from cycode.cli.models import LocalScanResult
1313
from cycode.cyclient.models import ScanConfiguration
1414

15+
# Keeps the hook message readable when a single file trips dozens of detections
16+
MAX_VIOLATION_DETAIL_LINES = 5
17+
1518

1619
def set_issue_detected(ctx: typer.Context, issue_detected: bool) -> None:
1720
ctx.obj['issue_detected'] = issue_detected
@@ -43,6 +46,33 @@ def generate_unique_scan_id() -> UUID:
4346
return uuid4()
4447

4548

49+
def _build_detection_lines(
50+
local_scan_results: list['LocalScanResult'], max_lines: int = MAX_VIOLATION_DETAIL_LINES
51+
) -> str:
52+
"""One line per distinct finding: what it is, and the value hash identifying it.
53+
54+
The value hash is safe to display; the value itself is not. Detections excluded by an existing
55+
ignore rule are already gone from `document_detections`, so only what actually blocked is listed.
56+
"""
57+
type_by_sha = {}
58+
for local_scan_result in local_scan_results:
59+
for document_detections in local_scan_result.document_detections:
60+
for detection in document_detections.detections:
61+
sha = detection.detection_details.get('sha512')
62+
if sha and sha not in type_by_sha:
63+
type_by_sha[sha] = detection.type or detection.message
64+
65+
if not type_by_sha:
66+
return ''
67+
68+
lines = [f' - {detection_type}: {sha}' for sha, detection_type in list(type_by_sha.items())[:max_lines]]
69+
remaining = len(type_by_sha) - len(lines)
70+
if remaining:
71+
lines.append(f' - ...and {remaining} more')
72+
73+
return '\n' + '\n'.join(lines)
74+
75+
4676
def build_violation_summary(local_scan_results: list['LocalScanResult']) -> str:
4777
"""Build violation summary string with severity breakdown and emojis."""
4878
detections_count = 0
@@ -61,4 +91,5 @@ def build_violation_summary(local_scan_results: list['LocalScanResult']) -> str:
6191
count = severity_counts[severity]
6292
severity_parts.append(f'{emoji} {severity.upper()} - {count}')
6393

64-
return f'Cycode found {detections_count} violations: {" | ".join(severity_parts)}'
94+
summary = f'Cycode found {detections_count} violations: {" | ".join(severity_parts)}'
95+
return summary + _build_detection_lines(local_scan_results)

‎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.models import Document, DocumentDetections, LocalScanResult
25+
from cycode.cli.utils.scan_utils import MAX_VIOLATION_DETAIL_LINES, build_violation_summary
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)