Skip to content
Closed
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
6 changes: 2 additions & 4 deletions cycode/cli/apps/ai_guardrails/scan/guardrail_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,8 @@ def apply_platform_config(policy: dict, config: Optional[GuardrailConfig], ide_n

The platform is the only mode source: no cache (cold start) means the built-in defaults -
Report everywhere with the default globs - which equal an unconfigured tenant's platform
config, so behaviour is uniform either way. Each matrix cell lands on its own per-feature
action, so the two FileRead guardrails (content scan vs. sensitive path) keep independent modes.
An all-Off event never reaches here at all: scan_command skips it.
config, so behaviour is uniform either way. The sensitive-path mode comes back in the server's
verdict. An all-Off event never reaches here at all: scan_command skips it.
"""

def cell(guardrail_key: str) -> str:
Expand All @@ -121,7 +120,6 @@ def action(guardrail_key: str) -> str:
if cell(BlockReason.SENSITIVE_PATH) != GuardrailCellMode.OFF
else []
)
file_read['path_action'] = action(BlockReason.SENSITIVE_PATH)

policy.setdefault('mcp', {})['action'] = action(BlockReason.SECRETS_IN_MCP_ARGS)

Expand Down
127 changes: 43 additions & 84 deletions cycode/cli/apps/ai_guardrails/scan/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
if TYPE_CHECKING:
from cycode.cli.apps.ai_guardrails.scan.guardrail_config import GuardrailConfig

from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode, PolicyMode
from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode
from cycode.cli.apps.ai_guardrails.ides.base import HookDecision
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value
Expand Down Expand Up @@ -81,17 +81,12 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
error_message = None

try:
scan_outcome = _scan_text_for_secrets(
ctx,
clipped,
timeout_ms,
payload=payload,
event_type=AiHookEventType.PROMPT,
)
guardrail = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.PROMPT]
scan_outcome = _scan_text_for_secrets(ctx, clipped, timeout_ms, payload=payload, guardrail=guardrail)
scan_id = scan_outcome.scan_id

if scan_outcome.violation_summary:
block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.PROMPT]
block_reason = guardrail
if scan_outcome.verdict == GuardrailsMode.BLOCK:
outcome = AIHookOutcome.BLOCKED
user_message = f'Remove secrets before sending. {scan_outcome.violation_summary}'
Expand All @@ -117,74 +112,47 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli


def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision:
"""Block sensitive paths and scan file content for secrets."""
"""Scan the file for secrets under the sensitive-path or secrets-in-file guardrail."""
ai_client = ctx.obj['ai_security_client']

file_read_config = get_policy_value(policy, 'file_read', default={})
file_path = payload.file_path or ''
path_mode = get_effective_mode(file_read_config, action_key='path_action')

scan_id = None
block_reason = None
outcome = AIHookOutcome.ALLOWED
error_message = None

try:
is_sensitive_path = is_denied_path(file_path, policy)
if is_sensitive_path:
block_reason = BlockReason.SENSITIVE_PATH
if path_mode == GuardrailsMode.BLOCK:
outcome = AIHookOutcome.BLOCKED
user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).'
return HookDecision.deny(
AiHookEventType.FILE_READ,
user_message,
'This file path is classified as sensitive; do not read/send it to the model.',
)
# 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.
outcome = AIHookOutcome.WARNED
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,
)
block_reason = None
outcome = AIHookOutcome.ALLOWED

if get_policy_value(file_read_config, 'scan_content', default=True):
scan_outcome = _scan_path_for_secrets(ctx, file_path, policy, payload=payload)
scan_id = scan_outcome.scan_id
if scan_outcome.violation_summary:
block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.FILE_READ]
if scan_outcome.verdict == GuardrailsMode.BLOCK:
outcome = AIHookOutcome.BLOCKED
user_message = f'Cycode blocked reading {file_path}. {scan_outcome.violation_summary}'
return HookDecision.deny(
AiHookEventType.FILE_READ,
user_message,
'Secrets detected; do not send this file to the model.',
)
outcome = AIHookOutcome.WARNED
user_message = f'Cycode detected secrets in {file_path}. {scan_outcome.violation_summary}'
return HookDecision.ask(
AiHookEventType.FILE_READ,
user_message,
'Possible secrets detected; proceed with caution.',
)

if is_sensitive_path:
user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?'
return HookDecision.ask(
if is_denied_path(file_path, policy):
guardrail = BlockReason.SENSITIVE_PATH
elif get_policy_value(policy, 'file_read', 'scan_content', default=True):
guardrail = BlockReason.SECRETS_IN_FILE
else:
return HookDecision.allow(AiHookEventType.FILE_READ)

scan_outcome = _scan_path_for_secrets(ctx, file_path, policy, payload=payload, guardrail=guardrail)
scan_id = scan_outcome.scan_id
if not scan_outcome.violation_summary:
return HookDecision.allow(AiHookEventType.FILE_READ)

block_reason = guardrail
subject = f'the sensitive file {file_path}' if guardrail == BlockReason.SENSITIVE_PATH else file_path
if scan_outcome.verdict == GuardrailsMode.BLOCK:
outcome = AIHookOutcome.BLOCKED
user_message = f'Cycode blocked reading {subject}. {scan_outcome.violation_summary}'
return HookDecision.deny(
AiHookEventType.FILE_READ,
user_message,
'This file path is classified as sensitive; proceed with caution.',
'Secrets detected; do not send this file to the model.',
)

return HookDecision.allow(AiHookEventType.FILE_READ)
outcome = AIHookOutcome.WARNED
user_message = f'Cycode detected secrets in {subject}. {scan_outcome.violation_summary}'
return HookDecision.ask(
AiHookEventType.FILE_READ,
user_message,
'Possible secrets detected; proceed with caution.',
)
except Exception as e:
outcome = (
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
Expand Down Expand Up @@ -239,16 +207,11 @@ def _handle_arg_scan(
error_message = None

try:
scan_outcome = _scan_text_for_secrets(
ctx,
clipped,
timeout_ms,
payload=payload,
event_type=feature.event_type,
)
guardrail = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[feature.event_type]
scan_outcome = _scan_text_for_secrets(ctx, clipped, timeout_ms, payload=payload, guardrail=guardrail)
scan_id = scan_outcome.scan_id
if scan_outcome.violation_summary:
block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[feature.event_type]
block_reason = guardrail
if scan_outcome.verdict == GuardrailsMode.BLOCK:
outcome = AIHookOutcome.BLOCKED
return HookDecision.deny(
Expand Down Expand Up @@ -312,12 +275,6 @@ def get_handler_for_event(event_type: str) -> Optional[HandlerFn]:
return handlers.get(event_type)


def get_effective_mode(feature_config: dict, action_key: str = 'action') -> GuardrailsMode:
"""A guardrail's action is its matrix cell: block, or warn (report) for everything else."""
action = get_policy_value(feature_config, action_key, default=PolicyMode.BLOCK)
return GuardrailsMode.BLOCK if action == PolicyMode.BLOCK else GuardrailsMode.REPORT


def should_detach_scan(
config: Optional['GuardrailConfig'],
policy: dict,
Expand All @@ -341,12 +298,13 @@ def build_ai_guardrails_scan_parameters(
ctx: typer.Context,
paths: Optional[tuple[str, ...]],
payload: AIHookPayload,
event_type: AiHookEventType,
guardrail: BlockReason,
) -> dict:
"""`guardrail` is sent as detection_source and picks the server's floors."""
scan_parameters = get_scan_parameters(ctx, paths)
scan_parameters.setdefault('metadata', {})['ai_guardrails'] = {
'ide_provider': payload.ide_provider,
'detection_source': SECRETS_BLOCK_REASON_BY_EVENT_TYPE[event_type].value,
'detection_source': guardrail.value,
'device_id': get_serial_number(),
'device_hostname': get_hostname(),
'conversation_id': payload.conversation_id,
Expand Down Expand Up @@ -422,16 +380,16 @@ def _scan_text_for_secrets(
text: str,
timeout_ms: int,
payload: AIHookPayload,
event_type: AiHookEventType,
guardrail: BlockReason,
) -> ScanOutcome:
"""Scan text content for secrets using Cycode CLI."""
"""Scan text for secrets."""
if not text:
return NO_SCAN

document = Document(path='prompt-content.txt', content=text, is_git_diff_format=False)
scan_ctx = _setup_scan_context(ctx)
timeout_seconds = timeout_ms / 1000.0
scan_parameters = build_ai_guardrails_scan_parameters(scan_ctx, None, payload, event_type)
scan_parameters = build_ai_guardrails_scan_parameters(scan_ctx, None, payload, guardrail)
return _perform_scan(scan_ctx, [document], scan_parameters, timeout_seconds)


Expand All @@ -440,8 +398,9 @@ def _scan_path_for_secrets(
file_path: str,
policy: dict,
payload: AIHookPayload,
guardrail: BlockReason,
) -> ScanOutcome:
"""Scan a file path for secrets."""
"""Scan a file for secrets."""
if not file_path or not os.path.isfile(file_path):
return NO_SCAN

Expand All @@ -459,5 +418,5 @@ def _scan_path_for_secrets(

document = Document(path=os.path.basename(file_path), content=content, is_git_diff_format=False)
scan_ctx = _setup_scan_context(ctx)
scan_parameters = build_ai_guardrails_scan_parameters(scan_ctx, (file_path,), payload, AiHookEventType.FILE_READ)
scan_parameters = build_ai_guardrails_scan_parameters(scan_ctx, (file_path,), payload, guardrail)
return _perform_scan(scan_ctx, [document], scan_parameters, timeout_seconds)
15 changes: 5 additions & 10 deletions tests/cli/commands/ai_guardrails/scan/test_guardrail_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ def test_apply_platform_config_without_cache_uses_report_defaults() -> None:

assert policy['prompt']['action'] == 'warn'
assert policy['file_read']['action'] == 'warn'
assert policy['file_read']['path_action'] == 'warn'
assert policy['mcp']['action'] == 'warn'
assert policy['file_read']['deny_globs'] == DEFAULT_SENSITIVE_PATH_GLOBS

Expand All @@ -119,17 +118,13 @@ def test_apply_platform_config_block_cell_sets_block_action() -> None:
assert policy['mcp']['action'] == 'warn'


def test_apply_platform_config_file_read_cells_keep_independent_actions() -> None:
# Content scan and sensitive path share the FileRead event but are separate matrix cells.
def test_apply_platform_config_sensitive_path_block_keeps_its_globs_and_content_action() -> None:
# The sensitive-path mode never sets the content action.
policy: dict = {}
apply_platform_config(policy, _config(file_read='Block', sensitive_path='Report'), 'cursor')
assert policy['file_read']['action'] == 'block'
assert policy['file_read']['path_action'] == 'warn'

policy = {}
apply_platform_config(policy, _config(file_read='Report', sensitive_path='Block'), 'cursor')
config = _config(file_read='Report', sensitive_path='Block')
apply_platform_config(policy, config, 'cursor')
assert policy['file_read']['action'] == 'warn'
assert policy['file_read']['path_action'] == 'block'
assert policy['file_read']['deny_globs'] == config.sensitive_globs()


def test_apply_platform_config_off_cells_disable_subfeatures() -> None:
Expand Down
Loading
Loading