From 51e97b4c062278a1ad9613864189f6e02d8ff5e3 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Tue, 13 Oct 2026 17:23:40 +0300 Subject: [PATCH 1/2] CM-75116: Make guardrails block messages actionable Each finding now shows its type, line and masked value, and the message ends with a ready-to-run `cycode ignore --by-sha` command per finding for false positives. Tool denies pass the findings and instructions to the model (the agent runs the ignore only once the user confirms), Cursor's beforeReadFile gets them in user_message since it drops agent_message, and Claude Code no longer echoes a blocked prompt back. Co-Authored-By: Claude Opus 5.5 --- cycode/cli/apps/ai_guardrails/ides/base.py | 10 +++ .../apps/ai_guardrails/ides/claude_code.py | 11 ++- cycode/cli/apps/ai_guardrails/ides/codex.py | 2 +- cycode/cli/apps/ai_guardrails/ides/copilot.py | 3 +- cycode/cli/apps/ai_guardrails/ides/cursor.py | 7 +- .../cli/apps/ai_guardrails/scan/handlers.py | 71 ++++++++++++++++--- cycode/cli/apps/ai_guardrails/scan/utils.py | 50 ++++++++----- .../ai_guardrails/ides/test_claude_code.py | 5 +- .../commands/ai_guardrails/ides/test_codex.py | 4 +- .../ai_guardrails/ides/test_copilot.py | 4 +- .../ai_guardrails/ides/test_cursor.py | 11 ++- .../ai_guardrails/scan/test_handlers.py | 66 ++++++++++++----- 12 files changed, 187 insertions(+), 57 deletions(-) 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..f7b9fb37 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -10,6 +10,7 @@ import json import os +import sys import threading from collections.abc import Callable from dataclasses import dataclass @@ -46,12 +47,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 +138,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 = 'secrets' if len(scan_outcome.shas) > 1 else 'secret' + 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 +209,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 +328,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 +406,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 +526,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..6f1143c4 100644 --- a/cycode/cli/apps/ai_guardrails/scan/utils.py +++ b/cycode/cli/apps/ai_guardrails/scan/utils.py @@ -13,9 +13,11 @@ 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 +100,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 +160,4 @@ 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) + 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..472ddc0a 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,45 @@ 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 ' - GitHub Token' in summary + assert shas == () # Tests for handle_before_mcp_execution From bed6b52c11bfd4034515046bd325046afe2778c8 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Wed, 14 Oct 2026 11:43:12 +0300 Subject: [PATCH 2/2] CM-75116: Pluralize guardrails messages with gettext Co-Authored-By: Claude Opus 5.5 --- cycode/cli/apps/ai_guardrails/scan/handlers.py | 3 ++- cycode/cli/apps/ai_guardrails/scan/utils.py | 4 +++- tests/cli/commands/ai_guardrails/scan/test_handlers.py | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index f7b9fb37..81c699db 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -14,6 +14,7 @@ import threading from collections.abc import Callable from dataclasses import dataclass +from gettext import ngettext from typing import TYPE_CHECKING, Any, NamedTuple import typer @@ -138,7 +139,7 @@ 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 - secrets = 'secrets' if len(scan_outcome.shas) > 1 else 'secret' + 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)}' diff --git a/cycode/cli/apps/ai_guardrails/scan/utils.py b/cycode/cli/apps/ai_guardrails/scan/utils.py index 6f1143c4..988c37b5 100644 --- a/cycode/cli/apps/ai_guardrails/scan/utils.py +++ b/cycode/cli/apps/ai_guardrails/scan/utils.py @@ -8,6 +8,7 @@ import os import sys from collections import defaultdict +from gettext import ngettext from pathlib import Path from typing import TYPE_CHECKING @@ -160,4 +161,5 @@ def _build_severity_line(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)}' + violations = ngettext('violation', 'violations', detections_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 472ddc0a..41ab6c33 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -545,6 +545,7 @@ def test_violation_summary_lists_findings_without_a_sha() -> None: summary, shas = build_violation_summary([local_scan_result]) + assert 'Cycode found 1 violation:' in summary assert ' - GitHub Token' in summary assert shas == ()