Skip to content

Commit 4c92fb4

Browse files
Ilanlidoclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 5c6876a commit 4c92fb4

File tree

2 files changed

+128
-9
lines changed

2 files changed

+128
-9
lines changed

cycode/cli/apps/ai_guardrails/scan/handlers.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
116116

117117
try:
118118
# Check path-based denylist first
119-
if is_denied_path(file_path, policy):
119+
is_sensitive_path = is_denied_path(file_path, policy)
120+
if is_sensitive_path:
120121
block_reason = BlockReason.SENSITIVE_PATH
121122
if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
122123
outcome = AIHookOutcome.BLOCKED
@@ -125,13 +126,21 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
125126
user_message,
126127
'This file path is classified as sensitive; do not read/send it to the model.',
127128
)
128-
# Warn mode - ask user for permission
129+
# Warn mode - if content scan is enabled, emit a separate event for the
130+
# sensitive path so the finally block can independently track the scan result.
131+
# If content scan is disabled, a single event (from finally) is enough.
129132
outcome = AIHookOutcome.WARNED
130-
user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?'
131-
return response_builder.ask_permission(
132-
user_message,
133-
'This file path is classified as sensitive; proceed with caution.',
134-
)
133+
if get_policy_value(file_read_config, 'scan_content', default=True):
134+
ai_client.create_event(
135+
payload,
136+
AiHookEventType.FILE_READ,
137+
outcome,
138+
block_reason=BlockReason.SENSITIVE_PATH,
139+
file_path=payload.file_path,
140+
)
141+
# Reset for the content scan result tracked by the finally block
142+
block_reason = None
143+
outcome = AIHookOutcome.ALLOWED
135144

136145
# Scan file content if enabled
137146
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:
152161
user_message,
153162
'Possible secrets detected; proceed with caution.',
154163
)
155-
return response_builder.allow_permission()
164+
165+
# If path was sensitive but content scan found no secrets (or scan disabled), still warn
166+
if is_sensitive_path:
167+
user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?'
168+
return response_builder.ask_permission(
169+
user_message,
170+
'This file path is classified as sensitive; proceed with caution.',
171+
)
156172

157173
return response_builder.allow_permission()
158174
except Exception as e:
@@ -342,7 +358,7 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) ->
342358
Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean.
343359
Raises exception on error or timeout.
344360
"""
345-
if not file_path or not os.path.exists(file_path):
361+
if not file_path or not os.path.isfile(file_path):
346362
return None, None
347363

348364
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)

tests/cli/commands/ai_guardrails/scan/test_handlers.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,109 @@ def test_handle_before_read_file_scan_disabled(
263263
mock_scan.assert_not_called()
264264

265265

266+
@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path')
267+
@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets')
268+
def test_handle_before_read_file_sensitive_path_warn_mode_scans_content(
269+
mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any]
270+
) -> None:
271+
"""Test that sensitive path in warn mode still scans file content and emits two events."""
272+
mock_is_denied.return_value = True
273+
mock_scan.return_value = (None, 'scan-id-123')
274+
default_policy['mode'] = 'warn'
275+
payload = AIHookPayload(
276+
event_name='file_read',
277+
ide_provider='cursor',
278+
file_path='/path/to/.env',
279+
)
280+
281+
result = handle_before_read_file(mock_ctx, payload, default_policy)
282+
283+
# Content was scanned even though path is sensitive
284+
mock_scan.assert_called_once()
285+
# Still warns about sensitive path since no secrets found
286+
assert result['permission'] == 'ask'
287+
assert '.env' in result['user_message']
288+
289+
# Two events: sensitive path warn + content scan result (no secrets, but still warned due to path)
290+
assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2
291+
first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0]
292+
assert first_event.args[2] == AIHookOutcome.WARNED
293+
assert first_event.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH
294+
second_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[1]
295+
assert second_event.args[2] == AIHookOutcome.WARNED
296+
assert second_event.kwargs['block_reason'] is None
297+
298+
299+
@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path')
300+
@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets')
301+
def test_handle_before_read_file_sensitive_path_warn_mode_with_secrets(
302+
mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any]
303+
) -> None:
304+
"""Test that sensitive path in warn mode reports secrets and emits two events."""
305+
mock_is_denied.return_value = True
306+
mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-456')
307+
default_policy['mode'] = 'warn'
308+
payload = AIHookPayload(
309+
event_name='file_read',
310+
ide_provider='cursor',
311+
file_path='/path/to/.env',
312+
)
313+
314+
result = handle_before_read_file(mock_ctx, payload, default_policy)
315+
316+
mock_scan.assert_called_once()
317+
assert result['permission'] == 'ask'
318+
assert 'Found 1 secret: API key' in result['user_message']
319+
320+
# Two events: sensitive path warn + secrets warn
321+
assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2
322+
first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0]
323+
assert first_event.args[2] == AIHookOutcome.WARNED
324+
assert first_event.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH
325+
second_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[1]
326+
assert second_event.args[2] == AIHookOutcome.WARNED
327+
assert second_event.kwargs['block_reason'] == BlockReason.SECRETS_IN_FILE
328+
329+
330+
@patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path')
331+
@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_path_for_secrets')
332+
def test_handle_before_read_file_sensitive_path_scan_disabled_warns(
333+
mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any]
334+
) -> None:
335+
"""Test that sensitive path in warn mode with scan disabled emits a single event."""
336+
mock_is_denied.return_value = True
337+
default_policy['mode'] = 'warn'
338+
default_policy['file_read']['scan_content'] = False
339+
payload = AIHookPayload(
340+
event_name='file_read',
341+
ide_provider='cursor',
342+
file_path='/path/to/.env',
343+
)
344+
345+
result = handle_before_read_file(mock_ctx, payload, default_policy)
346+
347+
mock_scan.assert_not_called()
348+
assert result['permission'] == 'ask'
349+
assert '.env' in result['user_message']
350+
351+
# Single event: sensitive path warn (no separate scan event when scan is disabled)
352+
mock_ctx.obj['ai_security_client'].create_event.assert_called_once()
353+
call_args = mock_ctx.obj['ai_security_client'].create_event.call_args
354+
assert call_args.args[2] == AIHookOutcome.WARNED
355+
assert call_args.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH
356+
357+
358+
def test_scan_path_for_secrets_directory(mock_ctx: MagicMock, default_policy: dict[str, Any], fs: Any) -> None:
359+
"""Test that _scan_path_for_secrets returns (None, None) for directories."""
360+
from cycode.cli.apps.ai_guardrails.scan.handlers import _scan_path_for_secrets
361+
362+
fs.create_dir('/path/to/some_directory')
363+
364+
result = _scan_path_for_secrets(mock_ctx, '/path/to/some_directory', default_policy)
365+
366+
assert result == (None, None)
367+
368+
266369
# Tests for handle_before_mcp_execution
267370

268371

0 commit comments

Comments
 (0)