cycode 3.22.2.dev3__py3-none-any.whl → 3.23.1.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cycode/__init__.py +1 -1
- cycode/cli/apps/ai_guardrails/consts.py +12 -0
- cycode/cli/apps/ai_guardrails/hooks_manager.py +7 -8
- cycode/cli/apps/ai_guardrails/install_command.py +15 -14
- cycode/cli/apps/ai_guardrails/scan/consts.py +20 -31
- cycode/cli/apps/ai_guardrails/scan/guardrail_config.py +165 -0
- cycode/cli/apps/ai_guardrails/scan/handlers.py +47 -64
- cycode/cli/apps/ai_guardrails/scan/policy.py +37 -5
- cycode/cli/apps/ai_guardrails/scan/scan_command.py +42 -22
- cycode/cli/apps/ai_guardrails/session_start_command.py +21 -0
- cycode/cli/utils/path_utils.py +27 -0
- cycode/cli/utils/yaml_utils.py +3 -24
- cycode/cyclient/ai_security_manager_client.py +10 -0
- {cycode-3.22.2.dev3.dist-info → cycode-3.23.1.dev1.dist-info}/METADATA +1 -1
- {cycode-3.22.2.dev3.dist-info → cycode-3.23.1.dev1.dist-info}/RECORD +18 -17
- {cycode-3.22.2.dev3.dist-info → cycode-3.23.1.dev1.dist-info}/WHEEL +0 -0
- {cycode-3.22.2.dev3.dist-info → cycode-3.23.1.dev1.dist-info}/entry_points.txt +0 -0
- {cycode-3.22.2.dev3.dist-info → cycode-3.23.1.dev1.dist-info}/licenses/LICENCE +0 -0
cycode/__init__.py
CHANGED
|
@@ -5,4 +5,4 @@ import time as _time
|
|
|
5
5
|
# end-to-end scan duration from the moment the user actually triggered it.
|
|
6
6
|
_BOOT_WALL: float = _time.time()
|
|
7
7
|
|
|
8
|
-
__version__ = '3.
|
|
8
|
+
__version__ = '3.23.1.dev1' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag
|
|
@@ -22,6 +22,18 @@ class GuardrailsMode(str, Enum):
|
|
|
22
22
|
BLOCK = 'block'
|
|
23
23
|
|
|
24
24
|
|
|
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
|
+
|
|
25
37
|
# Base CLI commands invoked from installed hooks. IDE classes append --ide flags
|
|
26
38
|
# (and any other suffix) on top of these.
|
|
27
39
|
CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan'
|
|
@@ -12,9 +12,9 @@ from typing import Optional
|
|
|
12
12
|
|
|
13
13
|
import yaml
|
|
14
14
|
|
|
15
|
-
from cycode.cli.apps.ai_guardrails.consts import PolicyMode
|
|
16
15
|
from cycode.cli.apps.ai_guardrails.ides.base import IDE
|
|
17
16
|
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
|
|
18
18
|
from cycode.logger import get_logger
|
|
19
19
|
|
|
20
20
|
logger = get_logger('AI Guardrails Hooks')
|
|
@@ -102,22 +102,21 @@ def _load_policy_dict(policy_path: Path) -> dict:
|
|
|
102
102
|
return {**copy.deepcopy(DEFAULT_POLICY), **existing}
|
|
103
103
|
|
|
104
104
|
|
|
105
|
-
def create_policy_file(scope: 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).
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
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.
|
|
110
110
|
"""
|
|
111
111
|
config_dir = repo_path / '.cycode' if scope == 'repo' and repo_path else Path.home() / '.cycode'
|
|
112
112
|
policy_path = config_dir / POLICY_FILE_NAME
|
|
113
113
|
|
|
114
|
-
policy = _load_policy_dict(policy_path)
|
|
115
|
-
policy['mode'] = mode.value
|
|
114
|
+
policy = strip_platform_managed_keys(_load_policy_dict(policy_path))
|
|
116
115
|
|
|
117
116
|
try:
|
|
118
117
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
119
118
|
policy_path.write_text(yaml.dump(policy, default_flow_style=False, sort_keys=False), encoding='utf-8')
|
|
120
|
-
return True, f'AI guardrails policy
|
|
119
|
+
return True, f'AI guardrails policy file set: {policy_path}'
|
|
121
120
|
except Exception as e:
|
|
122
121
|
logger.error('Failed to create policy file', exc_info=e)
|
|
123
122
|
return False, f'Failed to create policy file: {policy_path}'
|
|
@@ -6,7 +6,7 @@ from typing import Annotated, Optional
|
|
|
6
6
|
import typer
|
|
7
7
|
|
|
8
8
|
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
|
|
9
|
+
from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode
|
|
10
10
|
from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, install_hooks
|
|
11
11
|
from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides
|
|
12
12
|
|
|
@@ -44,8 +44,7 @@ def install_command(
|
|
|
44
44
|
typer.Option(
|
|
45
45
|
'--mode',
|
|
46
46
|
'-m',
|
|
47
|
-
help='
|
|
48
|
-
'"block" for sync blocking hooks.',
|
|
47
|
+
help='[Deprecated] Enforcement mode is platform-managed; configure guardrails in the Cycode platform.',
|
|
49
48
|
),
|
|
50
49
|
] = GuardrailsMode.REPORT,
|
|
51
50
|
) -> None:
|
|
@@ -80,32 +79,34 @@ def install_command(
|
|
|
80
79
|
console.print(f'[red]✗[/] {message}', style='bold red')
|
|
81
80
|
all_success = False
|
|
82
81
|
|
|
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
|
+
|
|
83
88
|
if any_success:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
_print_next_steps(results, mode)
|
|
89
|
+
_install_policy(scope, repo_path)
|
|
90
|
+
_print_next_steps(results)
|
|
87
91
|
|
|
88
92
|
if not all_success:
|
|
89
93
|
raise typer.Exit(1)
|
|
90
94
|
|
|
91
95
|
|
|
92
|
-
def _install_policy(scope: str, repo_path: Optional[Path]
|
|
93
|
-
policy_success, policy_message = create_policy_file(scope,
|
|
96
|
+
def _install_policy(scope: str, repo_path: Optional[Path]) -> None:
|
|
97
|
+
policy_success, policy_message = create_policy_file(scope, repo_path)
|
|
94
98
|
if policy_success:
|
|
95
99
|
console.print(f'[green]✓[/] {policy_message}')
|
|
96
100
|
else:
|
|
97
101
|
console.print(f'[red]✗[/] {policy_message}', style='bold red')
|
|
98
102
|
|
|
99
103
|
|
|
100
|
-
def _print_next_steps(results: list[tuple[str, bool, str]]
|
|
104
|
+
def _print_next_steps(results: list[tuple[str, bool, str]]) -> None:
|
|
101
105
|
console.print()
|
|
102
106
|
console.print('[bold]Next steps:[/]')
|
|
103
107
|
successful_ides = [name for name, success, _ in results if success]
|
|
104
108
|
ide_list = ', '.join(successful_ides)
|
|
105
109
|
console.print(f'1. Restart {ide_list} to activate the hooks')
|
|
106
|
-
console.print('2.
|
|
110
|
+
console.print('2. Configure guardrail enforcement in the Cycode platform')
|
|
107
111
|
console.print()
|
|
108
|
-
|
|
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.[/]')
|
|
@@ -1,48 +1,37 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Constants and default configuration for AI guardrails.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
# Policy file name
|
|
10
11
|
POLICY_FILE_NAME = 'ai-guardrails.yaml'
|
|
11
12
|
|
|
12
|
-
#
|
|
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.
|
|
13
29
|
DEFAULT_POLICY = {
|
|
14
30
|
'version': 1,
|
|
15
|
-
'mode': 'block', # block | warn
|
|
16
31
|
'fail_open': True, # allow if scan fails/timeouts
|
|
17
32
|
'secrets': {
|
|
18
33
|
'scan_type': 'secret',
|
|
19
34
|
'timeout_ms': 30000,
|
|
20
35
|
'max_bytes': 200000,
|
|
21
36
|
},
|
|
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
|
-
},
|
|
48
37
|
}
|
|
@@ -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
|
|
@@ -13,10 +13,13 @@ import os
|
|
|
13
13
|
from dataclasses import dataclass
|
|
14
14
|
from multiprocessing.pool import ThreadPool
|
|
15
15
|
from multiprocessing.pool import TimeoutError as PoolTimeoutError
|
|
16
|
-
from typing import Callable, Optional
|
|
16
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
17
17
|
|
|
18
18
|
import typer
|
|
19
19
|
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from cycode.cli.apps.ai_guardrails.scan.guardrail_config import GuardrailConfig
|
|
22
|
+
|
|
20
23
|
from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode, PolicyMode
|
|
21
24
|
from cycode.cli.apps.ai_guardrails.ides.base import HookDecision
|
|
22
25
|
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
|
|
@@ -48,11 +51,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
48
51
|
ai_client = ctx.obj['ai_security_client']
|
|
49
52
|
|
|
50
53
|
prompt_config = get_policy_value(policy, 'prompt', default={})
|
|
51
|
-
|
|
52
|
-
ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED)
|
|
53
|
-
return HookDecision.allow(AiHookEventType.PROMPT)
|
|
54
|
-
|
|
55
|
-
effective_mode = get_effective_mode(policy, prompt_config)
|
|
54
|
+
effective_mode = get_effective_mode(prompt_config)
|
|
56
55
|
prompt = payload.prompt or ''
|
|
57
56
|
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
|
|
58
57
|
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
|
|
@@ -104,12 +103,9 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
|
|
|
104
103
|
ai_client = ctx.obj['ai_security_client']
|
|
105
104
|
|
|
106
105
|
file_read_config = get_policy_value(policy, 'file_read', default={})
|
|
107
|
-
if not get_policy_value(file_read_config, 'enabled', default=True):
|
|
108
|
-
ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED)
|
|
109
|
-
return HookDecision.allow(AiHookEventType.FILE_READ)
|
|
110
|
-
|
|
111
106
|
file_path = payload.file_path or ''
|
|
112
|
-
|
|
107
|
+
path_mode = get_effective_mode(file_read_config, action_key='path_action')
|
|
108
|
+
content_mode = get_effective_mode(file_read_config)
|
|
113
109
|
|
|
114
110
|
scan_id = None
|
|
115
111
|
block_reason = None
|
|
@@ -120,7 +116,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
|
|
|
120
116
|
is_sensitive_path = is_denied_path(file_path, policy)
|
|
121
117
|
if is_sensitive_path:
|
|
122
118
|
block_reason = BlockReason.SENSITIVE_PATH
|
|
123
|
-
if
|
|
119
|
+
if path_mode == GuardrailsMode.BLOCK:
|
|
124
120
|
outcome = AIHookOutcome.BLOCKED
|
|
125
121
|
user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).'
|
|
126
122
|
return HookDecision.deny(
|
|
@@ -144,11 +140,11 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
|
|
|
144
140
|
|
|
145
141
|
if get_policy_value(file_read_config, 'scan_content', default=True):
|
|
146
142
|
violation_summary, scan_id = _scan_path_for_secrets(
|
|
147
|
-
ctx, file_path, policy, payload=payload, effective_mode=
|
|
143
|
+
ctx, file_path, policy, payload=payload, effective_mode=content_mode
|
|
148
144
|
)
|
|
149
145
|
if violation_summary:
|
|
150
146
|
block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.FILE_READ]
|
|
151
|
-
if
|
|
147
|
+
if content_mode == GuardrailsMode.BLOCK:
|
|
152
148
|
outcome = AIHookOutcome.BLOCKED
|
|
153
149
|
user_message = f'Cycode blocked reading {file_path}. {violation_summary}'
|
|
154
150
|
return HookDecision.deny(
|
|
@@ -201,7 +197,6 @@ class _ArgScanFeature:
|
|
|
201
197
|
"""
|
|
202
198
|
|
|
203
199
|
policy_key: str # 'mcp' or 'command_exec'
|
|
204
|
-
scan_key: str # 'scan_arguments' or 'scan_command'
|
|
205
200
|
event_type: AiHookEventType
|
|
206
201
|
deny_message: Callable[[str], str]
|
|
207
202
|
deny_agent_message: str
|
|
@@ -220,14 +215,10 @@ def _handle_arg_scan(
|
|
|
220
215
|
ai_client = ctx.obj['ai_security_client']
|
|
221
216
|
|
|
222
217
|
feature_config = get_policy_value(policy, feature.policy_key, default={})
|
|
223
|
-
if not get_policy_value(feature_config, 'enabled', default=True):
|
|
224
|
-
ai_client.create_event(payload, feature.event_type, AIHookOutcome.ALLOWED)
|
|
225
|
-
return HookDecision.allow(feature.event_type)
|
|
226
|
-
|
|
227
218
|
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
|
|
228
219
|
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
|
|
229
220
|
clipped = truncate_utf8(scan_text, max_bytes)
|
|
230
|
-
effective_mode = get_effective_mode(
|
|
221
|
+
effective_mode = get_effective_mode(feature_config)
|
|
231
222
|
|
|
232
223
|
scan_id = None
|
|
233
224
|
block_reason = None
|
|
@@ -235,30 +226,29 @@ def _handle_arg_scan(
|
|
|
235
226
|
error_message = None
|
|
236
227
|
|
|
237
228
|
try:
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return HookDecision.deny(
|
|
252
|
-
feature.event_type,
|
|
253
|
-
feature.deny_message(violation_summary),
|
|
254
|
-
feature.deny_agent_message,
|
|
255
|
-
)
|
|
256
|
-
outcome = AIHookOutcome.WARNED
|
|
257
|
-
return HookDecision.ask(
|
|
229
|
+
violation_summary, scan_id = _scan_text_for_secrets(
|
|
230
|
+
ctx,
|
|
231
|
+
clipped,
|
|
232
|
+
timeout_ms,
|
|
233
|
+
payload=payload,
|
|
234
|
+
event_type=feature.event_type,
|
|
235
|
+
effective_mode=effective_mode,
|
|
236
|
+
)
|
|
237
|
+
if violation_summary:
|
|
238
|
+
block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[feature.event_type]
|
|
239
|
+
if effective_mode == GuardrailsMode.BLOCK:
|
|
240
|
+
outcome = AIHookOutcome.BLOCKED
|
|
241
|
+
return HookDecision.deny(
|
|
258
242
|
feature.event_type,
|
|
259
|
-
feature.
|
|
260
|
-
feature.
|
|
243
|
+
feature.deny_message(violation_summary),
|
|
244
|
+
feature.deny_agent_message,
|
|
261
245
|
)
|
|
246
|
+
outcome = AIHookOutcome.WARNED
|
|
247
|
+
return HookDecision.ask(
|
|
248
|
+
feature.event_type,
|
|
249
|
+
feature.ask_message(violation_summary),
|
|
250
|
+
feature.ask_agent_message,
|
|
251
|
+
)
|
|
262
252
|
|
|
263
253
|
return HookDecision.allow(feature.event_type)
|
|
264
254
|
except Exception as e:
|
|
@@ -290,7 +280,6 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
290
280
|
policy,
|
|
291
281
|
_ArgScanFeature(
|
|
292
282
|
policy_key='mcp',
|
|
293
|
-
scan_key='scan_arguments',
|
|
294
283
|
event_type=AiHookEventType.MCP_EXECUTION,
|
|
295
284
|
deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}',
|
|
296
285
|
deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.',
|
|
@@ -311,35 +300,29 @@ def get_handler_for_event(event_type: str) -> Optional[HandlerFn]:
|
|
|
311
300
|
return handlers.get(event_type)
|
|
312
301
|
|
|
313
302
|
|
|
314
|
-
def get_effective_mode(
|
|
315
|
-
"""
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
return GuardrailsMode.BLOCK if (mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK) else GuardrailsMode.REPORT
|
|
319
|
-
|
|
303
|
+
def get_effective_mode(feature_config: dict, action_key: str = 'action') -> GuardrailsMode:
|
|
304
|
+
"""A guardrail's action is its matrix cell: block, or warn (report) for everything else."""
|
|
305
|
+
action = get_policy_value(feature_config, action_key, default=PolicyMode.BLOCK)
|
|
306
|
+
return GuardrailsMode.BLOCK if action == PolicyMode.BLOCK else GuardrailsMode.REPORT
|
|
320
307
|
|
|
321
|
-
# The policy section each event's handler reads its feature config from.
|
|
322
|
-
_FEATURE_KEY_BY_EVENT_TYPE: dict[str, str] = {
|
|
323
|
-
AiHookEventType.PROMPT.value: 'prompt',
|
|
324
|
-
AiHookEventType.FILE_READ.value: 'file_read',
|
|
325
|
-
AiHookEventType.MCP_EXECUTION.value: 'mcp',
|
|
326
|
-
}
|
|
327
308
|
|
|
328
|
-
|
|
329
|
-
|
|
309
|
+
def should_detach_scan(
|
|
310
|
+
config: Optional['GuardrailConfig'],
|
|
311
|
+
policy: dict,
|
|
312
|
+
event_name: str,
|
|
313
|
+
ide_name: Optional[str],
|
|
314
|
+
) -> bool:
|
|
330
315
|
"""Whether this event's scan is safe to run detached.
|
|
331
316
|
|
|
332
|
-
Report mode never blocks, so nobody consumes the verdict.
|
|
333
|
-
|
|
334
|
-
|
|
317
|
+
Report mode never blocks, so nobody consumes the verdict. The platform config is the only
|
|
318
|
+
mode source: without a cache the scan stays synchronous (never detach on an assumption).
|
|
319
|
+
Fail-closed configs also stay synchronous: their deny on scan failure must reach the IDE.
|
|
335
320
|
"""
|
|
336
|
-
|
|
337
|
-
if feature_key is None:
|
|
321
|
+
if config is None:
|
|
338
322
|
return False
|
|
339
323
|
if not get_policy_value(policy, 'fail_open', default=True):
|
|
340
324
|
return False
|
|
341
|
-
|
|
342
|
-
return get_effective_mode(policy, feature_config) == GuardrailsMode.REPORT
|
|
325
|
+
return not config.can_event_block(event_name, ide_name)
|
|
343
326
|
|
|
344
327
|
|
|
345
328
|
def build_ai_guardrails_scan_parameters(
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Policy loading and configuration management for AI guardrails.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
Operational knobs are loaded and merged in order (later overrides earlier):
|
|
5
5
|
1. Built-in defaults (consts.DEFAULT_POLICY)
|
|
6
6
|
2. Machine-wide config (admin/MDM-provisioned; see get_machine_policy_path)
|
|
7
7
|
3. User-level config (~/.cycode/ai-guardrails.yaml)
|
|
8
8
|
4. Repo-level config (<workspace>/.cycode/ai-guardrails.yaml)
|
|
9
|
+
|
|
10
|
+
Enforcement is not part of that merge: the platform resolves it and apply_platform_config
|
|
11
|
+
overlays it on top of the result (see scan/guardrail_config.py).
|
|
9
12
|
"""
|
|
10
13
|
|
|
11
14
|
import json
|
|
@@ -17,6 +20,34 @@ from typing import Any, Optional
|
|
|
17
20
|
import yaml
|
|
18
21
|
|
|
19
22
|
from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME
|
|
23
|
+
from cycode.logger import get_logger
|
|
24
|
+
|
|
25
|
+
logger = get_logger('AI Guardrails')
|
|
26
|
+
|
|
27
|
+
# A local policy file may only contribute operational knobs; enforcement comes from the
|
|
28
|
+
# platform. Read as a whitelist rather than a strip-list, so a key we forget to enumerate
|
|
29
|
+
# is ignored by default instead of silently weakening enforcement.
|
|
30
|
+
_LOCAL_POLICY_KEYS = ('version', 'fail_open', 'secrets')
|
|
31
|
+
|
|
32
|
+
# The sections the platform resolves. Only used when rewriting a user's file: keys an older
|
|
33
|
+
# CLI wrote there still read as enforcement to a human, so drop them on the way out.
|
|
34
|
+
_PLATFORM_OWNED_KEYS = ('mode', 'prompt', 'file_read', 'mcp')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pick_local_knobs(config: dict, filename: str) -> dict:
|
|
38
|
+
"""Keep only the keys a local policy file is allowed to contribute."""
|
|
39
|
+
ignored = [key for key in config if key not in _LOCAL_POLICY_KEYS]
|
|
40
|
+
if ignored:
|
|
41
|
+
logger.debug(
|
|
42
|
+
'Ignoring non-knob keys in local policy file, %s',
|
|
43
|
+
{'filename': filename, 'keys': ignored},
|
|
44
|
+
)
|
|
45
|
+
return {key: value for key, value in config.items() if key in _LOCAL_POLICY_KEYS}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def strip_platform_managed_keys(config: dict) -> dict:
|
|
49
|
+
"""Drop the enforcement sections a local file may carry (older CLIs wrote them)."""
|
|
50
|
+
return {key: value for key, value in config.items() if key not in _PLATFORM_OWNED_KEYS}
|
|
20
51
|
|
|
21
52
|
|
|
22
53
|
def get_machine_policy_path() -> Path:
|
|
@@ -83,21 +114,22 @@ def load_policy(workspace_root: Optional[str] = None) -> dict:
|
|
|
83
114
|
policy = load_defaults()
|
|
84
115
|
|
|
85
116
|
# Merge machine-wide config (admin/MDM-provisioned) - overrides defaults, below user/repo.
|
|
86
|
-
|
|
117
|
+
machine_policy_path = get_machine_policy_path()
|
|
118
|
+
machine_config = load_yaml_file(machine_policy_path)
|
|
87
119
|
if machine_config:
|
|
88
|
-
policy = deep_merge(policy, machine_config)
|
|
120
|
+
policy = deep_merge(policy, pick_local_knobs(machine_config, str(machine_policy_path)))
|
|
89
121
|
|
|
90
122
|
# Merge user-level config (if exists)
|
|
91
123
|
user_policy_path = Path.home() / '.cycode' / POLICY_FILE_NAME
|
|
92
124
|
user_config = load_yaml_file(user_policy_path)
|
|
93
125
|
if user_config:
|
|
94
|
-
policy = deep_merge(policy, user_config)
|
|
126
|
+
policy = deep_merge(policy, pick_local_knobs(user_config, str(user_policy_path)))
|
|
95
127
|
|
|
96
128
|
# Merge repo-level config (if exists) - highest precedence
|
|
97
129
|
if workspace_root:
|
|
98
130
|
repo_policy_path = Path(workspace_root) / '.cycode' / POLICY_FILE_NAME
|
|
99
131
|
repo_config = load_yaml_file(repo_policy_path)
|
|
100
132
|
if repo_config:
|
|
101
|
-
policy = deep_merge(policy, repo_config)
|
|
133
|
+
policy = deep_merge(policy, pick_local_knobs(repo_config, str(repo_policy_path)))
|
|
102
134
|
|
|
103
135
|
return policy
|
|
@@ -14,8 +14,9 @@ import click
|
|
|
14
14
|
import typer
|
|
15
15
|
|
|
16
16
|
from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide
|
|
17
|
-
from cycode.cli.apps.ai_guardrails.ides.base import HookDecision
|
|
17
|
+
from cycode.cli.apps.ai_guardrails.ides.base import IDE, HookDecision
|
|
18
18
|
from cycode.cli.apps.ai_guardrails.scan.detach import is_detached_child, respawn_detached
|
|
19
|
+
from cycode.cli.apps.ai_guardrails.scan.guardrail_config import apply_platform_config, load_guardrail_config
|
|
19
20
|
from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event, should_detach_scan
|
|
20
21
|
from cycode.cli.apps.ai_guardrails.scan.policy import load_policy
|
|
21
22
|
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
|
|
@@ -61,6 +62,31 @@ def _deny_for_event(
|
|
|
61
62
|
return HookDecision.deny(target, user_message, agent_message)
|
|
62
63
|
|
|
63
64
|
|
|
65
|
+
def _should_skip_payload(ide_integration: IDE, payload: Optional[dict]) -> bool:
|
|
66
|
+
"""Fast exits that never scan: empty/foreign/synthetic payloads all answer a plain allow."""
|
|
67
|
+
if not payload:
|
|
68
|
+
logger.debug('Empty or invalid JSON payload received')
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
# Prevent cross-IDE processing (e.g. Cursor reading Claude Code hooks
|
|
72
|
+
# from ~/.claude/settings.json).
|
|
73
|
+
if not ide_integration.matches_payload(payload):
|
|
74
|
+
logger.debug(
|
|
75
|
+
'Payload event does not match expected IDE, skipping',
|
|
76
|
+
extra={'hook_event_name': payload.get('hook_event_name'), 'expected_ide': ide_integration.name},
|
|
77
|
+
)
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
# Fork/subagent completions arrive as synthetic user turns (e.g. Claude Code's
|
|
81
|
+
# <task-notification>); they are agent-generated, not user prompts - skip before
|
|
82
|
+
# parse_hook_payload, which reads the transcript and IDE config from disk.
|
|
83
|
+
if ide_integration.is_synthetic_prompt(payload):
|
|
84
|
+
logger.debug('Synthetic prompt detected, skipping scan')
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
64
90
|
def _initialize_clients(ctx: typer.Context) -> None:
|
|
65
91
|
"""Initialize API clients.
|
|
66
92
|
|
|
@@ -95,26 +121,7 @@ def scan_command(
|
|
|
95
121
|
stdin_data = read_stdin_text().strip()
|
|
96
122
|
payload = safe_json_parse(stdin_data)
|
|
97
123
|
|
|
98
|
-
if
|
|
99
|
-
logger.debug('Empty or invalid JSON payload received')
|
|
100
|
-
output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)))
|
|
101
|
-
return
|
|
102
|
-
|
|
103
|
-
# Prevent cross-IDE processing (e.g. Cursor reading Claude Code hooks
|
|
104
|
-
# from ~/.claude/settings.json).
|
|
105
|
-
if not ide_integration.matches_payload(payload):
|
|
106
|
-
logger.debug(
|
|
107
|
-
'Payload event does not match expected IDE, skipping',
|
|
108
|
-
extra={'hook_event_name': payload.get('hook_event_name'), 'expected_ide': ide_integration.name},
|
|
109
|
-
)
|
|
110
|
-
output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)))
|
|
111
|
-
return
|
|
112
|
-
|
|
113
|
-
# Fork/subagent completions arrive as synthetic user turns (e.g. Claude Code's
|
|
114
|
-
# <task-notification>); they are agent-generated, not user prompts - skip before
|
|
115
|
-
# parse_hook_payload, which reads the transcript and IDE config from disk.
|
|
116
|
-
if ide_integration.is_synthetic_prompt(payload):
|
|
117
|
-
logger.debug('Synthetic prompt detected, skipping scan')
|
|
124
|
+
if _should_skip_payload(ide_integration, payload):
|
|
118
125
|
output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)))
|
|
119
126
|
return
|
|
120
127
|
|
|
@@ -136,14 +143,27 @@ def scan_command(
|
|
|
136
143
|
output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)))
|
|
137
144
|
return
|
|
138
145
|
|
|
146
|
+
# Scans only read the cache; session-start refreshes it, so the hot path never waits on the
|
|
147
|
+
# network. Every guardrail for this event Off: skip entirely - no scan, no event, no auth.
|
|
148
|
+
config = load_guardrail_config()
|
|
149
|
+
if config is not None and config.is_event_off(event_name, ide_integration.name):
|
|
150
|
+
logger.debug('Guardrails are off for this event, allowing', extra={'event_name': event_name})
|
|
151
|
+
output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)))
|
|
152
|
+
return
|
|
153
|
+
|
|
139
154
|
# `or` (not a .get default) - Cursor sends workspace_roots=[] when no folder is open.
|
|
140
155
|
workspace_roots = payload.get('workspace_roots') or ['.']
|
|
141
156
|
policy = load_policy(workspace_roots[0])
|
|
157
|
+
apply_platform_config(policy, config, ide_integration.name)
|
|
142
158
|
|
|
143
159
|
# Report mode: nobody consumes the verdict, so hand the scan to a detached
|
|
144
160
|
# child and release the IDE immediately. Runs before any client or network
|
|
145
161
|
# work. A failed respawn falls through to the synchronous path.
|
|
146
|
-
if
|
|
162
|
+
if (
|
|
163
|
+
not is_detached_child()
|
|
164
|
+
and should_detach_scan(config, policy, event_name, ide_integration.name)
|
|
165
|
+
and respawn_detached(stdin_data)
|
|
166
|
+
):
|
|
147
167
|
return
|
|
148
168
|
|
|
149
169
|
try:
|
|
@@ -15,6 +15,7 @@ from cycode.cli.apps.ai_guardrails.ides import (
|
|
|
15
15
|
collect_all_skills,
|
|
16
16
|
get_ide,
|
|
17
17
|
)
|
|
18
|
+
from cycode.cli.apps.ai_guardrails.scan.guardrail_config import load_guardrail_config, save_guardrail_config
|
|
18
19
|
from cycode.cli.apps.ai_guardrails.scan.utils import read_stdin_text, safe_json_parse
|
|
19
20
|
from cycode.cli.apps.auth.auth_common import get_authorization_info
|
|
20
21
|
from cycode.cli.apps.auth.auth_manager import AuthManager
|
|
@@ -167,3 +168,23 @@ def session_start_command(
|
|
|
167
168
|
|
|
168
169
|
# Report session context (device + cross-IDE MCP servers and plugins)
|
|
169
170
|
_report_session_context(ai_client, session_payload.ide_user_email, auth_info.tenant_id)
|
|
171
|
+
|
|
172
|
+
# SessionStart precedes the first prompt hook in every IDE, so scans normally find a cache.
|
|
173
|
+
_sync_guardrail_config(ai_client, auth_info.tenant_id)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _sync_guardrail_config(ai_client: 'AISecurityManagerClient', tenant_id: Optional[str]) -> None:
|
|
177
|
+
"""Refresh the guardrail config cache when it is expired or belongs to another tenant.
|
|
178
|
+
|
|
179
|
+
Every step here swallows its own failures - a broken cache or an unreachable platform
|
|
180
|
+
must never fail the session.
|
|
181
|
+
"""
|
|
182
|
+
cached = load_guardrail_config()
|
|
183
|
+
if cached is not None and not cached.needs_refresh(tenant_id):
|
|
184
|
+
logger.debug('Guardrail config cache is fresh, skipping fetch')
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
resolved = ai_client.get_resolved_guardrails()
|
|
188
|
+
if resolved:
|
|
189
|
+
save_guardrail_config(resolved, tenant_id)
|
|
190
|
+
logger.debug('Guardrail config cache updated')
|
cycode/cli/utils/path_utils.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
|
+
import tempfile
|
|
3
4
|
from functools import cache
|
|
4
5
|
from typing import TYPE_CHECKING, AnyStr, Optional, Union
|
|
5
6
|
|
|
@@ -86,6 +87,32 @@ def get_file_content(file_path: Union[str, 'PathLike']) -> Optional[AnyStr]:
|
|
|
86
87
|
logger.warn('Permission denied to read the file: %s', file_path)
|
|
87
88
|
|
|
88
89
|
|
|
90
|
+
def atomic_write_text(filename: str, content: str) -> None:
|
|
91
|
+
"""Write via a temp file + rename so concurrent CLI processes never read a torn file."""
|
|
92
|
+
directory = os.path.dirname(filename)
|
|
93
|
+
file_descriptor, temp_filename = tempfile.mkstemp(dir=directory, prefix=f'.{os.path.basename(filename)}.')
|
|
94
|
+
try:
|
|
95
|
+
with os.fdopen(file_descriptor, 'w', encoding='UTF-8') as file:
|
|
96
|
+
file.write(content)
|
|
97
|
+
file.flush()
|
|
98
|
+
os.fsync(file.fileno())
|
|
99
|
+
|
|
100
|
+
os.replace(temp_filename, filename)
|
|
101
|
+
except Exception:
|
|
102
|
+
if os.path.exists(temp_filename):
|
|
103
|
+
os.remove(temp_filename)
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def quarantine_corrupt_file(filename: str) -> None:
|
|
108
|
+
# Renamed rather than deleted: the file may hold the only copy of the user's credentials,
|
|
109
|
+
# and keeping it around leaves something to look at in the next bug report.
|
|
110
|
+
try:
|
|
111
|
+
os.replace(filename, f'{filename}.corrupt')
|
|
112
|
+
except OSError as e:
|
|
113
|
+
logger.warning('Failed to quarantine corrupt file, %s', {'filename': filename}, exc_info=e)
|
|
114
|
+
|
|
115
|
+
|
|
89
116
|
def load_json(txt: str) -> Optional[dict]:
|
|
90
117
|
try:
|
|
91
118
|
return json.loads(txt)
|
cycode/cli/utils/yaml_utils.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import os
|
|
2
|
-
import tempfile
|
|
3
2
|
from collections.abc import Hashable
|
|
4
3
|
from typing import Any, TextIO
|
|
5
4
|
|
|
6
5
|
import yaml
|
|
7
6
|
|
|
7
|
+
from cycode.cli.utils.path_utils import atomic_write_text, quarantine_corrupt_file
|
|
8
8
|
from cycode.logger import get_logger
|
|
9
9
|
|
|
10
10
|
logger = get_logger('YAML Utils')
|
|
@@ -35,15 +35,6 @@ def _yaml_object_safe_load(file: TextIO) -> dict[Hashable, Any]:
|
|
|
35
35
|
return loaded_file
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
def _quarantine_corrupt_file(filename: str) -> None:
|
|
39
|
-
# Renamed rather than deleted: the file may hold the only copy of the user's credentials,
|
|
40
|
-
# and keeping it around leaves something to look at in the next bug report.
|
|
41
|
-
try:
|
|
42
|
-
os.replace(filename, f'{filename}.corrupt')
|
|
43
|
-
except OSError as e:
|
|
44
|
-
logger.warning('Failed to quarantine corrupt file, %s', {'filename': filename}, exc_info=e)
|
|
45
|
-
|
|
46
|
-
|
|
47
38
|
def read_yaml_file(filename: str) -> dict[Hashable, Any]:
|
|
48
39
|
if not os.access(filename, os.R_OK) or not os.path.exists(filename):
|
|
49
40
|
logger.debug('Config file is not accessible or does not exist: %s', {'filename': filename})
|
|
@@ -54,7 +45,7 @@ def read_yaml_file(filename: str) -> dict[Hashable, Any]:
|
|
|
54
45
|
return _yaml_object_safe_load(file)
|
|
55
46
|
except yaml.YAMLError as e:
|
|
56
47
|
logger.warning('Config file is corrupt and will be moved aside, %s', {'filename': filename}, exc_info=e)
|
|
57
|
-
|
|
48
|
+
quarantine_corrupt_file(filename)
|
|
58
49
|
return {}
|
|
59
50
|
|
|
60
51
|
|
|
@@ -64,19 +55,7 @@ def write_yaml_file(filename: str, content: dict[Hashable, Any]) -> None:
|
|
|
64
55
|
logger.warning('No write permission for file. Cannot save config, %s', {'filename': filename})
|
|
65
56
|
return
|
|
66
57
|
|
|
67
|
-
|
|
68
|
-
file_descriptor, temp_filename = tempfile.mkstemp(dir=directory, prefix=f'.{os.path.basename(filename)}.')
|
|
69
|
-
try:
|
|
70
|
-
with os.fdopen(file_descriptor, 'w', encoding='UTF-8') as file:
|
|
71
|
-
yaml.safe_dump(content, file)
|
|
72
|
-
file.flush()
|
|
73
|
-
os.fsync(file.fileno())
|
|
74
|
-
|
|
75
|
-
os.replace(temp_filename, filename)
|
|
76
|
-
except Exception:
|
|
77
|
-
if os.path.exists(temp_filename):
|
|
78
|
-
os.remove(temp_filename)
|
|
79
|
-
raise
|
|
58
|
+
atomic_write_text(filename, yaml.safe_dump(content))
|
|
80
59
|
|
|
81
60
|
|
|
82
61
|
def update_yaml_file(filename: str, content: dict[Hashable, Any]) -> None:
|
|
@@ -18,6 +18,7 @@ class AISecurityManagerClient:
|
|
|
18
18
|
_CONVERSATIONS_PATH = 'v4/ai-security/interactions/conversations'
|
|
19
19
|
_EVENTS_PATH = 'v4/ai-security/interactions/events'
|
|
20
20
|
_SESSION_CONTEXT_PATH = 'v4/ai-security/interactions/session-context'
|
|
21
|
+
_RESOLVED_GUARDRAILS_PATH = 'v4/ai-security/guardrails/resolved'
|
|
21
22
|
|
|
22
23
|
def __init__(self, client: CycodeClientBase, service_config: 'AISecurityManagerServiceConfigBase') -> None:
|
|
23
24
|
self.client = client
|
|
@@ -92,6 +93,15 @@ class AISecurityManagerClient:
|
|
|
92
93
|
logger.debug('Failed to create AI hook event', exc_info=e)
|
|
93
94
|
# Don't fail the hook if tracking fails
|
|
94
95
|
|
|
96
|
+
def get_resolved_guardrails(self) -> Optional[dict]:
|
|
97
|
+
"""Fetch the tenant's resolved guardrail config (per-agent modes + sensitive-path globs)."""
|
|
98
|
+
try:
|
|
99
|
+
response = self.client.get(self._build_endpoint_path(self._RESOLVED_GUARDRAILS_PATH))
|
|
100
|
+
return response.json()
|
|
101
|
+
except Exception as e:
|
|
102
|
+
logger.debug('Failed to fetch resolved guardrail config', exc_info=e)
|
|
103
|
+
return None
|
|
104
|
+
|
|
95
105
|
def report_session_context(
|
|
96
106
|
self,
|
|
97
107
|
hostname: Optional[str] = None,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
cycode/__init__.py,sha256=
|
|
1
|
+
cycode/__init__.py,sha256=9avEgdZkCaD_4Klbu5ohfcM_MidKc6b4_XrsOWD1zjU,396
|
|
2
2
|
cycode/__main__.py,sha256=Z3bD5yrA7yPvAChcADQrqCaZd0ChGI1gdiwALwbWJ6U,104
|
|
3
3
|
cycode/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
4
|
cycode/cli/app.py,sha256=AlR2durAEbsa47PDfIj7JtMvJDWA_Dq6wPtVuMJYSCs,10250
|
|
@@ -6,8 +6,8 @@ cycode/cli/apps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
6
6
|
cycode/cli/apps/activation_manager.py,sha256=Hz9PDJFB-ZmYi4HSG8iYC-fR8j5v25VuUU-l95Otsdk,1678
|
|
7
7
|
cycode/cli/apps/ai_guardrails/__init__.py,sha256=NsqB1Ca83BIjJMcDSt6suec6Ed0iNnacC0gBqkuuTtI,1367
|
|
8
8
|
cycode/cli/apps/ai_guardrails/command_utils.py,sha256=NVwd0-2RGRKIqhsQ-4LNDR1D0gVm_o7n-z5LxG2bqAo,800
|
|
9
|
-
cycode/cli/apps/ai_guardrails/consts.py,sha256=
|
|
10
|
-
cycode/cli/apps/ai_guardrails/hooks_manager.py,sha256=
|
|
9
|
+
cycode/cli/apps/ai_guardrails/consts.py,sha256=CO3j7CiVoc0CuWtIjYEWmGSXNAd2DfiPhffW-gZMRT4,1152
|
|
10
|
+
cycode/cli/apps/ai_guardrails/hooks_manager.py,sha256=gIP4LGyOL1OFmCfxM1nrjedrBpCwLc_mcb7k40bIz_Q,9398
|
|
11
11
|
cycode/cli/apps/ai_guardrails/ides/__init__.py,sha256=5odqWNfvvbFfAtR8ioEytXZ5VczWAOGyr1xuASC_OnQ,3346
|
|
12
12
|
cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py,sha256=XPIc9pZFEgdGVTSSAl9yqI48F88GKlFnJ5FsLUhHjCE,3981
|
|
13
13
|
cycode/cli/apps/ai_guardrails/ides/_skill_utils.py,sha256=Xk8z-uquTMvg4JxNwZxnclR79ME7ZKq7rxht3aFQUeo,3972
|
|
@@ -16,17 +16,18 @@ cycode/cli/apps/ai_guardrails/ides/claude_code.py,sha256=JCSB2PRPtZETFSskxV02hUw
|
|
|
16
16
|
cycode/cli/apps/ai_guardrails/ides/codex.py,sha256=P3dCa2bHLoeKs10TN6OmwP6BjcuJkNMkfUFa4T7n9Vo,12335
|
|
17
17
|
cycode/cli/apps/ai_guardrails/ides/copilot.py,sha256=XK2CLdB3oLJrP5Z-pGNsLbnwfehv4AW4vTLdvSE3eb0,21894
|
|
18
18
|
cycode/cli/apps/ai_guardrails/ides/cursor.py,sha256=cqi12ELV5ZF7ANUA9n33gc8Hj9hURqEBLJ3gzf-v68w,5850
|
|
19
|
-
cycode/cli/apps/ai_guardrails/install_command.py,sha256=
|
|
19
|
+
cycode/cli/apps/ai_guardrails/install_command.py,sha256=3T-Wj_Fr871uhfTe77CGqrkeAwvHafK_aEv7BZ0jAJU,4069
|
|
20
20
|
cycode/cli/apps/ai_guardrails/scan/__init__.py,sha256=qJc82XiQGiAuc1sYY8Ij_A-qXpxgLPuayQq8xWlouMA,48
|
|
21
|
-
cycode/cli/apps/ai_guardrails/scan/consts.py,sha256=
|
|
21
|
+
cycode/cli/apps/ai_guardrails/scan/consts.py,sha256=8H8JXlgm65sYkgTXqsBInHhHe80UPjqY4vumZXB4GGM,1060
|
|
22
22
|
cycode/cli/apps/ai_guardrails/scan/detach.py,sha256=8BRgBq9Qi8nXEx6NKlE8scSRPkO30XCfsiI5PLvq6Gk,2700
|
|
23
|
-
cycode/cli/apps/ai_guardrails/scan/
|
|
23
|
+
cycode/cli/apps/ai_guardrails/scan/guardrail_config.py,sha256=QSBwmkTdlC9zXIqI3gIiOlefviJWrpeJHzFu1Cs-zgE,7051
|
|
24
|
+
cycode/cli/apps/ai_guardrails/scan/handlers.py,sha256=vOAvknrlCN-G_AUE7adlZAg0V7BhtGTYKB4r1yZDBu0,17695
|
|
24
25
|
cycode/cli/apps/ai_guardrails/scan/payload.py,sha256=sWsWq5yXP54MVCajsb180kLuprI_M2kspMOzQGh7r_o,1534
|
|
25
|
-
cycode/cli/apps/ai_guardrails/scan/policy.py,sha256=
|
|
26
|
-
cycode/cli/apps/ai_guardrails/scan/scan_command.py,sha256=
|
|
26
|
+
cycode/cli/apps/ai_guardrails/scan/policy.py,sha256=3HuDoL_NYE3lyHRAlIMleq6pi-stIDQwotHsKhUFhTQ,5000
|
|
27
|
+
cycode/cli/apps/ai_guardrails/scan/scan_command.py,sha256=fgV32phgkBPwqSau7oFDISZ58CVpq214JlYwZQM9bnM,8318
|
|
27
28
|
cycode/cli/apps/ai_guardrails/scan/types.py,sha256=ybQm242QN0l_4SSNX4xMHXxzqEK-MW-hfIOixI7zvGU,1497
|
|
28
29
|
cycode/cli/apps/ai_guardrails/scan/utils.py,sha256=QzR_zmivDYwg2-F8g4bFfsycHhNo-pPuJly0P_l0gm8,2879
|
|
29
|
-
cycode/cli/apps/ai_guardrails/session_start_command.py,sha256=
|
|
30
|
+
cycode/cli/apps/ai_guardrails/session_start_command.py,sha256=oEW-OsHXkf8P7SX1vKa9Ddlv_hASRiQ42JZR3MnJfRg,7171
|
|
30
31
|
cycode/cli/apps/ai_guardrails/status_command.py,sha256=Uqss68TEPCYPXpLix6Bh-4J3g-khxWsAqlIGYH5x4bQ,3203
|
|
31
32
|
cycode/cli/apps/ai_guardrails/uninstall_command.py,sha256=dOmePfZmlHAPy2zEJM1yMtSuDqvzDwtqgmLKYK-T9PI,2698
|
|
32
33
|
cycode/cli/apps/ai_remediation/__init__.py,sha256=8vYthY9RQeJqEni3AIF5sryz8n-XJQ6VNqG4aEFBAdY,553
|
|
@@ -182,7 +183,7 @@ cycode/cli/utils/git_proxy.py,sha256=FPHMBiyLFK9X9vKYpKySRKJH6Dc9Cb3nO241Q95dASE
|
|
|
182
183
|
cycode/cli/utils/host_info.py,sha256=ba3scmnOJQOtNpVT-rf2-FKb0ocGzpPUONsG-uxmhro,6249
|
|
183
184
|
cycode/cli/utils/ignore_utils.py,sha256=cODqhnOHA2kRo8rMY0YcmcKkmXNPOC9UTCmFu62RRqE,15567
|
|
184
185
|
cycode/cli/utils/jwt_utils.py,sha256=EGI-0CKhCGY8hIcZ9b9diq9hqtOUf8Ha8ukeVJIf974,818
|
|
185
|
-
cycode/cli/utils/path_utils.py,sha256=
|
|
186
|
+
cycode/cli/utils/path_utils.py,sha256=zc48CSU7hxqjSgfH6h5M1B0kIcKW44BOJrUa_6z-mAo,4317
|
|
186
187
|
cycode/cli/utils/progress_bar.py,sha256=bKBWHHdZsVkdDdWMJLfgLGR0cBYeB44P_DpRM8pvWqU,9528
|
|
187
188
|
cycode/cli/utils/scan_batch.py,sha256=5xKGVDVqoRxdKhuZkK11x4QrNqKmU20Q83E_fy8Nndk,5188
|
|
188
189
|
cycode/cli/utils/scan_utils.py,sha256=sTj7j9dVHcgeMqfYp8sO78ZiWX8LnhpgjCOi1N1gmAM,2248
|
|
@@ -192,10 +193,10 @@ cycode/cli/utils/task_timer.py,sha256=wxfM2TtJGjc1F17CIja_Qmt6zd4a1qdMwuz0ltgTDA
|
|
|
192
193
|
cycode/cli/utils/trust_store.py,sha256=nEVyaDHhBu2f1Lw1CeHogaD8lQIp6u-sxaVDEKEhIW8,1992
|
|
193
194
|
cycode/cli/utils/url_utils.py,sha256=2ZhRCbQsKnvAl5MBugQ5CrwuALP8uivsCI6chlqxahM,2304
|
|
194
195
|
cycode/cli/utils/version_checker.py,sha256=0f5PaTk02ZkDxzBqZOeMV9mU_CWcx6HKW80jUKFOOZs,8239
|
|
195
|
-
cycode/cli/utils/yaml_utils.py,sha256=
|
|
196
|
+
cycode/cli/utils/yaml_utils.py,sha256=eyOcfNtqvmRMKrKY5jCdwwKKOsXAFchcBAoES0F_o7c,2167
|
|
196
197
|
cycode/config.py,sha256=jHORGZQcAXkAGSf2XreC-RQoc8sdNWja69QKtPWTbWo,1044
|
|
197
198
|
cycode/cyclient/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
198
|
-
cycode/cyclient/ai_security_manager_client.py,sha256=
|
|
199
|
+
cycode/cyclient/ai_security_manager_client.py,sha256=LXOLtA2Moa4bb0r1W9y6gQ9MLN3VPWhaNMGs1GILx9E,5450
|
|
199
200
|
cycode/cyclient/ai_security_manager_service_config.py,sha256=83pQzgOb93JW6E-dznJkI4c0NEXmQRlx9YZKMmjVwp8,808
|
|
200
201
|
cycode/cyclient/auth_client.py,sha256=TwbmZ358Ancf-Q-IZolvfljZ8691_6botsqd0R0PLPk,2105
|
|
201
202
|
cycode/cyclient/base_token_auth_client.py,sha256=mn5580d7A8Z2_zcdFKIJk78ADK7mViwTcV-4QCpRCGo,4369
|
|
@@ -216,8 +217,8 @@ cycode/cyclient/report_client.py,sha256=Scq30NeJPzgXv0hPLO1U05AdE9i_2iu6cIrSKpEJ
|
|
|
216
217
|
cycode/cyclient/scan_client.py,sha256=DqAZ7u6Z_cvw9A9RlLkAQUgLRwPCCAsUq5U9umt4F7Y,16955
|
|
217
218
|
cycode/cyclient/scan_config_base.py,sha256=mXsPZGYCtp85rv5GIige40yQZXuRcEKUW-VQJ0vgFzk,1201
|
|
218
219
|
cycode/logger.py,sha256=EfZGRK6VC5rE_LAjIcRrHFiQCueylCDXoG6bvGkrIME,2111
|
|
219
|
-
cycode-3.
|
|
220
|
-
cycode-3.
|
|
221
|
-
cycode-3.
|
|
222
|
-
cycode-3.
|
|
223
|
-
cycode-3.
|
|
220
|
+
cycode-3.23.1.dev1.dist-info/METADATA,sha256=4vnnUjN1UZGDWdXBApe-U2DMEicw9nRjMpXBNMt-C5A,93687
|
|
221
|
+
cycode-3.23.1.dev1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
222
|
+
cycode-3.23.1.dev1.dist-info/entry_points.txt,sha256=iDcVJM8ByLElVgvBgtYxDjw1kT7O8Mo0LcWZIT5L3Ig,45
|
|
223
|
+
cycode-3.23.1.dev1.dist-info/licenses/LICENCE,sha256=2Wx4N6mD_4xB7-E3hPkZ3MPhpJy__k_I8MaCSO-PDRo,1068
|
|
224
|
+
cycode-3.23.1.dev1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|