From da20eba6c8988aac1a59b3705271bb40fcbe3fc6 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Wed, 16 Sep 2026 15:11:03 +0300 Subject: [PATCH 1/2] 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) --- .../cli/apps/ai_guardrails/scan/handlers.py | 6 +- cycode/cli/utils/scan_utils.py | 33 ++++++++++- .../ai_guardrails/scan/test_handlers.py | 58 ++++++++++++++++++- 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 469fc489..38293625 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -76,7 +76,8 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.PROMPT] if effective_mode == GuardrailsMode.BLOCK: outcome = AIHookOutcome.BLOCKED - user_message = f'{violation_summary}. Remove secrets before sending.' + # Summary last: it is multi-line, so it must not be interpolated mid-sentence + user_message = f'Remove secrets before sending. {violation_summary}' return HookDecision.deny(AiHookEventType.PROMPT, user_message) outcome = AIHookOutcome.WARNED return HookDecision.allow(AiHookEventType.PROMPT) @@ -283,7 +284,8 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli event_type=AiHookEventType.MCP_EXECUTION, deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}', deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.', - ask_message=lambda v: f'{v} in MCP tool call "{tool}". Allow execution?', + # Summary last: it is multi-line, so it must not be interpolated mid-sentence + ask_message=lambda v: f'Allow MCP tool call "{tool}"? {v}', ask_agent_message='Possible secrets detected in tool arguments; proceed with caution.', ), scan_text=args_text, diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index 819a4116..75dec022 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -12,6 +12,9 @@ from cycode.cli.models import LocalScanResult from cycode.cyclient.models import ScanConfiguration +# Keeps the hook message readable when a single file trips dozens of detections +MAX_VIOLATION_DETAIL_LINES = 5 + def set_issue_detected(ctx: typer.Context, issue_detected: bool) -> None: ctx.obj['issue_detected'] = issue_detected @@ -43,6 +46,33 @@ def generate_unique_scan_id() -> UUID: return uuid4() +def _build_detection_lines( + local_scan_results: list['LocalScanResult'], max_lines: int = MAX_VIOLATION_DETAIL_LINES +) -> str: + """One line per distinct finding: what it is, and the value hash identifying it. + + The value hash is safe to display; the value itself is not. Detections excluded by an existing + ignore rule are already gone from `document_detections`, so only what actually blocked is listed. + """ + type_by_sha = {} + for local_scan_result in local_scan_results: + for document_detections in local_scan_result.document_detections: + for detection in document_detections.detections: + sha = detection.detection_details.get('sha512') + if sha and sha not in type_by_sha: + type_by_sha[sha] = detection.type or detection.message + + if not type_by_sha: + return '' + + lines = [f' - {detection_type}: {sha}' for sha, detection_type in list(type_by_sha.items())[:max_lines]] + remaining = len(type_by_sha) - len(lines) + if remaining: + lines.append(f' - ...and {remaining} more') + + return '\n' + '\n'.join(lines) + + def build_violation_summary(local_scan_results: list['LocalScanResult']) -> str: """Build violation summary string with severity breakdown and emojis.""" detections_count = 0 @@ -61,4 +91,5 @@ def build_violation_summary(local_scan_results: list['LocalScanResult']) -> str: count = severity_counts[severity] severity_parts.append(f'{emoji} {severity.upper()} - {count}') - return f'Cycode found {detections_count} violations: {" | ".join(severity_parts)}' + summary = f'Cycode found {detections_count} violations: {" | ".join(severity_parts)}' + return summary + _build_detection_lines(local_scan_results) diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 8625b2f6..2ace0e1d 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -21,7 +21,9 @@ ) from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason -from cycode.cli.models import Document, LocalScanResult +from cycode.cli.models import Document, DocumentDetections, LocalScanResult +from cycode.cli.utils.scan_utils import MAX_VIOLATION_DETAIL_LINES, build_violation_summary +from cycode.cyclient.models import Detection @pytest.fixture @@ -420,6 +422,60 @@ def test_perform_scan_no_violation_when_all_detections_excluded(mock_ctx: MagicM assert scan_id == 'scan-id-123' +def _local_scan_result_with_detections(*shas: str) -> LocalScanResult: + document = Document(path='prompt-content.txt', content='some content', is_git_diff_format=False) + detections = [ + Detection( + detection_type_id='type-id', + type='GitHub Token', + message='Hardcoded secret', + detection_details={'sha512': sha}, + detection_rule_id='rule-id', + severity='High', + ) + for sha in shas + ] + return LocalScanResult( + scan_id='scan-id-123', + report_url=None, + document_detections=[DocumentDetections(document=document, detections=detections)], + issue_detected=True, + detections_count=len(detections), + relevant_detections_count=len(detections), + ) + + +def test_violation_summary_lists_one_line_per_distinct_sha() -> None: + """A blocked developer needs the value hash to act on the finding (e.g. `cycode ignore --by-sha`).""" + summary = build_violation_summary([_local_scan_result_with_detections('sha-aaa', 'sha-bbb', 'sha-aaa')]) + + assert 'Cycode found 3 violations' in summary + assert 'GitHub Token: sha-aaa' in summary + assert 'GitHub Token: sha-bbb' in summary + # Repeated values collapse to one line; the hash identifies the value, not the occurrence + assert summary.count('sha-aaa') == 1 + + +def test_violation_summary_caps_the_detection_lines() -> None: + """A file full of detections must not turn the hook message into a wall of text.""" + shas = [f'sha-{index}' for index in range(MAX_VIOLATION_DETAIL_LINES + 3)] + + summary = build_violation_summary([_local_scan_result_with_detections(*shas)]) + + assert summary.count('GitHub Token: ') == MAX_VIOLATION_DETAIL_LINES + assert '...and 3 more' in summary + + +def test_violation_summary_omits_lines_without_a_sha() -> None: + """Non-secret scan types carry no value hash, so there is nothing to list.""" + local_scan_result = _local_scan_result_with_detections('sha-aaa') + local_scan_result.document_detections[0].detections[0].detection_details = {} + + summary = build_violation_summary([local_scan_result]) + + assert 'GitHub Token' not in summary + + # Tests for handle_before_mcp_execution From 5a05f52fa8166c3cb11bac348f857019eb23296f Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Wed, 16 Sep 2026 15:15:54 +0300 Subject: [PATCH 2/2] CM-72834: Move the violation summary into the guardrails package build_violation_summary sat in the shared utils/scan_utils.py but has only ever had one caller, and it is now shaped for a hook message rather than a terminal - multi-line, with a value hash per finding. Other scan commands render through the printers, which already have their own Secret SHA column. Co-Authored-By: Claude Opus 5 (1M context) --- .../cli/apps/ai_guardrails/scan/handlers.py | 5 +- cycode/cli/apps/ai_guardrails/scan/utils.py | 60 ++++++++++++++++++- cycode/cli/utils/scan_utils.py | 54 ----------------- .../ai_guardrails/scan/test_handlers.py | 2 +- 4 files changed, 61 insertions(+), 60 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 38293625..20254738 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -30,7 +30,7 @@ AIHookOutcome, BlockReason, ) -from cycode.cli.apps.ai_guardrails.scan.utils import is_denied_path, truncate_utf8 +from cycode.cli.apps.ai_guardrails.scan.utils import build_violation_summary, is_denied_path, truncate_utf8 from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func from cycode.cli.apps.scan.scan_parameters import get_scan_parameters from cycode.cli.cli_types import ScanTypeOption, SeverityOption @@ -38,7 +38,6 @@ from cycode.cli.models import Document from cycode.cli.utils.host_info import get_hostname, get_serial_number from cycode.cli.utils.progress_bar import DummyProgressBar, ScanProgressBarSection -from cycode.cli.utils.scan_utils import build_violation_summary from cycode.logger import get_logger logger = get_logger('AI Guardrails') @@ -76,7 +75,6 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.PROMPT] if effective_mode == GuardrailsMode.BLOCK: outcome = AIHookOutcome.BLOCKED - # Summary last: it is multi-line, so it must not be interpolated mid-sentence user_message = f'Remove secrets before sending. {violation_summary}' return HookDecision.deny(AiHookEventType.PROMPT, user_message) outcome = AIHookOutcome.WARNED @@ -284,7 +282,6 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli event_type=AiHookEventType.MCP_EXECUTION, deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}', deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.', - # Summary last: it is multi-line, so it must not be interpolated mid-sentence ask_message=lambda v: f'Allow MCP tool call "{tool}"? {v}', ask_agent_message='Possible secrets detected in tool arguments; proceed with caution.', ), diff --git a/cycode/cli/apps/ai_guardrails/scan/utils.py b/cycode/cli/apps/ai_guardrails/scan/utils.py index 6223c925..645d7636 100644 --- a/cycode/cli/apps/ai_guardrails/scan/utils.py +++ b/cycode/cli/apps/ai_guardrails/scan/utils.py @@ -1,15 +1,24 @@ """ Utility functions for AI guardrails. -Includes JSON parsing, path matching, and text handling utilities. +Includes JSON parsing, path matching, text handling and hook-message utilities. """ import json import os import sys +from collections import defaultdict from pathlib import Path +from typing import TYPE_CHECKING from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value +from cycode.cli.cli_types import SeverityOption + +if TYPE_CHECKING: + from cycode.cli.models import LocalScanResult + +# Keeps the hook message readable when a single file trips dozens of detections +MAX_VIOLATION_DETAIL_LINES = 5 def read_stdin_text() -> str: @@ -87,3 +96,52 @@ def is_denied_path(file_path: str, policy: dict) -> bool: def output_json(obj: dict) -> None: """Write JSON response to stdout (for IDE to read).""" print(json.dumps(obj), end='') # noqa: T201 + + +def _build_detection_lines( + local_scan_results: list['LocalScanResult'], max_lines: int = MAX_VIOLATION_DETAIL_LINES +) -> str: + """One line per distinct finding: what it is, and the value hash identifying it. + + The value hash is safe to display; the value itself is not. Detections excluded by an existing + ignore rule are already gone from `document_detections`, so only what actually blocked is listed. + """ + type_by_sha = {} + for local_scan_result in local_scan_results: + for document_detections in local_scan_result.document_detections: + for detection in document_detections.detections: + sha = detection.detection_details.get('sha512') + if sha and sha not in type_by_sha: + type_by_sha[sha] = detection.type or detection.message + + if not type_by_sha: + return '' + + lines = [f' - {detection_type}: {sha}' for sha, detection_type in list(type_by_sha.items())[:max_lines]] + remaining = len(type_by_sha) - len(lines) + if remaining: + lines.append(f' - ...and {remaining} more') + + return '\n' + '\n'.join(lines) + + +def build_violation_summary(local_scan_results: list['LocalScanResult']) -> str: + """Build violation summary string with severity breakdown and emojis.""" + detections_count = 0 + severity_counts = defaultdict(int) + + for local_scan_result in local_scan_results: + for document_detections in local_scan_result.document_detections: + for detection in document_detections.detections: + if detection.severity: + detections_count += 1 + severity_counts[SeverityOption(detection.severity)] += 1 + + severity_parts = [] + for severity in reversed(SeverityOption): + emoji = SeverityOption.get_member_unicode_emoji(severity) + count = severity_counts[severity] + severity_parts.append(f'{emoji} {severity.upper()} - {count}') + + summary = f'Cycode found {detections_count} violations: {" | ".join(severity_parts)}' + return summary + _build_detection_lines(local_scan_results) diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index 75dec022..951d6cc4 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -1,20 +1,15 @@ import os -from collections import defaultdict from typing import TYPE_CHECKING, Optional from uuid import UUID, uuid4 import typer from cycode.cli import consts -from cycode.cli.cli_types import SeverityOption if TYPE_CHECKING: from cycode.cli.models import LocalScanResult from cycode.cyclient.models import ScanConfiguration -# Keeps the hook message readable when a single file trips dozens of detections -MAX_VIOLATION_DETAIL_LINES = 5 - def set_issue_detected(ctx: typer.Context, issue_detected: bool) -> None: ctx.obj['issue_detected'] = issue_detected @@ -44,52 +39,3 @@ def generate_unique_scan_id() -> UUID: return UUID(os.environ['PYTEST_TEST_UNIQUE_ID']) return uuid4() - - -def _build_detection_lines( - local_scan_results: list['LocalScanResult'], max_lines: int = MAX_VIOLATION_DETAIL_LINES -) -> str: - """One line per distinct finding: what it is, and the value hash identifying it. - - The value hash is safe to display; the value itself is not. Detections excluded by an existing - ignore rule are already gone from `document_detections`, so only what actually blocked is listed. - """ - type_by_sha = {} - for local_scan_result in local_scan_results: - for document_detections in local_scan_result.document_detections: - for detection in document_detections.detections: - sha = detection.detection_details.get('sha512') - if sha and sha not in type_by_sha: - type_by_sha[sha] = detection.type or detection.message - - if not type_by_sha: - return '' - - lines = [f' - {detection_type}: {sha}' for sha, detection_type in list(type_by_sha.items())[:max_lines]] - remaining = len(type_by_sha) - len(lines) - if remaining: - lines.append(f' - ...and {remaining} more') - - return '\n' + '\n'.join(lines) - - -def build_violation_summary(local_scan_results: list['LocalScanResult']) -> str: - """Build violation summary string with severity breakdown and emojis.""" - detections_count = 0 - severity_counts = defaultdict(int) - - for local_scan_result in local_scan_results: - for document_detections in local_scan_result.document_detections: - for detection in document_detections.detections: - if detection.severity: - detections_count += 1 - severity_counts[SeverityOption(detection.severity)] += 1 - - severity_parts = [] - for severity in reversed(SeverityOption): - emoji = SeverityOption.get_member_unicode_emoji(severity) - count = severity_counts[severity] - severity_parts.append(f'{emoji} {severity.upper()} - {count}') - - summary = f'Cycode found {detections_count} violations: {" | ".join(severity_parts)}' - return summary + _build_detection_lines(local_scan_results) diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 2ace0e1d..47d473bc 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -21,8 +21,8 @@ ) from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason +from cycode.cli.apps.ai_guardrails.scan.utils import MAX_VIOLATION_DETAIL_LINES, build_violation_summary from cycode.cli.models import Document, DocumentDetections, LocalScanResult -from cycode.cli.utils.scan_utils import MAX_VIOLATION_DETAIL_LINES, build_violation_summary from cycode.cyclient.models import Detection