Skip to content

Commit 64d84c5

Browse files
IlanlidoclaudeAltruistus
authored
CM-68944: Fetch AI guardrail config from the platform (#541)
Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: Nikita Fishbakh <nikita.fishbakh@cycode.com>
1 parent 055cf3a commit 64d84c5

19 files changed

Lines changed: 870 additions & 304 deletions

‎cycode/cli/apps/ai_guardrails/consts.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ class GuardrailsMode(str, Enum):
2222
BLOCK = 'block'
2323

2424

25+
class GuardrailCellMode(str, Enum):
26+
"""A guardrail x agent cell in the platform-resolved matrix.
27+
28+
Separate from GuardrailsMode because Off is a platform-only state: it is not an
29+
install `--mode` choice, and an off guardrail reports no mode to the server.
30+
"""
31+
32+
OFF = 'off'
33+
REPORT = GuardrailsMode.REPORT.value
34+
BLOCK = GuardrailsMode.BLOCK.value
35+
36+
2537
# Base CLI commands invoked from installed hooks. IDE classes append --ide flags
2638
# (and any other suffix) on top of these.
2739
CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan'

‎cycode/cli/apps/ai_guardrails/hooks_manager.py‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212

1313
import yaml
1414

15-
from cycode.cli.apps.ai_guardrails.consts import PolicyMode
1615
from cycode.cli.apps.ai_guardrails.ides.base import IDE
1716
from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME
17+
from cycode.cli.apps.ai_guardrails.scan.policy import strip_platform_managed_keys
1818
from cycode.logger import get_logger
1919

2020
logger = get_logger('AI Guardrails Hooks')
@@ -102,22 +102,21 @@ def _load_policy_dict(policy_path: Path) -> dict:
102102
return {**copy.deepcopy(DEFAULT_POLICY), **existing}
103103

104104

105-
def create_policy_file(scope: str, mode: PolicyMode, repo_path: Optional[Path] = None) -> tuple[bool, str]:
106-
"""Create or update the ai-guardrails.yaml policy file.
105+
def create_policy_file(scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
106+
"""Create or update the ai-guardrails.yaml policy file (operational knobs only).
107107
108-
If the file already exists, only the mode field is updated; otherwise a new
109-
file is created from the default policy.
108+
Enforcement mode and sensitive-path globs are platform-managed; those keys are stripped
109+
(including ones an older CLI wrote), everything else the user customized is preserved.
110110
"""
111111
config_dir = repo_path / '.cycode' if scope == 'repo' and repo_path else Path.home() / '.cycode'
112112
policy_path = config_dir / POLICY_FILE_NAME
113113

114-
policy = _load_policy_dict(policy_path)
115-
policy['mode'] = mode.value
114+
policy = strip_platform_managed_keys(_load_policy_dict(policy_path))
116115

117116
try:
118117
config_dir.mkdir(parents=True, exist_ok=True)
119118
policy_path.write_text(yaml.dump(policy, default_flow_style=False, sort_keys=False), encoding='utf-8')
120-
return True, f'AI guardrails policy ({mode.value} mode) set: {policy_path}'
119+
return True, f'AI guardrails policy file set: {policy_path}'
121120
except Exception as e:
122121
logger.error('Failed to create policy file', exc_info=e)
123122
return False, f'Failed to create policy file: {policy_path}'

‎cycode/cli/apps/ai_guardrails/install_command.py‎

Lines changed: 15 additions & 14 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 GuardrailsMode, PolicyMode
9+
from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode
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

@@ -44,8 +44,7 @@ def install_command(
4444
typer.Option(
4545
'--mode',
4646
'-m',
47-
help='Installation mode: "report" for async non-blocking hooks with warn policy, '
48-
'"block" for sync blocking hooks.',
47+
help='[Deprecated] Enforcement mode is platform-managed; configure guardrails in the Cycode platform.',
4948
),
5049
] = GuardrailsMode.REPORT,
5150
) -> None:
@@ -80,32 +79,34 @@ def install_command(
8079
console.print(f'[red]✗[/] {message}', style='bold red')
8180
all_success = False
8281

82+
if mode == GuardrailsMode.BLOCK:
83+
console.print(
84+
'[yellow]--mode is deprecated:[/] enforcement mode is platform-managed; '
85+
'configure guardrails in the Cycode platform.'
86+
)
87+
8388
if any_success:
84-
policy_mode = PolicyMode.WARN if mode == GuardrailsMode.REPORT else PolicyMode.BLOCK
85-
_install_policy(scope, repo_path, policy_mode)
86-
_print_next_steps(results, mode)
89+
_install_policy(scope, repo_path)
90+
_print_next_steps(results)
8791

8892
if not all_success:
8993
raise typer.Exit(1)
9094

9195

92-
def _install_policy(scope: str, repo_path: Optional[Path], policy_mode: PolicyMode) -> None:
93-
policy_success, policy_message = create_policy_file(scope, policy_mode, repo_path)
96+
def _install_policy(scope: str, repo_path: Optional[Path]) -> None:
97+
policy_success, policy_message = create_policy_file(scope, repo_path)
9498
if policy_success:
9599
console.print(f'[green]✓[/] {policy_message}')
96100
else:
97101
console.print(f'[red]✗[/] {policy_message}', style='bold red')
98102

99103

100-
def _print_next_steps(results: list[tuple[str, bool, str]], mode: GuardrailsMode) -> None:
104+
def _print_next_steps(results: list[tuple[str, bool, str]]) -> None:
101105
console.print()
102106
console.print('[bold]Next steps:[/]')
103107
successful_ides = [name for name, success, _ in results if success]
104108
ide_list = ', '.join(successful_ides)
105109
console.print(f'1. Restart {ide_list} to activate the hooks')
106-
console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml')
110+
console.print('2. Configure guardrail enforcement in the Cycode platform')
107111
console.print()
108-
if mode == GuardrailsMode.REPORT:
109-
console.print('[dim]Report mode: policy is set to warn.[/]')
110-
else:
111-
console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]')
112+
console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]')
Lines changed: 20 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,37 @@
11
"""
22
Constants and default configuration for AI guardrails.
33
4-
These defaults can be overridden by:
5-
1. User-level config: ~/.cycode/ai-guardrails.yaml
6-
2. Repo-level config: <workspace>/.cycode/ai-guardrails.yaml
4+
Enforcement (which guardrails run, in which mode, over which paths) is platform-owned and
5+
resolved per scan; see scan/guardrail_config.py. What is left here is the operational knobs
6+
a local file may override - user-level ~/.cycode/ai-guardrails.yaml, then repo-level
7+
<workspace>/.cycode/ai-guardrails.yaml.
78
"""
89

910
# Policy file name
1011
POLICY_FILE_NAME = 'ai-guardrails.yaml'
1112

12-
# Default policy configuration
13+
# Sensitive-path globs used until the platform's own list is cached (cold start, or a tenant
14+
# that never customized them). Not a local knob: apply_platform_config always overwrites it.
15+
DEFAULT_SENSITIVE_PATH_GLOBS = [
16+
'.env',
17+
'.env.*',
18+
'*.pem',
19+
'*.p12',
20+
'*.key',
21+
'.aws/**',
22+
'.ssh/**',
23+
'*kubeconfig*',
24+
'.npmrc',
25+
'.netrc',
26+
]
27+
28+
# Default policy configuration: operational knobs only.
1329
DEFAULT_POLICY = {
1430
'version': 1,
15-
'mode': 'block', # block | warn
1631
'fail_open': True, # allow if scan fails/timeouts
1732
'secrets': {
1833
'scan_type': 'secret',
1934
'timeout_ms': 30000,
2035
'max_bytes': 200000,
2136
},
22-
'prompt': {
23-
'enabled': True,
24-
'action': 'block',
25-
},
26-
'file_read': {
27-
'enabled': True,
28-
'action': 'block',
29-
'deny_globs': [
30-
'.env',
31-
'.env.*',
32-
'*.pem',
33-
'*.p12',
34-
'*.key',
35-
'.aws/**',
36-
'.ssh/**',
37-
'*kubeconfig*',
38-
'.npmrc',
39-
'.netrc',
40-
],
41-
'scan_content': True,
42-
},
43-
'mcp': {
44-
'enabled': True,
45-
'action': 'block',
46-
'scan_arguments': True,
47-
},
4837
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Platform guardrail config cache.
2+
3+
session-start fetches the tenant's resolved guardrail config from the platform and writes it
4+
here; scans only read. Per-agent modes and sensitive-path globs are platform-owned - local
5+
policy files never carry them. An absent or corrupt cache means built-in defaults (Report
6+
everywhere + the default globs), always synchronous.
7+
"""
8+
9+
import json
10+
import time
11+
from dataclasses import dataclass, field
12+
from pathlib import Path
13+
from typing import Optional
14+
15+
from cycode.cli.apps.ai_guardrails.consts import GuardrailCellMode, PolicyMode
16+
from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_SENSITIVE_PATH_GLOBS
17+
from cycode.cli.apps.ai_guardrails.scan.types import BlockReason
18+
from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY
19+
from cycode.cli.utils.path_utils import atomic_write_text, quarantine_corrupt_file
20+
from cycode.logger import get_logger
21+
22+
logger = get_logger('AI Guardrails')
23+
24+
GUARDRAILS_CONFIG_FILE_NAME = 'ai-guardrails-config.json'
25+
26+
_DEFAULT_TTL_SECONDS = 900
27+
28+
# Guardrail keys are the CLI's block-reason vocabulary. Anything else in the payload (a future
29+
# guardrail this CLI doesn't implement) is ignored - unknown config must never fail closed.
30+
_KNOWN_GUARDRAIL_KEYS = frozenset(
31+
reason.value
32+
for reason in (
33+
BlockReason.SECRETS_IN_PROMPT,
34+
BlockReason.SECRETS_IN_FILE,
35+
BlockReason.SENSITIVE_PATH,
36+
BlockReason.SECRETS_IN_MCP_ARGS,
37+
)
38+
)
39+
40+
# CLI --ide names to matrix column names; identity for names not listed.
41+
_AGENT_BY_IDE_NAME = {'claude-code': 'claude'}
42+
43+
44+
def get_config_cache_path() -> Path:
45+
return Path.home() / CYCODE_CONFIGURATION_DIRECTORY / GUARDRAILS_CONFIG_FILE_NAME
46+
47+
48+
def agent_for_ide(ide_name: Optional[str]) -> str:
49+
ide_name = (ide_name or '').lower()
50+
return _AGENT_BY_IDE_NAME.get(ide_name, ide_name)
51+
52+
53+
def _default_sensitive_globs() -> list:
54+
return list(DEFAULT_SENSITIVE_PATH_GLOBS)
55+
56+
57+
@dataclass
58+
class GuardrailConfig:
59+
payload: dict
60+
fetched_at: float
61+
tenant_id: Optional[str] = None
62+
_guardrails: dict = field(init=False, repr=False)
63+
64+
def __post_init__(self) -> None:
65+
self._guardrails = {
66+
guardrail.get('key'): guardrail
67+
for guardrail in self.payload.get('guardrails') or []
68+
if guardrail.get('key') in _KNOWN_GUARDRAIL_KEYS
69+
}
70+
71+
def mode_for(self, guardrail_key: str, ide_name: Optional[str]) -> str:
72+
agents = (self._guardrails.get(guardrail_key) or {}).get('agents') or {}
73+
return str(agents.get(agent_for_ide(ide_name), GuardrailCellMode.REPORT.value)).lower()
74+
75+
def _modes_for_event(self, event_name: str, ide_name: Optional[str]) -> list:
76+
return [
77+
self.mode_for(key, ide_name)
78+
for key, guardrail in self._guardrails.items()
79+
if str(guardrail.get('event_type', '')).lower() == str(event_name).lower()
80+
]
81+
82+
def is_event_off(self, event_name: str, ide_name: Optional[str]) -> bool:
83+
"""Every guardrail for this event is Off - skip the scan entirely."""
84+
modes = self._modes_for_event(event_name, ide_name)
85+
return bool(modes) and all(mode == GuardrailCellMode.OFF for mode in modes)
86+
87+
def can_event_block(self, event_name: str, ide_name: Optional[str]) -> bool:
88+
"""At least one guardrail for this event is in Block mode - the scan must stay synchronous."""
89+
return GuardrailCellMode.BLOCK in self._modes_for_event(event_name, ide_name)
90+
91+
def sensitive_globs(self) -> list:
92+
settings = (self._guardrails.get(BlockReason.SENSITIVE_PATH) or {}).get('settings') or {}
93+
globs = settings.get('globs')
94+
return globs if isinstance(globs, list) and globs else _default_sensitive_globs()
95+
96+
def is_expired(self) -> bool:
97+
ttl = self.payload.get('ttl_seconds') or _DEFAULT_TTL_SECONDS
98+
return time.time() - self.fetched_at > ttl
99+
100+
def needs_refresh(self, tenant_id: Optional[str]) -> bool:
101+
"""Expired, or fetched for another tenant (the user switched tenants since)."""
102+
return self.is_expired() or self.tenant_id != tenant_id
103+
104+
105+
def apply_platform_config(policy: dict, config: Optional[GuardrailConfig], ide_name: Optional[str]) -> None:
106+
"""Overlay the platform-owned enforcement config onto the local knobs-only policy.
107+
108+
The platform is the only mode source: no cache (cold start) means the built-in defaults -
109+
Report everywhere with the default globs - which equal an unconfigured tenant's platform
110+
config, so behaviour is uniform either way. Each matrix cell lands on its own per-feature
111+
action, so the two FileRead guardrails (content scan vs. sensitive path) keep independent modes.
112+
An all-Off event never reaches here at all: scan_command skips it.
113+
"""
114+
115+
def cell(guardrail_key: str) -> str:
116+
return config.mode_for(guardrail_key, ide_name) if config is not None else GuardrailCellMode.REPORT.value
117+
118+
def action(guardrail_key: str) -> str:
119+
return PolicyMode.BLOCK.value if cell(guardrail_key) == GuardrailCellMode.BLOCK else PolicyMode.WARN.value
120+
121+
policy.setdefault('prompt', {})['action'] = action(BlockReason.SECRETS_IN_PROMPT)
122+
123+
file_read = policy.setdefault('file_read', {})
124+
file_read['scan_content'] = cell(BlockReason.SECRETS_IN_FILE) != GuardrailCellMode.OFF
125+
file_read['action'] = action(BlockReason.SECRETS_IN_FILE)
126+
file_read['deny_globs'] = (
127+
(config.sensitive_globs() if config is not None else _default_sensitive_globs())
128+
if cell(BlockReason.SENSITIVE_PATH) != GuardrailCellMode.OFF
129+
else []
130+
)
131+
file_read['path_action'] = action(BlockReason.SENSITIVE_PATH)
132+
133+
policy.setdefault('mcp', {})['action'] = action(BlockReason.SECRETS_IN_MCP_ARGS)
134+
135+
136+
def save_guardrail_config(payload: dict, tenant_id: Optional[str]) -> None:
137+
"""Persist a fetched resolved config; a failed write just leaves the previous cache in place."""
138+
path = get_config_cache_path()
139+
content = {'fetched_at': time.time(), 'tenant_id': tenant_id, 'payload': payload}
140+
try:
141+
path.parent.mkdir(parents=True, exist_ok=True)
142+
atomic_write_text(str(path), json.dumps(content))
143+
except Exception as e:
144+
logger.debug('Failed to save guardrail config cache', exc_info=e)
145+
146+
147+
def load_guardrail_config() -> Optional[GuardrailConfig]:
148+
"""The cached platform config, or None when it is absent or corrupt (quarantined)."""
149+
path = get_config_cache_path()
150+
if not path.exists():
151+
return None
152+
153+
try:
154+
with open(path, encoding='UTF-8') as file:
155+
content = json.load(file)
156+
payload = content['payload']
157+
if not isinstance(payload, dict):
158+
raise ValueError('payload is not an object')
159+
return GuardrailConfig(
160+
payload=payload, fetched_at=float(content['fetched_at']), tenant_id=content.get('tenant_id')
161+
)
162+
except Exception as e:
163+
logger.warning('Guardrail config cache is corrupt and will be moved aside', exc_info=e)
164+
quarantine_corrupt_file(str(path))
165+
return None

0 commit comments

Comments
 (0)