diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py index ab5958b4..ace69ec2 100644 --- a/cycode/cli/apps/ai_guardrails/ides/base.py +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -47,6 +47,16 @@ class HookDecision: user_message: str | None = None agent_message: str | None = None + @property + def model_message(self) -> str: + """What a tool deny tells the model: the findings the user sees, then the agent's instructions.""" + return '\n'.join(message for message in (self.user_message, self.agent_message) if message) + + @property + def permission_reason(self) -> str: + """A PreToolUse permissionDecisionReason: a deny reason goes to the model, an ask reason to the user.""" + return self.model_message if self.action == DecisionAction.DENY else self.user_message or '' + @classmethod def allow(cls, event_type: AiHookEventType) -> 'HookDecision': return cls(action=DecisionAction.ALLOW, event_type=event_type) diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index c70c363f..ca126d84 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -397,8 +397,13 @@ def build_hook_response(self, decision: HookDecision) -> dict: if decision.event_type == AiHookEventType.PROMPT: if decision.action == DecisionAction.ALLOW: return {} - # Both DENY and (unexpected) ASK on prompts collapse to a block. - return {'decision': 'block', 'reason': decision.user_message or ''} + # Both DENY and (unexpected) ASK on prompts collapse to a block. Claude Code echoes the blocked + # prompt (secret included) under the reason unless suppressed; the reason already locates the finding. + return { + 'decision': 'block', + 'reason': decision.user_message or '', + 'hookSpecificOutput': {'hookEventName': 'UserPromptSubmit', 'suppressOriginalPrompt': True}, + } # FILE_READ / MCP_EXECUTION → hookSpecificOutput shape. if decision.action == DecisionAction.ALLOW: @@ -412,7 +417,7 @@ def build_hook_response(self, decision: HookDecision) -> dict: 'hookSpecificOutput': { 'hookEventName': 'PreToolUse', 'permissionDecision': decision.action.value, # 'deny' or 'ask' - 'permissionDecisionReason': decision.user_message or '', + 'permissionDecisionReason': decision.permission_reason, } } diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py index a41e6f12..cb8e6deb 100644 --- a/cycode/cli/apps/ai_guardrails/ides/codex.py +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -319,7 +319,7 @@ def build_hook_response(self, decision: HookDecision) -> dict: 'hookSpecificOutput': { 'hookEventName': 'PreToolUse', 'permissionDecision': decision.action.value, # 'deny' or 'ask' - 'permissionDecisionReason': decision.user_message or '', + 'permissionDecisionReason': decision.permission_reason, } } diff --git a/cycode/cli/apps/ai_guardrails/ides/copilot.py b/cycode/cli/apps/ai_guardrails/ides/copilot.py index 90b71028..01a1738c 100644 --- a/cycode/cli/apps/ai_guardrails/ides/copilot.py +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -474,7 +474,8 @@ def build_hook_response(self, decision: HookDecision) -> dict: 'hookSpecificOutput': { 'hookEventName': 'PreToolUse', 'permissionDecision': decision.action.value, # 'deny' or 'ask' - 'permissionDecisionReason': decision.user_message or '', + # Copilot VS Code shows a deny reason to the user rather than the model, which is harmless + 'permissionDecisionReason': decision.permission_reason, } } diff --git a/cycode/cli/apps/ai_guardrails/ides/cursor.py b/cycode/cli/apps/ai_guardrails/ides/cursor.py index 4fbf070e..0dab8c5e 100644 --- a/cycode/cli/apps/ai_guardrails/ides/cursor.py +++ b/cycode/cli/apps/ai_guardrails/ides/cursor.py @@ -149,10 +149,13 @@ def build_hook_response(self, decision: HookDecision) -> dict: # FILE_READ / MCP_EXECUTION → permission shape if decision.action == DecisionAction.ALLOW: return {'permission': 'allow'} + # beforeReadFile has no agent_message; its deny user_message is what reaches the model + file_read_deny = decision.event_type == AiHookEventType.FILE_READ and decision.action == DecisionAction.DENY return { 'permission': decision.action.value, # 'deny' or 'ask' - 'user_message': decision.user_message or '', - 'agent_message': decision.agent_message or '', + 'user_message': decision.model_message if file_read_deny else decision.user_message or '', + # The agent does not see user_message, and needs the findings (their sha) to act on its instructions + 'agent_message': decision.model_message, } def build_session_payload(self, raw_payload: dict) -> AIHookPayload: diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 5d29129f..81c699db 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -10,9 +10,11 @@ import json import os +import sys import threading from collections.abc import Callable from dataclasses import dataclass +from gettext import ngettext from typing import TYPE_CHECKING, Any, NamedTuple import typer @@ -46,12 +48,53 @@ HandlerFn = Callable[[typer.Context, AIHookPayload, dict], HookDecision] +def _ignore_commands(shas: tuple[str, ...]) -> list[str]: + """Ready-to-run ignore commands, one per finding, in the order the findings are listed. + + Names the binary running this hook, which may not be on PATH (e.g. MDM installs). Repo scope: it lands in + the cwd's .cycode/config.yaml, which matches where the hook reads it as long as the agent runs it. + """ + executable = sys.executable if getattr(sys, 'frozen', False) else sys.argv[0] + if ' ' in executable: + executable = f'"{executable}"' + return [f'`{executable} ignore --by-sha {sha}`' for sha in shas] + + +def _with_ignore_commands(shas: tuple[str, ...], one: str, many: str) -> str: + """`one` formatted with the single command, or `many` followed by the commands as a list; '' without shas.""" + commands = _ignore_commands(shas) + if len(commands) == 1: + return one.format(command=commands[0]) + if commands: + return many + ''.join(f'\n {command}' for command in commands) + return '' + + +def _false_positive_user_hint(shas: tuple[str, ...]) -> str: + # The agent never saw the blocked prompt, so the command has to be complete for the developer to pass on + return _with_ignore_commands( + shas, + one='Not a real secret? Ask your code agent to run {command}, then resend.', + many='Not real secrets? Ask your code agent to run the command for each one, then resend:', + ) + + +def _false_positive_agent_hint(shas: tuple[str, ...]) -> str: + # Ignoring is the developer's call; the agent may run it for them, but only once they confirm + return 'Tell the user what was blocked.' + _with_ignore_commands( + shas, + one=' Only if the user confirms it is a false positive, run {command} and retry.', + many=' Only for a finding the user confirms is a false positive, run its command and retry:', + ) + + class ScanOutcome(NamedTuple): """What one guardrail scan came back with; the verdict is the server's, which applied the tenant's floors.""" violation_summary: str | None = None scan_id: str | None = None verdict: GuardrailsMode | None = None + shas: tuple[str, ...] = () NO_SCAN = ScanOutcome() @@ -96,7 +139,10 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.PROMPT] if scan_outcome.verdict == GuardrailsMode.BLOCK: outcome = AIHookOutcome.BLOCKED - user_message = f'Remove secrets before sending. {scan_outcome.violation_summary}' + secrets = ngettext('secret', 'secrets', len(scan_outcome.shas)) + user_message = f'{scan_outcome.violation_summary}\nRemove the {secrets} and resend.' + if scan_outcome.shas: + user_message += f'\n{_false_positive_user_hint(scan_outcome.shas)}' return HookDecision.deny(AiHookEventType.PROMPT, user_message) outcome = AIHookOutcome.WARNED return HookDecision.allow(AiHookEventType.PROMPT) @@ -164,14 +210,15 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.FILE_READ] if scan_outcome.verdict == GuardrailsMode.BLOCK: outcome = AIHookOutcome.BLOCKED - user_message = f'Cycode blocked reading {file_path}. {scan_outcome.violation_summary}' + user_message = ( + f'Cycode blocked reading {file_path} because it contains a secret.\n' + f'{scan_outcome.violation_summary}' + ) return HookDecision.deny( - AiHookEventType.FILE_READ, - user_message, - 'Secrets detected; do not send this file to the model.', + AiHookEventType.FILE_READ, user_message, _false_positive_agent_hint(scan_outcome.shas) ) outcome = AIHookOutcome.WARNED - user_message = f'Cycode detected secrets in {file_path}. {scan_outcome.violation_summary}' + user_message = f'Cycode detected secrets in {file_path}.\n{scan_outcome.violation_summary}' return HookDecision.ask( AiHookEventType.FILE_READ, user_message, @@ -282,7 +329,7 @@ def _handle_arg_scan( return HookDecision.deny( feature.event_type, feature.deny_message(scan_outcome.violation_summary), - feature.deny_agent_message, + f'{feature.deny_agent_message} {_false_positive_agent_hint(scan_outcome.shas)}', ) outcome = AIHookOutcome.WARNED return HookDecision.ask( @@ -360,9 +407,11 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli policy, _ArgScanFeature( event_type=AiHookEventType.MCP_EXECUTION, - deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}', + deny_message=lambda v: ( + f'Cycode blocked MCP tool call "{tool}" because its arguments contain a secret.\n{v}' + ), deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.', - ask_message=lambda v: f'Allow MCP tool call "{tool}"? {v}', + ask_message=lambda v: f'Allow MCP tool call "{tool}"?\n{v}', ask_agent_message='Possible secrets detected in tool arguments; proceed with caution.', scan_enabled=get_policy_value(policy, 'mcp', 'scan_args', default=True), ), @@ -478,11 +527,14 @@ def _perform_scan( if not local_scan_result: return NO_SCAN - violation_summary = build_violation_summary([local_scan_result]) if local_scan_result.issue_detected else None + violation_summary, shas = ( + build_violation_summary([local_scan_result]) if local_scan_result.issue_detected else (None, ()) + ) return ScanOutcome( violation_summary=violation_summary, scan_id=local_scan_result.scan_id, verdict=_parse_verdict(local_scan_result.verdict), + shas=shas, ) diff --git a/cycode/cli/apps/ai_guardrails/scan/utils.py b/cycode/cli/apps/ai_guardrails/scan/utils.py index 645d7636..988c37b5 100644 --- a/cycode/cli/apps/ai_guardrails/scan/utils.py +++ b/cycode/cli/apps/ai_guardrails/scan/utils.py @@ -8,14 +8,17 @@ import os import sys from collections import defaultdict +from gettext import ngettext 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 +from cycode.cli.utils.string_utils import obfuscate_text if TYPE_CHECKING: from cycode.cli.models import LocalScanResult + from cycode.cyclient.models import Detection # Keeps the hook message readable when a single file trips dozens of detections MAX_VIOLATION_DETAIL_LINES = 5 @@ -98,35 +101,50 @@ def output_json(obj: dict) -> None: print(json.dumps(obj), end='') # noqa: T201 -def _build_detection_lines( +def _describe_detection(detection: 'Detection', content: str) -> str: + details = detection.detection_details + description = detection.type or detection.message + + line = details.get('line') + if isinstance(line, int) and line >= 0: + description += f' (line {line + 1})' # the scanner reports 0-based lines + + start, length = details.get('start_position'), details.get('length') + if isinstance(start, int) and isinstance(length, int) and start >= 0 and length > 0: + value = content[start : start + length] + if value: + description += f': {obfuscate_text(value)}' + + return description + + +def build_violation_summary( 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. +) -> tuple[str, tuple[str, ...]]: + """One line per distinct finding (what it is, where, the masked value), and the value hashes to ignore them by. - The value hash is safe to display; the value itself is not. Detections excluded by an existing + The masked value and hash are 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 = {} + # Keyed by value hash so a repeated value is listed once; detections without one key by their description + finding_by_key = {} for local_scan_result in local_scan_results: for document_detections in local_scan_result.document_detections: for detection in document_detections.detections: + description = _describe_detection(detection, document_detections.document.content) 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 '' + finding_by_key.setdefault(sha or description, (sha, description)) - lines = [f' - {detection_type}: {sha}' for sha, detection_type in list(type_by_sha.items())[:max_lines]] - remaining = len(type_by_sha) - len(lines) + listed = list(finding_by_key.values())[:max_lines] + lines = [_build_severity_line(local_scan_results)] + [f' - {description}' for _, description in listed] + remaining = len(finding_by_key) - len(listed) if remaining: lines.append(f' - ...and {remaining} more') - return '\n' + '\n'.join(lines) + return '\n'.join(lines), tuple(sha for sha, _ in listed if sha) -def build_violation_summary(local_scan_results: list['LocalScanResult']) -> str: - """Build violation summary string with severity breakdown and emojis.""" +def _build_severity_line(local_scan_results: list['LocalScanResult']) -> str: detections_count = 0 severity_counts = defaultdict(int) @@ -143,5 +161,5 @@ def build_violation_summary(local_scan_results: list['LocalScanResult']) -> str: 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) + violations = ngettext('violation', 'violations', detections_count) + return f'Cycode found {detections_count} {violations}: {" | ".join(severity_parts)}' diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py index 12652160..d7d37e3e 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -194,9 +194,11 @@ def test_parse_empty_payload_defaults() -> None: def test_build_prompt_responses() -> None: claude = ClaudeCode() assert claude.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)) == {} + # The blocked prompt carries the secret, so it must not be echoed back under the reason assert claude.build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'no!')) == { 'decision': 'block', 'reason': 'no!', + 'hookSpecificOutput': {'hookEventName': 'UserPromptSubmit', 'suppressOriginalPrompt': True}, } @@ -210,7 +212,8 @@ def test_build_permission_responses() -> None: 'hookSpecificOutput': { 'hookEventName': 'PreToolUse', 'permissionDecision': 'deny', - 'permissionDecisionReason': 'user!', + # A deny reason reaches the model, which needs both the findings and its instructions + 'permissionDecisionReason': 'user!\nagent!', } } diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py index b867de39..e69ef0b7 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_codex.py +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -149,12 +149,12 @@ def test_build_mcp_execution_allow_and_deny() -> None: # Explicit permissionDecision "allow" is rejected by older Codex runtimes. assert allow == {} - deny = codex.build_hook_response(HookDecision.deny(AiHookEventType.MCP_EXECUTION, 'secret in args!')) + deny = codex.build_hook_response(HookDecision.deny(AiHookEventType.MCP_EXECUTION, 'secret in args!', 'agent!')) assert deny == { 'hookSpecificOutput': { 'hookEventName': 'PreToolUse', 'permissionDecision': 'deny', - 'permissionDecisionReason': 'secret in args!', + 'permissionDecisionReason': 'secret in args!\nagent!', } } diff --git a/tests/cli/commands/ai_guardrails/ides/test_copilot.py b/tests/cli/commands/ai_guardrails/ides/test_copilot.py index eed1b8a6..7914e76a 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_copilot.py +++ b/tests/cli/commands/ai_guardrails/ides/test_copilot.py @@ -282,12 +282,12 @@ def test_deny_prompt_response_shape() -> None: def test_deny_tool_response_shape() -> None: - response = Copilot().build_hook_response(HookDecision.deny(AiHookEventType.FILE_READ, 'Sensitive file')) + response = Copilot().build_hook_response(HookDecision.deny(AiHookEventType.FILE_READ, 'Sensitive file', 'agent!')) assert response == { 'hookSpecificOutput': { 'hookEventName': 'PreToolUse', 'permissionDecision': 'deny', - 'permissionDecisionReason': 'Sensitive file', + 'permissionDecisionReason': 'Sensitive file\nagent!', } } diff --git a/tests/cli/commands/ai_guardrails/ides/test_cursor.py b/tests/cli/commands/ai_guardrails/ides/test_cursor.py index 60834020..f54e6752 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_cursor.py +++ b/tests/cli/commands/ai_guardrails/ides/test_cursor.py @@ -175,15 +175,22 @@ def test_build_prompt_responses() -> None: def test_build_permission_responses() -> None: cursor = Cursor() assert cursor.build_hook_response(HookDecision.allow(AiHookEventType.FILE_READ)) == {'permission': 'allow'} + # beforeReadFile drops agent_message and feeds user_message to the model, so it carries both assert cursor.build_hook_response(HookDecision.deny(AiHookEventType.FILE_READ, 'user!', 'agent!')) == { + 'permission': 'deny', + 'user_message': 'user!\nagent!', + 'agent_message': 'user!\nagent!', + } + assert cursor.build_hook_response(HookDecision.deny(AiHookEventType.MCP_EXECUTION, 'user!', 'agent!')) == { 'permission': 'deny', 'user_message': 'user!', - 'agent_message': 'agent!', + # The agent never sees user_message, so it gets the findings too + 'agent_message': 'user!\nagent!', } assert cursor.build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'u', 'a')) == { 'permission': 'ask', 'user_message': 'u', - 'agent_message': 'a', + 'agent_message': 'u\na', } diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 88c8f30a..41ab6c33 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -1,6 +1,7 @@ """Tests for AI guardrails handlers.""" import os +import sys import threading import time from multiprocessing import synchronize @@ -96,18 +97,33 @@ def test_handle_before_submit_prompt_with_secrets_blocked( mock_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload, default_policy: dict[str, Any] ) -> None: """Test that prompt with secrets is blocked.""" - mock_scan.return_value = ScanOutcome('Found 1 secret: API key', 'scan-id-456', GuardrailsMode.BLOCK) + mock_scan.return_value = ScanOutcome('Found 1 secret: API key', 'scan-id-456', GuardrailsMode.BLOCK, ('sha-aaa',)) - result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + with patch.object(sys, 'argv', ['/opt/cycode/bin/cycode']): + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) assert result.action == DecisionAction.DENY assert result.event_type == AiHookEventType.PROMPT assert 'Found 1 secret: API key' in result.user_message + # The agent never saw the prompt, so the developer gets a complete command (running binary, real sha) to pass on + assert 'Ask your code agent to run `/opt/cycode/bin/cycode ignore --by-sha sha-aaa`' in result.user_message mock_ctx.obj['ai_security_client'].create_event.assert_called_once() call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.BLOCKED assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_PROMPT + # Several secrets: one command per finding, in the listed order + mock_scan.return_value = ScanOutcome('Found 2 secrets', 'scan-id-457', GuardrailsMode.BLOCK, ('sha-aaa', 'sha-bbb')) + with patch.object(sys, 'argv', ['/opt/cycode/bin/cycode']): + result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) + + assert 'Remove the secrets and resend.' in result.user_message + assert result.user_message.endswith( + 'then resend:\n' + ' `/opt/cycode/bin/cycode ignore --by-sha sha-aaa`\n' + ' `/opt/cycode/bin/cycode ignore --by-sha sha-bbb`' + ) + @patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') def test_handle_before_submit_prompt_with_secrets_warned( @@ -226,18 +242,25 @@ def test_handle_before_read_file_with_secrets( ) -> None: """Test that file with secrets is blocked, and merely reported when the verdict says so.""" mock_is_denied.return_value = False - mock_scan.return_value = ScanOutcome('Found 1 secret: password', 'scan-id-456', GuardrailsMode.BLOCK) + mock_scan.return_value = ScanOutcome( + 'Found 1 secret: password', 'scan-id-456', GuardrailsMode.BLOCK, ('sha-aaa', 'sha-bbb') + ) payload = AIHookPayload( event_name='FileRead', ide_provider='cursor', file_path='/path/to/file.txt', ) - result = handle_before_read_file(mock_ctx, payload, default_policy) + with patch.object(sys, 'argv', ['/opt/cycode/bin/cycode']): + result = handle_before_read_file(mock_ctx, payload, default_policy) assert result.action == DecisionAction.DENY assert result.event_type == AiHookEventType.FILE_READ assert 'Found 1 secret: password' in result.user_message + # The agent may ignore a finding, but only on the developer's confirmation + assert 'Only for a finding the user confirms is a false positive' in result.agent_message + assert '\n `/opt/cycode/bin/cycode ignore --by-sha sha-aaa`' in result.agent_message + assert '\n `/opt/cycode/bin/cycode ignore --by-sha sha-bbb`' in result.agent_message call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.BLOCKED assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_FILE @@ -485,34 +508,46 @@ def _local_scan_result_with_detections(*shas: str) -> LocalScanResult: 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')]) + """A blocked developer needs to find the value (line, masked value) and act on it (`cycode ignore --by-sha`).""" + local_scan_result = _local_scan_result_with_detections('sha-aaa', 'sha-bbb', 'sha-aaa') + local_scan_result.document_detections[0].document.content = 'first line\ntoken=ghp_abcdefghijklmnop\n' + local_scan_result.document_detections[0].detections[0].detection_details.update( + {'line': 1, 'start_position': 17, 'length': 20} + ) + + summary, shas = build_violation_summary([local_scan_result]) 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 + assert ' - GitHub Token (line 2): ghp**************nop' in summary + assert 'ghp_abcdefghijklmnop' not in summary + # Without position details there is nothing to locate or mask, but the finding is still listed + assert ' - GitHub Token\n' in summary + '\n' + # Repeated values collapse to one finding; the hash identifies the value, not the occurrence + assert shas == ('sha-aaa', 'sha-bbb') 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)]) + summary, listed_shas = build_violation_summary([_local_scan_result_with_detections(*shas)]) - assert summary.count('GitHub Token: ') == MAX_VIOLATION_DETAIL_LINES + assert summary.count(' - GitHub Token') == MAX_VIOLATION_DETAIL_LINES assert '...and 3 more' in summary + # Only listed findings get an ignore command + assert listed_shas == tuple(shas[:MAX_VIOLATION_DETAIL_LINES]) -def test_violation_summary_omits_lines_without_a_sha() -> None: - """Non-secret scan types carry no value hash, so there is nothing to list.""" +def test_violation_summary_lists_findings_without_a_sha() -> None: + """Non-secret scan types carry no value hash: the finding is still listed, there is just nothing to ignore by.""" 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]) + summary, shas = build_violation_summary([local_scan_result]) - assert 'GitHub Token' not in summary + assert 'Cycode found 1 violation:' in summary + assert ' - GitHub Token' in summary + assert shas == () # Tests for handle_before_mcp_execution