Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions cycode/cli/apps/ai_guardrails/scan/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
103 changes: 103 additions & 0 deletions tests/cli/commands/ai_guardrails/scan/test_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading