Skip to content

Commit ce8fa23

Browse files
Ilanlidoclaude
andauthored
CM-68943 Send ai-guardrails hook context with the scan so report-mode findings become violations (#504)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9fdb9a4 commit ce8fa23

9 files changed

Lines changed: 274 additions & 42 deletions

File tree

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ This guide walks you through both installation and usage.
2828
1. [Discovering Commands](#discovering-commands)
2929
2. [Examples](#platform-examples)
3030
3. [Notes & Limitations](#platform-notes--limitations)
31-
6. [Scan Command](#scan-command)
31+
6. [AI Guardrails](#ai-guardrails-beta)
32+
1. [Data Collected by AI Guardrails](#data-collected-by-ai-guardrails)
33+
7. [Scan Command](#scan-command)
3234
1. [Running a Scan](#running-a-scan)
3335
1. [Options](#options)
3436
1. [Severity Threshold](#severity-option)
@@ -704,6 +706,32 @@ cycode platform projects list --page-size 100 | jq '.items[].name'
704706
- **Override the cache TTL** with `CYCODE_SPEC_CACHE_TTL=<seconds>`.
705707

706708

709+
# AI Guardrails \[BETA\]
710+
711+
AI Guardrails installs hooks into supported AI coding agents (Claude Code, Cursor, Copilot, Codex) so that
712+
prompts, files the agent reads, and MCP tool arguments are scanned for secrets before they reach the model.
713+
714+
## Data Collected by AI Guardrails
715+
716+
Scanning happens server-side, so the scanned content leaves the machine: the prompt text, the contents of
717+
files the agent reads, and MCP tool arguments are sent to your Cycode tenant to be checked for secrets.
718+
719+
Each event is also reported with context about the developer and the machine, so a finding can be attributed
720+
to the device and user it came from. Some of this is personal data:
721+
722+
- **Device identifiers** — the machine's hostname and hardware serial number.
723+
- **User identifiers** — the email address of the user signed in to the AI coding agent, and the local
724+
operating-system username.
725+
- **Environment details** — operating system and version, the AI agent, its version and the model in use,
726+
the contents of the agent's MCP configuration files, and its enabled plugins.
727+
728+
The hardware serial number is cached in a local temporary file, readable only by the user who ran the
729+
command, so repeated hook invocations don't re-query the hardware.
730+
731+
If collecting this data is not acceptable in your environment, do not install the guardrails hooks
732+
(`cycode ai-guardrails uninstall` removes hooks that are already installed).
733+
734+
707735
# Scan Command
708736
709737
## Running a Scan

cycode/cli/apps/ai_guardrails/consts.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@ class PolicyMode(str, Enum):
1010
WARN = 'warn'
1111

1212

13-
class InstallMode(str, Enum):
14-
"""Installation mode for ai-guardrails install command."""
13+
class GuardrailsMode(str, Enum):
14+
"""Guardrails enforcement mode.
15+
16+
Used both as the ai-guardrails install-command mode and as the per-event
17+
effective mode reported to the server (the ai_guardrails scan parameter's
18+
`mode` field)
19+
"""
1520

1621
REPORT = 'report'
1722
BLOCK = 'block'

cycode/cli/apps/ai_guardrails/ides/copilot.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,9 +333,7 @@ def entry(command: str) -> dict:
333333
return {
334334
'version': 1,
335335
'hooks': {
336-
'sessionStart': [
337-
{'type': 'command', 'command': _SESSION_START_COMMAND, 'timeoutSec': _HOOK_TIMEOUT_SEC}
338-
],
336+
'sessionStart': [{'type': 'command', 'command': _SESSION_START_COMMAND}],
339337
'userPromptSubmitted': [entry(_SCAN_PROMPT_COMMAND)],
340338
'preToolUse': [entry(_SCAN_TOOL_COMMAND)],
341339
},

cycode/cli/apps/ai_guardrails/install_command.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import typer
77

88
from cycode.cli.apps.ai_guardrails.command_utils import console, resolve_repo_path, validate_scope
9-
from cycode.cli.apps.ai_guardrails.consts import InstallMode, PolicyMode
9+
from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode, PolicyMode
1010
from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, install_hooks
1111
from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides
1212

@@ -40,14 +40,14 @@ def install_command(
4040
),
4141
] = None,
4242
mode: Annotated[
43-
InstallMode,
43+
GuardrailsMode,
4444
typer.Option(
4545
'--mode',
4646
'-m',
4747
help='Installation mode: "report" for async non-blocking hooks with warn policy, '
4848
'"block" for sync blocking hooks.',
4949
),
50-
] = InstallMode.REPORT,
50+
] = GuardrailsMode.REPORT,
5151
) -> None:
5252
"""Install AI guardrails hooks for supported IDEs.
5353
@@ -65,7 +65,7 @@ def install_command(
6565
repo_path = resolve_repo_path(scope, repo_path)
6666
ides_to_install = resolve_ides(ide)
6767

68-
report_mode = mode == InstallMode.REPORT
68+
report_mode = mode == GuardrailsMode.REPORT
6969

7070
results: list[tuple[str, bool, str]] = []
7171
for current_ide in ides_to_install:
@@ -83,7 +83,7 @@ def install_command(
8383
all_success = False
8484

8585
if any_success:
86-
policy_mode = PolicyMode.WARN if mode == InstallMode.REPORT else PolicyMode.BLOCK
86+
policy_mode = PolicyMode.WARN if mode == GuardrailsMode.REPORT else PolicyMode.BLOCK
8787
_install_policy(scope, repo_path, policy_mode)
8888
_print_next_steps(results, mode)
8989

@@ -99,15 +99,15 @@ def _install_policy(scope: str, repo_path: Optional[Path], policy_mode: PolicyMo
9999
console.print(f'[red]✗[/] {policy_message}', style='bold red')
100100

101101

102-
def _print_next_steps(results: list[tuple[str, bool, str]], mode: InstallMode) -> None:
102+
def _print_next_steps(results: list[tuple[str, bool, str]], mode: GuardrailsMode) -> None:
103103
console.print()
104104
console.print('[bold]Next steps:[/]')
105105
successful_ides = [name for name, success, _ in results if success]
106106
ide_list = ', '.join(successful_ides)
107107
console.print(f'1. Restart {ide_list} to activate the hooks')
108108
console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml')
109109
console.print()
110-
if mode == InstallMode.REPORT:
110+
if mode == GuardrailsMode.REPORT:
111111
console.print('[dim]Report mode: hooks run async (non-blocking) and policy is set to warn.[/]')
112112
else:
113113
console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]')

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

Lines changed: 88 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,29 @@
1717

1818
import typer
1919

20-
from cycode.cli.apps.ai_guardrails.consts import PolicyMode
20+
from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode, PolicyMode
2121
from cycode.cli.apps.ai_guardrails.ides.base import HookDecision
2222
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
2323
from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value
24-
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason
24+
from cycode.cli.apps.ai_guardrails.scan.types import (
25+
SECRETS_BLOCK_REASON_BY_EVENT_TYPE,
26+
AiHookEventType,
27+
AIHookOutcome,
28+
BlockReason,
29+
)
2530
from cycode.cli.apps.ai_guardrails.scan.utils import is_denied_path, truncate_utf8
2631
from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func
2732
from cycode.cli.apps.scan.scan_parameters import get_scan_parameters
2833
from cycode.cli.cli_types import ScanTypeOption, SeverityOption
2934
from cycode.cli.files_collector.file_excluder import is_path_configured_in_exclusions
3035
from cycode.cli.models import Document
36+
from cycode.cli.utils.host_info import get_hostname, get_serial_number
3137
from cycode.cli.utils.progress_bar import DummyProgressBar, ScanProgressBarSection
3238
from cycode.cli.utils.scan_utils import build_violation_summary
3339
from cycode.logger import get_logger
3440

3541
logger = get_logger('AI Guardrails')
3642

37-
3843
HandlerFn = Callable[[typer.Context, AIHookPayload, dict], HookDecision]
3944

4045

@@ -47,7 +52,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
4752
ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED)
4853
return HookDecision.allow(AiHookEventType.PROMPT)
4954

50-
mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
55+
effective_mode = get_effective_mode(policy, prompt_config)
5156
prompt = payload.prompt or ''
5257
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
5358
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
@@ -59,12 +64,18 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
5964
error_message = None
6065

6166
try:
62-
violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms)
67+
violation_summary, scan_id = _scan_text_for_secrets(
68+
ctx,
69+
clipped,
70+
timeout_ms,
71+
payload=payload,
72+
event_type=AiHookEventType.PROMPT,
73+
effective_mode=effective_mode,
74+
)
6375

6476
if violation_summary:
65-
block_reason = BlockReason.SECRETS_IN_PROMPT
66-
action = get_policy_value(prompt_config, 'action', default=PolicyMode.BLOCK)
67-
if action == PolicyMode.BLOCK and mode == PolicyMode.BLOCK:
77+
block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.PROMPT]
78+
if effective_mode == GuardrailsMode.BLOCK:
6879
outcome = AIHookOutcome.BLOCKED
6980
user_message = f'{violation_summary}. Remove secrets before sending.'
7081
return HookDecision.deny(AiHookEventType.PROMPT, user_message)
@@ -97,9 +108,8 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
97108
ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED)
98109
return HookDecision.allow(AiHookEventType.FILE_READ)
99110

100-
mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
101111
file_path = payload.file_path or ''
102-
action = get_policy_value(file_read_config, 'action', default=PolicyMode.BLOCK)
112+
effective_mode = get_effective_mode(policy, file_read_config)
103113

104114
scan_id = None
105115
block_reason = None
@@ -110,7 +120,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
110120
is_sensitive_path = is_denied_path(file_path, policy)
111121
if is_sensitive_path:
112122
block_reason = BlockReason.SENSITIVE_PATH
113-
if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
123+
if effective_mode == GuardrailsMode.BLOCK:
114124
outcome = AIHookOutcome.BLOCKED
115125
user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).'
116126
return HookDecision.deny(
@@ -133,10 +143,12 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
133143
outcome = AIHookOutcome.ALLOWED
134144

135145
if get_policy_value(file_read_config, 'scan_content', default=True):
136-
violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy)
146+
violation_summary, scan_id = _scan_path_for_secrets(
147+
ctx, file_path, policy, payload=payload, effective_mode=effective_mode
148+
)
137149
if violation_summary:
138-
block_reason = BlockReason.SECRETS_IN_FILE
139-
if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
150+
block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.FILE_READ]
151+
if effective_mode == GuardrailsMode.BLOCK:
140152
outcome = AIHookOutcome.BLOCKED
141153
user_message = f'Cycode blocked reading {file_path}. {violation_summary}'
142154
return HookDecision.deny(
@@ -191,7 +203,6 @@ class _ArgScanFeature:
191203
policy_key: str # 'mcp' or 'command_exec'
192204
scan_key: str # 'scan_arguments' or 'scan_command'
193205
event_type: AiHookEventType
194-
block_reason: BlockReason
195206
deny_message: Callable[[str], str]
196207
deny_agent_message: str
197208
ask_message: Callable[[str], str]
@@ -213,11 +224,10 @@ def _handle_arg_scan(
213224
ai_client.create_event(payload, feature.event_type, AIHookOutcome.ALLOWED)
214225
return HookDecision.allow(feature.event_type)
215226

216-
mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
217227
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
218228
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
219229
clipped = truncate_utf8(scan_text, max_bytes)
220-
action = get_policy_value(feature_config, 'action', default=PolicyMode.BLOCK)
230+
effective_mode = get_effective_mode(policy, feature_config)
221231

222232
scan_id = None
223233
block_reason = None
@@ -226,10 +236,17 @@ def _handle_arg_scan(
226236

227237
try:
228238
if get_policy_value(feature_config, feature.scan_key, default=True):
229-
violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms)
239+
violation_summary, scan_id = _scan_text_for_secrets(
240+
ctx,
241+
clipped,
242+
timeout_ms,
243+
payload=payload,
244+
event_type=feature.event_type,
245+
effective_mode=effective_mode,
246+
)
230247
if violation_summary:
231-
block_reason = feature.block_reason
232-
if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
248+
block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[feature.event_type]
249+
if effective_mode == GuardrailsMode.BLOCK:
233250
outcome = AIHookOutcome.BLOCKED
234251
return HookDecision.deny(
235252
feature.event_type,
@@ -275,7 +292,6 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
275292
policy_key='mcp',
276293
scan_key='scan_arguments',
277294
event_type=AiHookEventType.MCP_EXECUTION,
278-
block_reason=BlockReason.SECRETS_IN_MCP_ARGS,
279295
deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}',
280296
deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.',
281297
ask_message=lambda v: f'{v} in MCP tool call "{tool}". Allow execution?',
@@ -295,6 +311,36 @@ def get_handler_for_event(event_type: str) -> Optional[HandlerFn]:
295311
return handlers.get(event_type)
296312

297313

314+
def get_effective_mode(policy: dict, feature_config: dict) -> GuardrailsMode:
315+
"""The event only blocks when both the global mode and the per-guardrail action are block."""
316+
mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
317+
action = get_policy_value(feature_config, 'action', default=PolicyMode.BLOCK)
318+
return GuardrailsMode.BLOCK if (mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK) else GuardrailsMode.REPORT
319+
320+
321+
def build_ai_guardrails_scan_parameters(
322+
ctx: typer.Context,
323+
paths: Optional[tuple[str, ...]],
324+
payload: AIHookPayload,
325+
event_type: AiHookEventType,
326+
effective_mode: GuardrailsMode,
327+
) -> dict:
328+
scan_parameters = get_scan_parameters(ctx, paths)
329+
scan_parameters.setdefault('metadata', {})['ai_guardrails'] = {
330+
'mode': effective_mode.value,
331+
'ide_provider': payload.ide_provider,
332+
'detection_source': SECRETS_BLOCK_REASON_BY_EVENT_TYPE[event_type].value,
333+
'device_id': get_serial_number(),
334+
'device_hostname': get_hostname(),
335+
'conversation_id': payload.conversation_id,
336+
'generation_id': payload.generation_id,
337+
'ide_user_email': payload.ide_user_email,
338+
'mcp_server_name': payload.mcp_server_name,
339+
'mcp_tool_name': payload.mcp_tool_name,
340+
}
341+
return scan_parameters
342+
343+
298344
def _setup_scan_context(ctx: typer.Context) -> typer.Context:
299345
"""Set up minimal context for scan_documents without progress bars or printing."""
300346
ctx.obj['progress_bar'] = DummyProgressBar([ScanProgressBarSection])
@@ -345,18 +391,32 @@ def _perform_scan(
345391
return None, scan_id
346392

347393

348-
def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tuple[Optional[str], Optional[str]]:
394+
def _scan_text_for_secrets(
395+
ctx: typer.Context,
396+
text: str,
397+
timeout_ms: int,
398+
payload: AIHookPayload,
399+
event_type: AiHookEventType,
400+
effective_mode: GuardrailsMode,
401+
) -> tuple[Optional[str], Optional[str]]:
349402
"""Scan text content for secrets using Cycode CLI."""
350403
if not text:
351404
return None, None
352405

353406
document = Document(path='prompt-content.txt', content=text, is_git_diff_format=False)
354407
scan_ctx = _setup_scan_context(ctx)
355408
timeout_seconds = timeout_ms / 1000.0
356-
return _perform_scan(scan_ctx, [document], get_scan_parameters(scan_ctx, None), timeout_seconds)
409+
scan_parameters = build_ai_guardrails_scan_parameters(scan_ctx, None, payload, event_type, effective_mode)
410+
return _perform_scan(scan_ctx, [document], scan_parameters, timeout_seconds)
357411

358412

359-
def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> tuple[Optional[str], Optional[str]]:
413+
def _scan_path_for_secrets(
414+
ctx: typer.Context,
415+
file_path: str,
416+
policy: dict,
417+
payload: AIHookPayload,
418+
effective_mode: GuardrailsMode,
419+
) -> tuple[Optional[str], Optional[str]]:
360420
"""Scan a file path for secrets."""
361421
if not file_path or not os.path.isfile(file_path):
362422
return None, None
@@ -375,4 +435,7 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) ->
375435

376436
document = Document(path=os.path.basename(file_path), content=content, is_git_diff_format=False)
377437
scan_ctx = _setup_scan_context(ctx)
378-
return _perform_scan(scan_ctx, [document], get_scan_parameters(scan_ctx, (file_path,)), timeout_seconds)
438+
scan_parameters = build_ai_guardrails_scan_parameters(
439+
scan_ctx, (file_path,), payload, AiHookEventType.FILE_READ, effective_mode
440+
)
441+
return _perform_scan(scan_ctx, [document], scan_parameters, timeout_seconds)

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"""
99

1010
from typing import Annotated, Optional, Union
11+
from uuid import uuid4
1112

1213
import click
1314
import typer
@@ -125,6 +126,9 @@ def scan_command(
125126
return
126127

127128
unified_payload = ide_integration.parse_hook_payload(payload)
129+
if not unified_payload.generation_id:
130+
# Not every IDE dialect provides a generation id (e.g. Copilot)
131+
unified_payload.generation_id = str(uuid4())
128132
event_name = unified_payload.event_name
129133
logger.debug(
130134
'Processing AI guardrails hook',

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,12 @@ class BlockReason(StrEnum):
4141
SECRETS_IN_MCP_ARGS = 'secrets_in_mcp_args'
4242
SENSITIVE_PATH = 'sensitive_path'
4343
SCAN_FAILURE = 'scan_failure'
44+
45+
46+
# The reason each event type yields when a secret is found in it. Also travels with the scan as
47+
# `detection_source`, so the violation and the hook event are labelled from the same vocabulary.
48+
SECRETS_BLOCK_REASON_BY_EVENT_TYPE: dict[AiHookEventType, BlockReason] = {
49+
AiHookEventType.PROMPT: BlockReason.SECRETS_IN_PROMPT,
50+
AiHookEventType.FILE_READ: BlockReason.SECRETS_IN_FILE,
51+
AiHookEventType.MCP_EXECUTION: BlockReason.SECRETS_IN_MCP_ARGS,
52+
}

0 commit comments

Comments
 (0)