Skip to content
Open
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
11 changes: 2 additions & 9 deletions cycode/cli/apps/ai_guardrails/scan/guardrail_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,11 @@
)
)

# CLI --ide names to matrix column names; identity for names not listed.
_AGENT_BY_IDE_NAME = {'claude-code': 'claude'}


def get_config_cache_path() -> Path:
return Path.home() / CYCODE_CONFIGURATION_DIRECTORY / GUARDRAILS_CONFIG_FILE_NAME


def agent_for_ide(ide_name: Optional[str]) -> str:
ide_name = (ide_name or '').lower()
return _AGENT_BY_IDE_NAME.get(ide_name, ide_name)


def _default_sensitive_globs() -> list:
return list(DEFAULT_SENSITIVE_PATH_GLOBS)

Expand All @@ -69,8 +61,9 @@ def __post_init__(self) -> None:
}

def mode_for(self, guardrail_key: str, ide_name: Optional[str]) -> str:
"""The platform keys the cells by our --ide names, so the lookup is direct."""
agents = (self._guardrails.get(guardrail_key) or {}).get('agents') or {}
return str(agents.get(agent_for_ide(ide_name), GuardrailCellMode.REPORT.value)).lower()
return str(agents.get((ide_name or '').lower(), GuardrailCellMode.REPORT.value)).lower()

