Skip to content

Commit c404e4c

Browse files
Ilanlidoclaude
andauthored
CM-61568: Fix sensitive path skipping content scan and directory hand… (#432)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5c6876a commit c404e4c

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 (allowed, no secrets found)
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.ALLOWED
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)