From 4c92fb4b3d5a1b840779f8f4ad1d150ea9dc3116 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Sun, 5 Apr 2026 17:47:31 +0300 Subject: [PATCH 1/2] CM-61568: Fix sensitive path skipping content scan and directory handling In warn mode, sensitive paths returned early without scanning file contents for secrets. Now falls through to content scan and emits separate events for the sensitive path finding and the content scan result. Also fixes _scan_path_for_secrets failing on directories by using os.path.isfile(). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/apps/ai_guardrails/scan/handlers.py | 34 ++++-- .../ai_guardrails/scan/test_handlers.py | 103 ++++++++++++++++++ 2 files changed, 128 insertions(+), 9 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 8c0a2ce7..99fa29c8 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -116,7 +116,8 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: try: # Check path-based denylist first - if is_denied_path(file_path, policy): + is_sensitive_path = is_denied_path(file_path, policy) + if is_sensitive_path: block_reason = BlockReason.SENSITIVE_PATH if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED @@ -125,13 +126,21 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: user_message, 'This file path is classified as sensitive; do not read/send it to the model.', ) - # Warn mode - ask user for permission + # 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. + # If content scan is disabled, a single event (from finally) is enough. outcome = AIHookOutcome.WARNED - user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?' - return response_builder.ask_permission( - user_message, - 'This file path is classified as sensitive; proceed with caution.', - ) + 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, + ) + # Reset for the content scan result tracked by the finally block + block_reason = None + outcome = AIHookOutcome.ALLOWED # Scan file content if enabled if get_policy_value(file_read_config, 'scan_content', default=True): @@ -152,7 +161,14 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: user_message, 'Possible secrets detected; proceed with caution.', ) - return response_builder.allow_permission() + + # If path was sensitive but content scan found no secrets (or scan disabled), still warn + if is_sensitive_path: + user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?' + return response_builder.ask_permission( + user_message, + 'This file path is classified as sensitive; proceed with caution.', + ) return response_builder.allow_permission() except Exception as e: @@ -342,7 +358,7 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean. Raises exception on error or timeout. """ - if not file_path or not os.path.exists(file_path): + if not file_path or not os.path.isfile(file_path): return None, None max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 1adfe25b..75a60ac0 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -263,6 +263,109 @@ def test_handle_before_read_file_scan_disabled( mock_scan.assert_not_called() +@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( + 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.""" + mock_is_denied.return_value = True + mock_scan.return_value = (None, 'scan-id-123') + default_policy['mode'] = 'warn' + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + # Content was scanned even though path is sensitive + mock_scan.assert_called_once() + # Still warns about sensitive path since no secrets found + assert result['permission'] == 'ask' + assert '.env' in result['user_message'] + + # Two events: sensitive path warn + content scan result (no secrets, but still warned due to path) + 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'] is None + + +@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( + 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.""" + mock_is_denied.return_value = True + mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-456') + default_policy['mode'] = 'warn' + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + mock_scan.assert_called_once() + assert result['permission'] == 'ask' + assert 'Found 1 secret: API key' in result['user_message'] + + # Two events: sensitive path warn + secrets warn + 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 + + +@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( + 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.""" + mock_is_denied.return_value = True + default_policy['mode'] = 'warn' + default_policy['file_read']['scan_content'] = False + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + mock_scan.assert_not_called() + assert result['permission'] == 'ask' + assert '.env' in result['user_message'] + + # Single event: sensitive path warn (no separate scan event when scan is disabled) + 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 + + +def test_scan_path_for_secrets_directory(mock_ctx: MagicMock, default_policy: dict[str, Any], fs: Any) -> None: + """Test that _scan_path_for_secrets returns (None, None) for directories.""" + from cycode.cli.apps.ai_guardrails.scan.handlers import _scan_path_for_secrets + + fs.create_dir('/path/to/some_directory') + + result = _scan_path_for_secrets(mock_ctx, '/path/to/some_directory', default_policy) + + assert result == (None, None) + + # Tests for handle_before_mcp_execution From bd813ea33fd6f7b248cb1b00a5d81d40c221b507 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski Date: Sun, 5 Apr 2026 17:55:35 +0300 Subject: [PATCH 2/2] CM-61568: Fix test assertion for second event outcome Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/cli/commands/ai_guardrails/scan/test_handlers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 75a60ac0..1ef1098c 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -286,13 +286,13 @@ def test_handle_before_read_file_sensitive_path_warn_mode_scans_content( assert result['permission'] == 'ask' assert '.env' in result['user_message'] - # Two events: sensitive path warn + content scan result (no secrets, but still warned due to path) + # Two events: sensitive path warn + content scan result (allowed, no secrets found) 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.args[2] == AIHookOutcome.ALLOWED assert second_event.kwargs['block_reason'] is None