def _modes_for_event(self, event_name: str, ide_name: Optional[str]) -> list:
return [
Expand Down
111 changes: 59 additions & 52 deletions cycode/cli/apps/ai_guardrails/scan/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from dataclasses import dataclass
from multiprocessing.pool import ThreadPool
from multiprocessing.pool import TimeoutError as PoolTimeoutError
from typing import TYPE_CHECKING, Callable, Optional
from typing import TYPE_CHECKING, Callable, NamedTuple, Optional

import typer

Expand Down Expand Up @@ -45,12 +45,32 @@
HandlerFn = Callable[[typer.Context, AIHookPayload, dict], HookDecision]


class ScanOutcome(NamedTuple):
"""What one guardrail scan came back with; the verdict is the server's, which applied the tenant's floors."""

violation_summary: Optional[str] = None
scan_id: Optional[str] = None
verdict: Optional[GuardrailsMode] = None


NO_SCAN = ScanOutcome()


def _parse_verdict(verdict: Optional[str]) -> Optional[GuardrailsMode]:
"""The server spells the verdict "Block"/"Report" and omits it when the scan found nothing to decide on."""
if not verdict:
return None
try:
return GuardrailsMode(verdict.lower())
except ValueError:
logger.debug('Ignoring unknown guardrail verdict, %s', {'verdict': verdict})
return None


def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision:
"""Scan prompt text for secrets before it's sent to the AI model."""
ai_client = ctx.obj['ai_security_client']

prompt_config = get_policy_value(policy, 'prompt', default={})
effective_mode = get_effective_mode(prompt_config)
prompt = payload.prompt or ''
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
Expand All @@ -62,20 +82,20 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
error_message = None

try:
violation_summary, scan_id = _scan_text_for_secrets(
scan_outcome = _scan_text_for_secrets(
ctx,
clipped,
timeout_ms,
payload=payload,
event_type=AiHookEventType.PROMPT,
effective_mode=effective_mode,
)
scan_id = scan_outcome.scan_id

if violation_summary:
if scan_outcome.violation_summary:
block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.PROMPT]
if effective_mode == GuardrailsMode.BLOCK:
if scan_outcome.verdict == GuardrailsMode.BLOCK:
outcome = AIHookOutcome.BLOCKED
user_message = f'Remove secrets before sending. {violation_summary}'
user_message = f'Remove secrets before sending. {scan_outcome.violation_summary}'
return HookDecision.deny(AiHookEventType.PROMPT, user_message)
outcome = AIHookOutcome.WARNED
return HookDecision.allow(AiHookEventType.PROMPT)
Expand Down Expand Up @@ -104,7 +124,6 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
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')
content_mode = get_effective_mode(file_read_config)

scan_id = None
block_reason = None
Expand Down Expand Up @@ -138,21 +157,20 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
outcome = AIHookOutcome.ALLOWED

if get_policy_value(file_read_config, 'scan_content', default=True):
violation_summary, scan_id = _scan_path_for_secrets(
ctx, file_path, policy, payload=payload, effective_mode=content_mode
)
if violation_summary:
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 content_mode == GuardrailsMode.BLOCK:
if scan_outcome.verdict == GuardrailsMode.BLOCK:
outcome = AIHookOutcome.BLOCKED
user_message = f'Cycode blocked reading {file_path}. {violation_summary}'
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}. {violation_summary}'
user_message = f'Cycode detected secrets in {file_path}. {scan_outcome.violation_summary}'
return HookDecision.ask(
AiHookEventType.FILE_READ,
user_message,
Expand Down Expand Up @@ -192,10 +210,9 @@ class _ArgScanFeature:
"""Configuration for a "scan some text and decide" event.

MCP execution and command exec share identical scan-and-decide logic;
only the policy key, event type, and user-facing messages differ.
only the event type and user-facing messages differ.
"""

policy_key: str # 'mcp' or 'command_exec'
event_type: AiHookEventType
deny_message: Callable[[str], str]
deny_agent_message: str
Expand All @@ -213,39 +230,37 @@ def _handle_arg_scan(
"""Shared scan + decision flow for MCP_EXECUTION and COMMAND_EXEC events."""
ai_client = ctx.obj['ai_security_client']

feature_config = get_policy_value(policy, feature.policy_key, default={})
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
clipped = truncate_utf8(scan_text, max_bytes)
effective_mode = get_effective_mode(feature_config)

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

try:
violation_summary, scan_id = _scan_text_for_secrets(
scan_outcome = _scan_text_for_secrets(
ctx,
clipped,
timeout_ms,
payload=payload,
event_type=feature.event_type,
effective_mode=effective_mode,
)
if violation_summary:
scan_id = scan_outcome.scan_id
if scan_outcome.violation_summary:
block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[feature.event_type]
if effective_mode == GuardrailsMode.BLOCK:
if scan_outcome.verdict == GuardrailsMode.BLOCK:
outcome = AIHookOutcome.BLOCKED
return HookDecision.deny(
feature.event_type,
feature.deny_message(violation_summary),
feature.deny_message(scan_outcome.violation_summary),
feature.deny_agent_message,
)
outcome = AIHookOutcome.WARNED
return HookDecision.ask(
feature.event_type,
feature.ask_message(violation_summary),
feature.ask_message(scan_outcome.violation_summary),
feature.ask_agent_message,
)

Expand Down Expand Up @@ -278,7 +293,6 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
payload,
policy,
_ArgScanFeature(
policy_key='mcp',
event_type=AiHookEventType.MCP_EXECUTION,
deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}',
deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.',
Expand Down Expand Up @@ -329,11 +343,9 @@ def build_ai_guardrails_scan_parameters(
paths: Optional[tuple[str, ...]],
payload: AIHookPayload,
event_type: AiHookEventType,
effective_mode: GuardrailsMode,
) -> dict:
scan_parameters = get_scan_parameters(ctx, paths)
scan_parameters.setdefault('metadata', {})['ai_guardrails'] = {
'mode': effective_mode.value,
'ide_provider': payload.ide_provider,
'detection_source': SECRETS_BLOCK_REASON_BY_EVENT_TYPE[event_type].value,
'device_id': get_serial_number(),
Expand All @@ -360,13 +372,13 @@ def _setup_scan_context(ctx: typer.Context) -> typer.Context:

def _perform_scan(
ctx: typer.Context, documents: list[Document], scan_parameters: dict, timeout_seconds: float
) -> tuple[Optional[str], Optional[str]]:
"""Run a scan on documents, returning (violation_summary, scan_id).
) -> ScanOutcome:
"""Run a scan on documents.

Raises on scan failure / timeout so the fail-open policy can take over.
"""
if not documents:
return None, None
return NO_SCAN

scan_batch_thread_func = _get_scan_documents_thread_func(
ctx, is_git_diff=False, is_commit_range=False, scan_parameters=scan_parameters
Expand All @@ -377,7 +389,7 @@ def _perform_scan(
with ThreadPool(processes=1) as pool:
result = pool.apply_async(scan_batch_thread_func, (documents,))
try:
scan_id, error, local_scan_result = result.get(timeout=timeout_seconds)
_, error, local_scan_result = result.get(timeout=timeout_seconds)
except PoolTimeoutError:
logger.debug('Scan timed out after %s seconds', timeout_seconds)
raise RuntimeError(f'Scan timed out after {timeout_seconds} seconds') from None
Expand All @@ -387,15 +399,14 @@ def _perform_scan(
raise RuntimeError(error.message)

if not local_scan_result:
return None, None

scan_id = local_scan_result.scan_id
return NO_SCAN

if local_scan_result.issue_detected:
violation_summary = build_violation_summary([local_scan_result])
return violation_summary, scan_id

return None, scan_id
violation_summary = build_violation_summary([local_scan_result]) if local_scan_result.issue_detected else None
return ScanOutcome(
violation_summary=violation_summary,
scan_id=local_scan_result.scan_id,
verdict=_parse_verdict(local_scan_result.verdict),
)


def _scan_text_for_secrets(
Expand All @@ -404,16 +415,15 @@ def _scan_text_for_secrets(
timeout_ms: int,
payload: AIHookPayload,
event_type: AiHookEventType,
effective_mode: GuardrailsMode,
) -> tuple[Optional[str], Optional[str]]:
) -> ScanOutcome:
"""Scan text content for secrets using Cycode CLI."""
if not text:
return None, None
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, effective_mode)
scan_parameters = build_ai_guardrails_scan_parameters(scan_ctx, None, payload, event_type)
return _perform_scan(scan_ctx, [document], scan_parameters, timeout_seconds)


Expand All @@ -422,15 +432,14 @@ def _scan_path_for_secrets(
file_path: str,
policy: dict,
payload: AIHookPayload,
effective_mode: GuardrailsMode,
) -> tuple[Optional[str], Optional[str]]:
) -> ScanOutcome:
"""Scan a file path for secrets."""
if not file_path or not os.path.isfile(file_path):
return None, None
return NO_SCAN

if is_path_configured_in_exclusions(str(ScanTypeOption.SECRET), os.path.abspath(file_path)):
logger.debug('Skipping scan; the path is in the ignore paths list, %s', {'file_path': file_path})
return None, None
return NO_SCAN

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

Expand All @@ -442,7 +451,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, effective_mode
)
scan_parameters = build_ai_guardrails_scan_parameters(scan_ctx, (file_path,), payload, AiHookEventType.FILE_READ)
return _perform_scan(scan_ctx, [document], scan_parameters, timeout_seconds)
2 changes: 2 additions & 0 deletions cycode/cli/apps/scan/scan_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def create_local_scan_result(
issue_detected=len(relevant_document_detections_list) > 0,
detections_count=detections_count,
relevant_detections_count=relevant_detections_count,
verdict=scan_result.verdict,
)


Expand Down Expand Up @@ -170,6 +171,7 @@ def get_sync_scan_result(scan_type: str, scan_results: 'ScanResultsSyncFlow') ->
did_detect=True,
detections_per_file=_map_detections_per_file_and_commit_id(scan_type, scan_results.detection_messages),
scan_id=scan_results.id,
verdict=scan_results.verdict,
)


Expand Down
1 change: 1 addition & 0 deletions cycode/cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class LocalScanResult(NamedTuple):
issue_detected: bool
detections_count: int
relevant_detections_count: int
verdict: Optional[str] = None


@dataclass
Expand Down
4 changes: 4 additions & 0 deletions cycode/cyclient/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,15 @@ def __init__(
report_url: Optional[str] = None,
scan_id: Optional[str] = None,
err: Optional[str] = None,
verdict: Optional[str] = None,
) -> None:
super().__init__()
self.did_detect = did_detect
self.detections_per_file = detections_per_file
self.scan_id = scan_id
self.report_url = report_url
self.err = err
self.verdict = verdict


class ScanResult(Schema):
Expand Down Expand Up @@ -506,6 +508,7 @@ def build_dto(self, data: dict[str, Any], **_) -> DetectionRule:
class ScanResultsSyncFlow:
id: str
detection_messages: list[dict]
verdict: Optional[str] = None


class ScanResultsSyncFlowSchema(Schema):
Expand All @@ -514,6 +517,7 @@ class Meta:

id = fields.String()
detection_messages = fields.List(fields.Dict())
verdict = fields.String(allow_none=True, load_default=None)

@post_load
def build_dto(self, data: dict[str, Any], **_) -> ScanResultsSyncFlow:
Expand Down
2 changes: 1 addition & 1 deletion tests/cli/commands/ai_guardrails/scan/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def resolved_guardrails_payload(
return {
'ttl_seconds': 900,
'guardrails': [
{'key': 'secrets_in_prompt', 'event_type': 'Prompt', 'agents': {'cursor': prompt, 'claude': 'Block'}},
{'key': 'secrets_in_prompt', 'event_type': 'Prompt', 'agents': {'cursor': prompt, 'claude-code': 'Block'}},
{'key': 'secrets_in_file', 'event_type': 'FileRead', 'agents': {'cursor': file_read}},
{
'key': 'sensitive_path',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,11 @@ def test_expired_cache_detected() -> None:
# --- lookups ---


def test_agent_mapping_claude_code_reads_claude_column() -> None:
def test_cells_are_read_by_ide_name() -> None:
config = _config()
# cursor column is Report; claude column is Block - the claude-code ide maps onto it.
# cursor column is Report; the claude-code column is Block.
assert config.can_event_block('Prompt', 'claude-code') is True
assert config.can_event_block('Prompt', 'Claude-Code') is True
assert config.can_event_block('Prompt', 'cursor') is False


Expand Down
Loading
Loading