From 3c0f0ca5a225345b6f5dbfe28c420489619e7968 Mon Sep 17 00:00:00 2001 From: Nikita Fishbakh Date: Mon, 5 Oct 2026 15:27:12 +0200 Subject: [PATCH 1/3] CM-73723: Judge sensitive-path reads by the sensitive-path thresholds A sensitive path is now scanned for secrets under its own guardrail (detection_source=sensitive_path), so scan-service applies that guardrail's per-agent mode and report/block floors - the same verdict rule the secret guardrails use. A sensitive file with no secrets is allowed, and the separate path-only block/warn (path_action) is gone. Co-Authored-By: Claude Opus 5.5 --- .../ai_guardrails/scan/guardrail_config.py | 8 +- .../cli/apps/ai_guardrails/scan/handlers.py | 96 +++++++-------- .../scan/test_guardrail_config.py | 16 +-- .../ai_guardrails/scan/test_handlers.py | 110 ++++++++---------- 4 files changed, 98 insertions(+), 132 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/guardrail_config.py b/cycode/cli/apps/ai_guardrails/scan/guardrail_config.py index 416262b8..a8debc17 100644 --- a/cycode/cli/apps/ai_guardrails/scan/guardrail_config.py +++ b/cycode/cli/apps/ai_guardrails/scan/guardrail_config.py @@ -100,9 +100,10 @@ def apply_platform_config(policy: dict, config: Optional[GuardrailConfig], ide_n The platform is the only mode source: no cache (cold start) means the built-in defaults - Report everywhere with the default globs - which equal an unconfigured tenant's platform - config, so behaviour is uniform either way. Each matrix cell lands on its own per-feature - action, so the two FileRead guardrails (content scan vs. sensitive path) keep independent modes. - An all-Off event never reaches here at all: scan_command skips it. + config, so behaviour is uniform either way. The two FileRead guardrails keep independent modes: + a sensitive path is scanned under its own guardrail, so the server's verdict carries the + sensitive-path cell, and only its Off (no globs) lands here. An all-Off event never reaches + here at all: scan_command skips it. """ def cell(guardrail_key: str) -> str: @@ -121,7 +122,6 @@ def action(guardrail_key: str) -> str: if cell(BlockReason.SENSITIVE_PATH) != GuardrailCellMode.OFF else [] ) - file_read['path_action'] = action(BlockReason.SENSITIVE_PATH) policy.setdefault('mcp', {})['action'] = action(BlockReason.SECRETS_IN_MCP_ARGS) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index c4a37b35..6fbdee85 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -117,12 +117,16 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: - """Block sensitive paths and scan file content for secrets.""" + """Scan the file content for secrets, judged by the sensitive-path guardrail when the path is sensitive. + + A sensitive path is scanned under its own guardrail, so the server applies the sensitive-path + floors and per-agent mode to the secrets it finds - the same verdict rule as the secret + guardrails. Every other file falls to the secrets-in-file guardrail. + """ ai_client = ctx.obj['ai_security_client'] file_read_config = get_policy_value(policy, 'file_read', default={}) file_path = payload.file_path or '' - path_mode = get_effective_mode(file_read_config, action_key='path_action') scan_id = None block_reason = None @@ -130,61 +134,36 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: error_message = None try: - is_sensitive_path = is_denied_path(file_path, policy) - if is_sensitive_path: - block_reason = BlockReason.SENSITIVE_PATH - if path_mode == GuardrailsMode.BLOCK: - outcome = AIHookOutcome.BLOCKED - user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).' - return HookDecision.deny( - AiHookEventType.FILE_READ, - user_message, - 'This file path is classified as sensitive; do not read/send it to the model.', - ) - # Warn mode: if content scan is enabled, emit a separate event for the - # sensitive path so the finally block can independently track the scan result. - outcome = AIHookOutcome.WARNED - if get_policy_value(file_read_config, 'scan_content', default=True): - ai_client.create_event( - payload, - AiHookEventType.FILE_READ, - outcome, - block_reason=BlockReason.SENSITIVE_PATH, - file_path=payload.file_path, - ) - block_reason = None - outcome = AIHookOutcome.ALLOWED - - if get_policy_value(file_read_config, 'scan_content', default=True): - scan_outcome = _scan_path_for_secrets(ctx, file_path, policy, payload=payload) - scan_id = scan_outcome.scan_id - if scan_outcome.violation_summary: - 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}' - return HookDecision.deny( - AiHookEventType.FILE_READ, - user_message, - 'Secrets detected; do not send this file to the model.', - ) - outcome = AIHookOutcome.WARNED - user_message = f'Cycode detected secrets in {file_path}. {scan_outcome.violation_summary}' - return HookDecision.ask( - AiHookEventType.FILE_READ, - user_message, - 'Possible secrets detected; proceed with caution.', - ) - - if is_sensitive_path: - user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?' - return HookDecision.ask( + if is_denied_path(file_path, policy): + guardrail = BlockReason.SENSITIVE_PATH + elif get_policy_value(file_read_config, 'scan_content', default=True): + guardrail = BlockReason.SECRETS_IN_FILE + else: + return HookDecision.allow(AiHookEventType.FILE_READ) + + scan_outcome = _scan_path_for_secrets(ctx, file_path, policy, payload=payload, guardrail=guardrail) + scan_id = scan_outcome.scan_id + if not scan_outcome.violation_summary: + return HookDecision.allow(AiHookEventType.FILE_READ) + + block_reason = guardrail + subject = f'the sensitive file {file_path}' if guardrail == BlockReason.SENSITIVE_PATH else file_path + if scan_outcome.verdict == GuardrailsMode.BLOCK: + outcome = AIHookOutcome.BLOCKED + user_message = f'Cycode blocked reading {subject}. {scan_outcome.violation_summary}' + return HookDecision.deny( AiHookEventType.FILE_READ, user_message, - 'This file path is classified as sensitive; proceed with caution.', + 'Secrets detected; do not send this file to the model.', ) - return HookDecision.allow(AiHookEventType.FILE_READ) + outcome = AIHookOutcome.WARNED + user_message = f'Cycode detected secrets in {subject}. {scan_outcome.violation_summary}' + return HookDecision.ask( + AiHookEventType.FILE_READ, + user_message, + 'Possible secrets detected; proceed with caution.', + ) except Exception as e: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED @@ -342,11 +321,13 @@ def build_ai_guardrails_scan_parameters( paths: Optional[tuple[str, ...]], payload: AIHookPayload, event_type: AiHookEventType, + guardrail: Optional[BlockReason] = None, ) -> dict: + """The scan parameters; `guardrail` (default: the event's secrets guardrail) picks the floors the server applies.""" scan_parameters = get_scan_parameters(ctx, paths) scan_parameters.setdefault('metadata', {})['ai_guardrails'] = { 'ide_provider': payload.ide_provider, - 'detection_source': SECRETS_BLOCK_REASON_BY_EVENT_TYPE[event_type].value, + 'detection_source': (guardrail or SECRETS_BLOCK_REASON_BY_EVENT_TYPE[event_type]).value, 'device_id': get_serial_number(), 'device_hostname': get_hostname(), 'conversation_id': payload.conversation_id, @@ -440,8 +421,9 @@ def _scan_path_for_secrets( file_path: str, policy: dict, payload: AIHookPayload, + guardrail: BlockReason = BlockReason.SECRETS_IN_FILE, ) -> ScanOutcome: - """Scan a file path for secrets.""" + """Scan a file path for secrets, judged by `guardrail`'s floors.""" if not file_path or not os.path.isfile(file_path): return NO_SCAN @@ -459,5 +441,7 @@ def _scan_path_for_secrets( document = Document(path=os.path.basename(file_path), content=content, is_git_diff_format=False) scan_ctx = _setup_scan_context(ctx) - scan_parameters = build_ai_guardrails_scan_parameters(scan_ctx, (file_path,), payload, AiHookEventType.FILE_READ) + scan_parameters = build_ai_guardrails_scan_parameters( + scan_ctx, (file_path,), payload, AiHookEventType.FILE_READ, guardrail + ) return _perform_scan(scan_ctx, [document], scan_parameters, timeout_seconds) diff --git a/tests/cli/commands/ai_guardrails/scan/test_guardrail_config.py b/tests/cli/commands/ai_guardrails/scan/test_guardrail_config.py index efde1d26..086982bc 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_guardrail_config.py +++ b/tests/cli/commands/ai_guardrails/scan/test_guardrail_config.py @@ -105,7 +105,6 @@ def test_apply_platform_config_without_cache_uses_report_defaults() -> None: assert policy['prompt']['action'] == 'warn' assert policy['file_read']['action'] == 'warn' - assert policy['file_read']['path_action'] == 'warn' assert policy['mcp']['action'] == 'warn' assert policy['file_read']['deny_globs'] == DEFAULT_SENSITIVE_PATH_GLOBS @@ -119,17 +118,14 @@ def test_apply_platform_config_block_cell_sets_block_action() -> None: assert policy['mcp']['action'] == 'warn' -def test_apply_platform_config_file_read_cells_keep_independent_actions() -> None: - # Content scan and sensitive path share the FileRead event but are separate matrix cells. +def test_apply_platform_config_sensitive_path_block_keeps_its_globs_and_content_action() -> None: + # The sensitive-path cell travels with its scan (the server's verdict), so it never sets the content action. policy: dict = {} - apply_platform_config(policy, _config(file_read='Block', sensitive_path='Report'), 'cursor') - assert policy['file_read']['action'] == 'block' - assert policy['file_read']['path_action'] == 'warn' - - policy = {} - apply_platform_config(policy, _config(file_read='Report', sensitive_path='Block'), 'cursor') + config = _config(file_read='Report', sensitive_path='Block') + apply_platform_config(policy, config, 'cursor') assert policy['file_read']['action'] == 'warn' - assert policy['file_read']['path_action'] == 'block' + assert policy['file_read']['deny_globs'] == config.sensitive_globs() + assert 'path_action' not in policy['file_read'] def test_apply_platform_config_off_cells_disable_subfeatures() -> None: diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 5235f1a3..6eca3eab 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -161,11 +161,13 @@ def test_handle_before_submit_prompt_scan_failure_fail_closed( @patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') -def test_handle_before_read_file_sensitive_path( - mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') +def test_handle_before_read_file_sensitive_path_blocked_by_verdict( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] ) -> None: - """Test that sensitive path is blocked.""" + """A sensitive path is scanned under its own guardrail, and blocked when the verdict says so.""" mock_is_denied.return_value = True + mock_scan.return_value = ScanOutcome('Found 1 secret: API key', 'scan-id-456', GuardrailsMode.BLOCK) payload = AIHookPayload( event_name='FileRead', ide_provider='cursor', @@ -174,26 +176,18 @@ def test_handle_before_read_file_sensitive_path( result = handle_before_read_file(mock_ctx, payload, default_policy) + assert mock_scan.call_args.kwargs['guardrail'] == BlockReason.SENSITIVE_PATH assert result.action == DecisionAction.DENY assert result.event_type == AiHookEventType.FILE_READ - assert '.env' in result.user_message + assert 'sensitive file /path/to/.env' in result.user_message + assert 'Found 1 secret: API key' 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.SENSITIVE_PATH + assert call_args.kwargs['scan_id'] == 'scan-id-456' assert call_args.kwargs['file_path'] == '/path/to/.env' - # The path guardrail has its own action: content scan in block mode must not make a - # report-mode path match block. - mock_ctx.obj['ai_security_client'].create_event.reset_mock() - default_policy['file_read']['path_action'] = 'warn' - default_policy['file_read']['scan_content'] = False - - result = handle_before_read_file(mock_ctx, payload, default_policy) - - assert result.action == DecisionAction.ASK - assert mock_ctx.obj['ai_security_client'].create_event.call_args.args[2] == AIHookOutcome.WARNED - @patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') @patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') @@ -241,8 +235,7 @@ def test_handle_before_read_file_with_secrets( assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_FILE assert call_args.kwargs['file_path'] == '/path/to/file.txt' - # A block-mode path guardrail must not make a Report verdict block; the developer is asked instead - default_policy['file_read']['path_action'] = 'block' + # A Report verdict never blocks; the developer is asked instead mock_scan.return_value = ScanOutcome('Found 1 secret: password', 'scan-id-456', GuardrailsMode.REPORT) result = handle_before_read_file(mock_ctx, payload, default_policy) @@ -274,13 +267,12 @@ def test_handle_before_read_file_scan_disabled( @patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') @patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') -def test_handle_before_read_file_sensitive_path_warn_mode_scans_content( +def test_handle_before_read_file_sensitive_path_report_verdict_asks( mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] ) -> None: - """Test that sensitive path in warn mode still scans file content and emits two events.""" + """Secrets under the sensitive-path block floor are reported and the developer is asked.""" mock_is_denied.return_value = True - mock_scan.return_value = ScanOutcome(scan_id='scan-id-123') - default_policy['file_read']['path_action'] = 'warn' + mock_scan.return_value = ScanOutcome('Found 1 secret: API key', 'scan-id-456', GuardrailsMode.REPORT) payload = AIHookPayload( event_name='FileRead', ide_provider='cursor', @@ -289,30 +281,22 @@ def test_handle_before_read_file_sensitive_path_warn_mode_scans_content( result = handle_before_read_file(mock_ctx, payload, default_policy) - mock_scan.assert_called_once() assert result.action == DecisionAction.ASK - assert result.event_type == AiHookEventType.FILE_READ - assert '.env' in result.user_message - - assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2 - first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0] - assert first_event.args[2] == AIHookOutcome.WARNED - assert first_event.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH - second_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[1] - assert second_event.args[2] == AIHookOutcome.ALLOWED - assert second_event.kwargs['block_reason'] is None + assert 'sensitive file /path/to/.env' 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.WARNED + assert call_args.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH @patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') @patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') -def test_handle_before_read_file_sensitive_path_warn_mode_with_secrets( +def test_handle_before_read_file_sensitive_path_without_secrets_is_allowed( mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] ) -> None: - """Test that sensitive path in warn mode reports secrets and emits two events.""" + """Like the secret guardrails, a sensitive path with nothing found is allowed and not flagged.""" mock_is_denied.return_value = True - mock_scan.return_value = ScanOutcome('Found 1 secret: API key', 'scan-id-456', GuardrailsMode.REPORT) - default_policy['file_read']['path_action'] = 'warn' - default_policy['file_read']['action'] = 'warn' + mock_scan.return_value = ScanOutcome(scan_id='scan-id-123') payload = AIHookPayload( event_name='FileRead', ide_provider='cursor', @@ -321,28 +305,22 @@ def test_handle_before_read_file_sensitive_path_warn_mode_with_secrets( result = handle_before_read_file(mock_ctx, payload, default_policy) - mock_scan.assert_called_once() - assert result.action == DecisionAction.ASK - assert result.event_type == AiHookEventType.FILE_READ - assert '.env' in result.user_message - - assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2 - first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0] - assert first_event.args[2] == AIHookOutcome.WARNED - assert first_event.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH - second_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[1] - assert second_event.args[2] == AIHookOutcome.WARNED - assert second_event.kwargs['block_reason'] == BlockReason.SECRETS_IN_FILE + assert result == HookDecision.allow(AiHookEventType.FILE_READ) + 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.ALLOWED + assert call_args.kwargs['block_reason'] is None + assert call_args.kwargs['scan_id'] == 'scan-id-123' @patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') @patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets') -def test_handle_before_read_file_sensitive_path_scan_disabled_warns( +def test_handle_before_read_file_sensitive_path_scans_with_content_scan_off( mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] ) -> None: - """Test that sensitive path in warn mode with scan disabled emits a single event.""" + """Secrets-in-file being Off does not switch off the sensitive-path guardrail's own scan.""" mock_is_denied.return_value = True - default_policy['file_read']['path_action'] = 'warn' + mock_scan.return_value = ScanOutcome(scan_id='scan-id-123') default_policy['file_read']['scan_content'] = False payload = AIHookPayload( event_name='FileRead', @@ -350,17 +328,10 @@ def test_handle_before_read_file_sensitive_path_scan_disabled_warns( file_path='/path/to/.env', ) - result = handle_before_read_file(mock_ctx, payload, default_policy) + handle_before_read_file(mock_ctx, payload, default_policy) - mock_scan.assert_not_called() - assert result.action == DecisionAction.ASK - assert result.event_type == AiHookEventType.FILE_READ - assert '.env' 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.WARNED - assert call_args.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + mock_scan.assert_called_once() + assert mock_scan.call_args.kwargs['guardrail'] == BlockReason.SENSITIVE_PATH def test_scan_path_for_secrets_directory( @@ -618,6 +589,21 @@ def test_build_ai_guardrails_scan_parameters( } +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.get_serial_number', return_value='SER-123') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.get_hostname', return_value='test-host') +def test_build_ai_guardrails_scan_parameters_names_the_given_guardrail( + mock_hostname: MagicMock, mock_serial: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload +) -> None: + """A sensitive-path scan is labelled with its own guardrail, so the server applies its floors.""" + mock_ctx.info_name = 'ai_guardrails' + + params = build_ai_guardrails_scan_parameters( + mock_ctx, None, mock_payload, AiHookEventType.FILE_READ, BlockReason.SENSITIVE_PATH + ) + + assert params['metadata']['ai_guardrails']['detection_source'] == 'sensitive_path' + + @patch('cycode.cli.apps.ai_guardrails.scan.handlers._perform_scan') def test_scan_text_for_secrets_injects_ai_guardrails_scan_parameter( mock_perform_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload From d98fd08e08281565ce62c564034d6b1c64b2be05 Mon Sep 17 00:00:00 2001 From: Nikita Fishbakh Date: Mon, 5 Oct 2026 16:02:37 +0200 Subject: [PATCH 2/3] CM-73723: Name the judging guardrail explicitly on every guardrail scan Scan helpers take a required guardrail instead of deriving detection_source from the event type, and the get_effective_mode helper left unused by the path_action removal is gone. Co-Authored-By: Claude Opus 5.5 --- .../cli/apps/ai_guardrails/scan/handlers.py | 52 ++++++------------- .../scan/test_guardrail_config.py | 1 - .../ai_guardrails/scan/test_handlers.py | 27 ++++------ 3 files changed, 25 insertions(+), 55 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 6fbdee85..18f13831 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: from cycode.cli.apps.ai_guardrails.scan.guardrail_config import GuardrailConfig -from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode, PolicyMode +from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode from cycode.cli.apps.ai_guardrails.ides.base import HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value @@ -81,17 +81,12 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli error_message = None try: - scan_outcome = _scan_text_for_secrets( - ctx, - clipped, - timeout_ms, - payload=payload, - event_type=AiHookEventType.PROMPT, - ) + guardrail = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.PROMPT] + scan_outcome = _scan_text_for_secrets(ctx, clipped, timeout_ms, payload=payload, guardrail=guardrail) scan_id = scan_outcome.scan_id if scan_outcome.violation_summary: - block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.PROMPT] + block_reason = guardrail if scan_outcome.verdict == GuardrailsMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'Remove secrets before sending. {scan_outcome.violation_summary}' @@ -125,7 +120,6 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: """ ai_client = ctx.obj['ai_security_client'] - file_read_config = get_policy_value(policy, 'file_read', default={}) file_path = payload.file_path or '' scan_id = None @@ -136,7 +130,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: try: if is_denied_path(file_path, policy): guardrail = BlockReason.SENSITIVE_PATH - elif get_policy_value(file_read_config, 'scan_content', default=True): + elif get_policy_value(policy, 'file_read', 'scan_content', default=True): guardrail = BlockReason.SECRETS_IN_FILE else: return HookDecision.allow(AiHookEventType.FILE_READ) @@ -218,16 +212,11 @@ def _handle_arg_scan( error_message = None try: - scan_outcome = _scan_text_for_secrets( - ctx, - clipped, - timeout_ms, - payload=payload, - event_type=feature.event_type, - ) + guardrail = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[feature.event_type] + scan_outcome = _scan_text_for_secrets(ctx, clipped, timeout_ms, payload=payload, guardrail=guardrail) scan_id = scan_outcome.scan_id if scan_outcome.violation_summary: - block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[feature.event_type] + block_reason = guardrail if scan_outcome.verdict == GuardrailsMode.BLOCK: outcome = AIHookOutcome.BLOCKED return HookDecision.deny( @@ -291,12 +280,6 @@ def get_handler_for_event(event_type: str) -> Optional[HandlerFn]: return handlers.get(event_type) -def get_effective_mode(feature_config: dict, action_key: str = 'action') -> GuardrailsMode: - """A guardrail's action is its matrix cell: block, or warn (report) for everything else.""" - action = get_policy_value(feature_config, action_key, default=PolicyMode.BLOCK) - return GuardrailsMode.BLOCK if action == PolicyMode.BLOCK else GuardrailsMode.REPORT - - def should_detach_scan( config: Optional['GuardrailConfig'], policy: dict, @@ -320,14 +303,13 @@ def build_ai_guardrails_scan_parameters( ctx: typer.Context, paths: Optional[tuple[str, ...]], payload: AIHookPayload, - event_type: AiHookEventType, - guardrail: Optional[BlockReason] = None, + guardrail: BlockReason, ) -> dict: - """The scan parameters; `guardrail` (default: the event's secrets guardrail) picks the floors the server applies.""" + """The scan parameters; `guardrail` travels as detection_source and picks the floors the server applies.""" scan_parameters = get_scan_parameters(ctx, paths) scan_parameters.setdefault('metadata', {})['ai_guardrails'] = { 'ide_provider': payload.ide_provider, - 'detection_source': (guardrail or SECRETS_BLOCK_REASON_BY_EVENT_TYPE[event_type]).value, + 'detection_source': guardrail.value, 'device_id': get_serial_number(), 'device_hostname': get_hostname(), 'conversation_id': payload.conversation_id, @@ -403,16 +385,16 @@ def _scan_text_for_secrets( text: str, timeout_ms: int, payload: AIHookPayload, - event_type: AiHookEventType, + guardrail: BlockReason, ) -> ScanOutcome: - """Scan text content for secrets using Cycode CLI.""" + """Scan text content for secrets, judged by `guardrail`'s floors.""" if not text: return NO_SCAN document = Document(path='prompt-content.txt', content=text, is_git_diff_format=False) scan_ctx = _setup_scan_context(ctx) timeout_seconds = timeout_ms / 1000.0 - scan_parameters = build_ai_guardrails_scan_parameters(scan_ctx, None, payload, event_type) + scan_parameters = build_ai_guardrails_scan_parameters(scan_ctx, None, payload, guardrail) return _perform_scan(scan_ctx, [document], scan_parameters, timeout_seconds) @@ -421,7 +403,7 @@ def _scan_path_for_secrets( file_path: str, policy: dict, payload: AIHookPayload, - guardrail: BlockReason = BlockReason.SECRETS_IN_FILE, + guardrail: BlockReason, ) -> ScanOutcome: """Scan a file path for secrets, judged by `guardrail`'s floors.""" if not file_path or not os.path.isfile(file_path): @@ -441,7 +423,5 @@ def _scan_path_for_secrets( document = Document(path=os.path.basename(file_path), content=content, is_git_diff_format=False) scan_ctx = _setup_scan_context(ctx) - scan_parameters = build_ai_guardrails_scan_parameters( - scan_ctx, (file_path,), payload, AiHookEventType.FILE_READ, guardrail - ) + scan_parameters = build_ai_guardrails_scan_parameters(scan_ctx, (file_path,), payload, guardrail) return _perform_scan(scan_ctx, [document], scan_parameters, timeout_seconds) diff --git a/tests/cli/commands/ai_guardrails/scan/test_guardrail_config.py b/tests/cli/commands/ai_guardrails/scan/test_guardrail_config.py index 086982bc..950c0dbc 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_guardrail_config.py +++ b/tests/cli/commands/ai_guardrails/scan/test_guardrail_config.py @@ -125,7 +125,6 @@ def test_apply_platform_config_sensitive_path_block_keeps_its_globs_and_content_ apply_platform_config(policy, config, 'cursor') assert policy['file_read']['action'] == 'warn' assert policy['file_read']['deny_globs'] == config.sensitive_globs() - assert 'path_action' not in policy['file_read'] def test_apply_platform_config_off_cells_disable_subfeatures() -> None: diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 6eca3eab..b7cbea83 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -17,7 +17,6 @@ _scan_path_for_secrets, _scan_text_for_secrets, build_ai_guardrails_scan_parameters, - get_effective_mode, handle_before_mcp_execution, handle_before_read_file, handle_before_submit_prompt, @@ -340,7 +339,9 @@ def test_scan_path_for_secrets_directory( """Test that _scan_path_for_secrets returns (None, None) for directories.""" fs.create_dir('/path/to/some_directory') - result = _scan_path_for_secrets(mock_ctx, '/path/to/some_directory', default_policy, payload=mock_payload) + result = _scan_path_for_secrets( + mock_ctx, '/path/to/some_directory', default_policy, payload=mock_payload, guardrail=BlockReason.SECRETS_IN_FILE + ) assert result == ScanOutcome() @@ -364,7 +365,9 @@ def test_scan_path_for_secrets_skips_path_configured_in_exclusions( 'cycode.cli.files_collector.file_excluder.configuration_manager.get_exclusions_by_scan_type', return_value={'paths': [excluded_dir]}, ): - result = _scan_path_for_secrets(mock_ctx, file_path, default_policy, payload=mock_payload) + result = _scan_path_for_secrets( + mock_ctx, file_path, default_policy, payload=mock_payload, guardrail=BlockReason.SECRETS_IN_FILE + ) assert result == ScanOutcome() mock_perform_scan.assert_not_called() @@ -553,16 +556,6 @@ def test_handle_before_mcp_execution_with_secrets_warned( assert call_args.args[2] == AIHookOutcome.WARNED -def test_get_effective_mode_reads_the_guardrails_action() -> None: - assert get_effective_mode({'action': 'block'}) == GuardrailsMode.BLOCK - assert get_effective_mode({'action': 'warn'}) == GuardrailsMode.REPORT - assert get_effective_mode({}) == GuardrailsMode.BLOCK - # A feature may carry more than one action; the caller picks which cell to read. - feature = {'action': 'warn', 'path_action': 'block'} - assert get_effective_mode(feature) == GuardrailsMode.REPORT - assert get_effective_mode(feature, action_key='path_action') == GuardrailsMode.BLOCK - - @patch('cycode.cli.apps.ai_guardrails.scan.handlers.get_serial_number', return_value='SER-123') @patch('cycode.cli.apps.ai_guardrails.scan.handlers.get_hostname', return_value='test-host') def test_build_ai_guardrails_scan_parameters( @@ -571,7 +564,7 @@ def test_build_ai_guardrails_scan_parameters( """The built scan parameters embed the full hook context alongside the standard scan parameters.""" mock_ctx.info_name = 'ai_guardrails' - params = build_ai_guardrails_scan_parameters(mock_ctx, None, mock_payload, AiHookEventType.PROMPT) + params = build_ai_guardrails_scan_parameters(mock_ctx, None, mock_payload, BlockReason.SECRETS_IN_PROMPT) assert params['command_type'] == 'ai_guardrails' assert params['metadata']['ai_guardrails'] == { @@ -597,9 +590,7 @@ def test_build_ai_guardrails_scan_parameters_names_the_given_guardrail( """A sensitive-path scan is labelled with its own guardrail, so the server applies its floors.""" mock_ctx.info_name = 'ai_guardrails' - params = build_ai_guardrails_scan_parameters( - mock_ctx, None, mock_payload, AiHookEventType.FILE_READ, BlockReason.SENSITIVE_PATH - ) + params = build_ai_guardrails_scan_parameters(mock_ctx, None, mock_payload, BlockReason.SENSITIVE_PATH) assert params['metadata']['ai_guardrails']['detection_source'] == 'sensitive_path' @@ -617,7 +608,7 @@ def test_scan_text_for_secrets_injects_ai_guardrails_scan_parameter( 'some text', 1000, payload=mock_payload, - event_type=AiHookEventType.PROMPT, + guardrail=BlockReason.SECRETS_IN_PROMPT, ) ai_guardrails = mock_perform_scan.call_args.args[2]['metadata']['ai_guardrails'] From 7c89b03388a083f48cb0ad902f2131decccd0559 Mon Sep 17 00:00:00 2001 From: Nikita Fishbakh Date: Mon, 5 Oct 2026 16:29:25 +0200 Subject: [PATCH 3/3] CM-73723: Shorten the comments added for sensitive-path thresholds Co-Authored-By: Claude Opus 5.5 --- .../cli/apps/ai_guardrails/scan/guardrail_config.py | 6 ++---- cycode/cli/apps/ai_guardrails/scan/handlers.py | 13 ++++--------- .../ai_guardrails/scan/test_guardrail_config.py | 2 +- .../commands/ai_guardrails/scan/test_handlers.py | 12 ++++++------ 4 files changed, 13 insertions(+), 20 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/guardrail_config.py b/cycode/cli/apps/ai_guardrails/scan/guardrail_config.py index a8debc17..b40cb4f9 100644 --- a/cycode/cli/apps/ai_guardrails/scan/guardrail_config.py +++ b/cycode/cli/apps/ai_guardrails/scan/guardrail_config.py @@ -100,10 +100,8 @@ def apply_platform_config(policy: dict, config: Optional[GuardrailConfig], ide_n The platform is the only mode source: no cache (cold start) means the built-in defaults - Report everywhere with the default globs - which equal an unconfigured tenant's platform - config, so behaviour is uniform either way. The two FileRead guardrails keep independent modes: - a sensitive path is scanned under its own guardrail, so the server's verdict carries the - sensitive-path cell, and only its Off (no globs) lands here. An all-Off event never reaches - here at all: scan_command skips it. + config, so behaviour is uniform either way. The sensitive-path mode comes back in the server's + verdict. An all-Off event never reaches here at all: scan_command skips it. """ def cell(guardrail_key: str) -> str: diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 18f13831..357345a0 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -112,12 +112,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: - """Scan the file content for secrets, judged by the sensitive-path guardrail when the path is sensitive. - - A sensitive path is scanned under its own guardrail, so the server applies the sensitive-path - floors and per-agent mode to the secrets it finds - the same verdict rule as the secret - guardrails. Every other file falls to the secrets-in-file guardrail. - """ + """Scan the file for secrets under the sensitive-path or secrets-in-file guardrail.""" ai_client = ctx.obj['ai_security_client'] file_path = payload.file_path or '' @@ -305,7 +300,7 @@ def build_ai_guardrails_scan_parameters( payload: AIHookPayload, guardrail: BlockReason, ) -> dict: - """The scan parameters; `guardrail` travels as detection_source and picks the floors the server applies.""" + """`guardrail` is sent as detection_source and picks the server's floors.""" scan_parameters = get_scan_parameters(ctx, paths) scan_parameters.setdefault('metadata', {})['ai_guardrails'] = { 'ide_provider': payload.ide_provider, @@ -387,7 +382,7 @@ def _scan_text_for_secrets( payload: AIHookPayload, guardrail: BlockReason, ) -> ScanOutcome: - """Scan text content for secrets, judged by `guardrail`'s floors.""" + """Scan text for secrets.""" if not text: return NO_SCAN @@ -405,7 +400,7 @@ def _scan_path_for_secrets( payload: AIHookPayload, guardrail: BlockReason, ) -> ScanOutcome: - """Scan a file path for secrets, judged by `guardrail`'s floors.""" + """Scan a file for secrets.""" if not file_path or not os.path.isfile(file_path): return NO_SCAN diff --git a/tests/cli/commands/ai_guardrails/scan/test_guardrail_config.py b/tests/cli/commands/ai_guardrails/scan/test_guardrail_config.py index 950c0dbc..3c241178 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_guardrail_config.py +++ b/tests/cli/commands/ai_guardrails/scan/test_guardrail_config.py @@ -119,7 +119,7 @@ def test_apply_platform_config_block_cell_sets_block_action() -> None: def test_apply_platform_config_sensitive_path_block_keeps_its_globs_and_content_action() -> None: - # The sensitive-path cell travels with its scan (the server's verdict), so it never sets the content action. + # The sensitive-path mode never sets the content action. policy: dict = {} config = _config(file_read='Report', sensitive_path='Block') apply_platform_config(policy, config, 'cursor') diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index b7cbea83..d0692990 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -164,7 +164,7 @@ def test_handle_before_submit_prompt_scan_failure_fail_closed( def test_handle_before_read_file_sensitive_path_blocked_by_verdict( mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] ) -> None: - """A sensitive path is scanned under its own guardrail, and blocked when the verdict says so.""" + """A Block verdict blocks a sensitive path.""" mock_is_denied.return_value = True mock_scan.return_value = ScanOutcome('Found 1 secret: API key', 'scan-id-456', GuardrailsMode.BLOCK) payload = AIHookPayload( @@ -234,7 +234,7 @@ def test_handle_before_read_file_with_secrets( assert call_args.kwargs['block_reason'] == BlockReason.SECRETS_IN_FILE assert call_args.kwargs['file_path'] == '/path/to/file.txt' - # A Report verdict never blocks; the developer is asked instead + # A Report verdict asks instead mock_scan.return_value = ScanOutcome('Found 1 secret: password', 'scan-id-456', GuardrailsMode.REPORT) result = handle_before_read_file(mock_ctx, payload, default_policy) @@ -269,7 +269,7 @@ def test_handle_before_read_file_scan_disabled( def test_handle_before_read_file_sensitive_path_report_verdict_asks( mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] ) -> None: - """Secrets under the sensitive-path block floor are reported and the developer is asked.""" + """A Report verdict asks.""" mock_is_denied.return_value = True mock_scan.return_value = ScanOutcome('Found 1 secret: API key', 'scan-id-456', GuardrailsMode.REPORT) payload = AIHookPayload( @@ -293,7 +293,7 @@ def test_handle_before_read_file_sensitive_path_report_verdict_asks( def test_handle_before_read_file_sensitive_path_without_secrets_is_allowed( mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] ) -> None: - """Like the secret guardrails, a sensitive path with nothing found is allowed and not flagged.""" + """No secrets means allowed.""" mock_is_denied.return_value = True mock_scan.return_value = ScanOutcome(scan_id='scan-id-123') payload = AIHookPayload( @@ -317,7 +317,7 @@ def test_handle_before_read_file_sensitive_path_without_secrets_is_allowed( def test_handle_before_read_file_sensitive_path_scans_with_content_scan_off( mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] ) -> None: - """Secrets-in-file being Off does not switch off the sensitive-path guardrail's own scan.""" + """Secrets-in-file Off still scans sensitive paths.""" mock_is_denied.return_value = True mock_scan.return_value = ScanOutcome(scan_id='scan-id-123') default_policy['file_read']['scan_content'] = False @@ -587,7 +587,7 @@ def test_build_ai_guardrails_scan_parameters( def test_build_ai_guardrails_scan_parameters_names_the_given_guardrail( mock_hostname: MagicMock, mock_serial: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload ) -> None: - """A sensitive-path scan is labelled with its own guardrail, so the server applies its floors.""" + """The guardrail becomes the detection_source.""" mock_ctx.info_name = 'ai_guardrails' params = build_ai_guardrails_scan_parameters(mock_ctx, None, mock_payload, BlockReason.SENSITIVE_PATH)