diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 469fc489..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,7 @@ 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.' + 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 +282,7 @@ 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?', + 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/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 819a4116..951d6cc4 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -1,12 +1,10 @@ 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 @@ -41,24 +39,3 @@ def generate_unique_scan_id() -> UUID: return UUID(os.environ['PYTEST_TEST_UNIQUE_ID']) return uuid4() - - -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}') - - return f'Cycode found {detections_count} violations: {" | ".join(severity_parts)}' diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 8625b2f6..47d473bc 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.apps.ai_guardrails.scan.utils import MAX_VIOLATION_DETAIL_LINES, build_violation_summary +from cycode.cli.models import Document, DocumentDetections, LocalScanResult +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