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..1ef1098c 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 (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.ALLOWED + 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