cycode 3.15.4.dev1__py3-none-any.whl → 3.15.4.dev2__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/hooks_manager.py +83 -25
- cycode/cli/apps/ai_guardrails/ides/__init__.py +2 -1
- cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +73 -0
- cycode/cli/apps/ai_guardrails/ides/base.py +20 -0
- cycode/cli/apps/ai_guardrails/ides/claude_code.py +32 -52
- cycode/cli/apps/ai_guardrails/ides/codex.py +310 -0
- cycode/cli/apps/ai_guardrails/ides/cursor.py +1 -1
- cycode/cli/apps/ai_guardrails/scan/handlers.py +66 -22
- cycode/cli/utils/jwt_utils.py +8 -0
- {cycode-3.15.4.dev1.dist-info → cycode-3.15.4.dev2.dist-info}/METADATA +3 -1
- {cycode-3.15.4.dev1.dist-info → cycode-3.15.4.dev2.dist-info}/RECORD +15 -13
- {cycode-3.15.4.dev1.dist-info → cycode-3.15.4.dev2.dist-info}/WHEEL +0 -0
- {cycode-3.15.4.dev1.dist-info → cycode-3.15.4.dev2.dist-info}/entry_points.txt +0 -0
- {cycode-3.15.4.dev1.dist-info → cycode-3.15.4.dev2.dist-info}/licenses/LICENCE +0 -0
cycode/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = '3.15.4.
|
|
1
|
+
__version__ = '3.15.4.dev2' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag
|
|
@@ -28,7 +28,7 @@ def _is_cycode_command(command: str) -> bool:
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def is_cycode_hook_entry(entry: dict) -> bool:
|
|
31
|
-
"""
|
|
31
|
+
"""True if any hook inside ``entry`` is owned by Cycode."""
|
|
32
32
|
command = entry.get('command', '')
|
|
33
33
|
if _is_cycode_command(command):
|
|
34
34
|
return True
|
|
@@ -40,6 +40,31 @@ def is_cycode_hook_entry(entry: dict) -> bool:
|
|
|
40
40
|
return False
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def _strip_cycode_from_entry(entry: dict) -> Optional[dict]:
|
|
44
|
+
"""Remove Cycode hooks from ``entry`` and return the remainder.
|
|
45
|
+
|
|
46
|
+
Returns ``None`` when nothing useful remains (Cursor-flat Cycode entry, or
|
|
47
|
+
every nested hook was Cycode). Non-Cycode hooks co-located in the same
|
|
48
|
+
entry are preserved.
|
|
49
|
+
"""
|
|
50
|
+
# Cursor format: the entry itself IS a single hook command.
|
|
51
|
+
if 'command' in entry and 'hooks' not in entry:
|
|
52
|
+
return None if _is_cycode_command(entry.get('command', '')) else entry
|
|
53
|
+
|
|
54
|
+
# Claude Code / Codex format: nested `hooks` list inside the entry.
|
|
55
|
+
nested = entry.get('hooks')
|
|
56
|
+
if isinstance(nested, list):
|
|
57
|
+
kept = [h for h in nested if not (isinstance(h, dict) and _is_cycode_command(h.get('command', '')))]
|
|
58
|
+
if not kept:
|
|
59
|
+
return None
|
|
60
|
+
if len(kept) == len(nested):
|
|
61
|
+
return entry # nothing Cycode-shaped inside; preserve identity
|
|
62
|
+
return {**entry, 'hooks': kept}
|
|
63
|
+
|
|
64
|
+
# Entry has neither shape we recognize — leave it alone defensively.
|
|
65
|
+
return entry
|
|
66
|
+
|
|
67
|
+
|
|
43
68
|
def _load_hooks_file(hooks_path: Path) -> Optional[dict]:
|
|
44
69
|
if not hooks_path.exists():
|
|
45
70
|
return None
|
|
@@ -108,50 +133,83 @@ def install_hooks(
|
|
|
108
133
|
|
|
109
134
|
for event, entries in rendered['hooks'].items():
|
|
110
135
|
existing['hooks'].setdefault(event, [])
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
# Add new Cycode entries
|
|
136
|
+
existing['hooks'][event] = [
|
|
137
|
+
stripped for e in existing['hooks'][event] if (stripped := _strip_cycode_from_entry(e)) is not None
|
|
138
|
+
]
|
|
116
139
|
for entry in entries:
|
|
117
140
|
existing['hooks'][event].append(entry)
|
|
118
141
|
|
|
119
|
-
if _save_hooks_file(hooks_path, existing):
|
|
120
|
-
return
|
|
121
|
-
return False, f'Failed to install hooks to {hooks_path}'
|
|
142
|
+
if not _save_hooks_file(hooks_path, existing):
|
|
143
|
+
return False, f'Failed to install hooks to {hooks_path}'
|
|
122
144
|
|
|
145
|
+
message = f'AI guardrails hooks installed: {hooks_path}'
|
|
123
146
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
147
|
+
# IDE-specific extras (e.g. Codex enables a TOML feature flag).
|
|
148
|
+
extra_ok, extra_message = ide.post_install(scope, repo_path)
|
|
149
|
+
if not extra_ok:
|
|
150
|
+
return False, extra_message
|
|
151
|
+
if extra_message:
|
|
152
|
+
message = f'{message}\n {extra_message}'
|
|
127
153
|
|
|
128
|
-
|
|
129
|
-
if existing is None:
|
|
130
|
-
return True, f'No hooks file found at {hooks_path}'
|
|
154
|
+
return True, message
|
|
131
155
|
|
|
156
|
+
|
|
157
|
+
def _strip_cycode_entries(existing: dict) -> bool:
|
|
158
|
+
"""Mutate ``existing`` to drop Cycode hooks (surgically). Return True if anything changed."""
|
|
132
159
|
modified = False
|
|
133
160
|
for event in list(existing.get('hooks', {}).keys()):
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
161
|
+
before = existing['hooks'][event]
|
|
162
|
+
after: list = []
|
|
163
|
+
for e in before:
|
|
164
|
+
stripped = _strip_cycode_from_entry(e)
|
|
165
|
+
if stripped is None:
|
|
166
|
+
modified = True
|
|
167
|
+
continue
|
|
168
|
+
if stripped is not e:
|
|
169
|
+
modified = True
|
|
170
|
+
after.append(stripped)
|
|
171
|
+
if not after:
|
|
139
172
|
del existing['hooks'][event]
|
|
173
|
+
else:
|
|
174
|
+
existing['hooks'][event] = after
|
|
175
|
+
return modified
|
|
140
176
|
|
|
177
|
+
|
|
178
|
+
def _persist_uninstall(hooks_path: Path, existing: dict, modified: bool) -> tuple[bool, str]:
|
|
179
|
+
"""Apply the uninstall result to disk and return ``(success, message)``."""
|
|
141
180
|
if not modified:
|
|
142
181
|
return True, 'No Cycode hooks found to remove'
|
|
143
|
-
|
|
144
182
|
if not existing.get('hooks'):
|
|
145
183
|
try:
|
|
146
184
|
hooks_path.unlink()
|
|
147
|
-
return True, f'Removed hooks file: {hooks_path}'
|
|
148
185
|
except Exception as e:
|
|
149
186
|
logger.debug('Failed to delete hooks file', exc_info=e)
|
|
150
187
|
return False, f'Failed to remove hooks file: {hooks_path}'
|
|
188
|
+
return True, f'Removed hooks file: {hooks_path}'
|
|
189
|
+
if not _save_hooks_file(hooks_path, existing):
|
|
190
|
+
return False, f'Failed to update hooks file: {hooks_path}'
|
|
191
|
+
return True, f'Cycode hooks removed from: {hooks_path}'
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def uninstall_hooks(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> tuple[bool, str]:
|
|
195
|
+
"""Remove Cycode AI guardrails hooks for ``ide``."""
|
|
196
|
+
hooks_path = ide.settings_path(scope, repo_path)
|
|
197
|
+
|
|
198
|
+
existing = _load_hooks_file(hooks_path)
|
|
199
|
+
if existing is None:
|
|
200
|
+
return True, f'No hooks file found at {hooks_path}'
|
|
151
201
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
202
|
+
modified = _strip_cycode_entries(existing)
|
|
203
|
+
file_ok, message = _persist_uninstall(hooks_path, existing, modified)
|
|
204
|
+
if not file_ok:
|
|
205
|
+
return False, message
|
|
206
|
+
|
|
207
|
+
extra_ok, extra_message = ide.post_uninstall(scope, repo_path)
|
|
208
|
+
if not extra_ok:
|
|
209
|
+
return False, extra_message
|
|
210
|
+
if extra_message:
|
|
211
|
+
message = f'{message}\n {extra_message}'
|
|
212
|
+
return True, message
|
|
155
213
|
|
|
156
214
|
|
|
157
215
|
def get_hooks_status(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> dict:
|
|
@@ -9,11 +9,12 @@ import typer
|
|
|
9
9
|
|
|
10
10
|
from cycode.cli.apps.ai_guardrails.ides.base import IDE
|
|
11
11
|
from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode
|
|
12
|
+
from cycode.cli.apps.ai_guardrails.ides.codex import Codex
|
|
12
13
|
from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor
|
|
13
14
|
|
|
14
15
|
# Single source of truth: name → singleton instance.
|
|
15
16
|
# `--ide` choices and install/uninstall/status iteration both derive from this.
|
|
16
|
-
IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode())}
|
|
17
|
+
IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode(), Codex())}
|
|
17
18
|
|
|
18
19
|
# Default IDE used when `--ide` is omitted. Kept here so the value is colocated
|
|
19
20
|
# with the registry; no module outside `ides/` needs to know which IDE wins.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Shared plugin-resolution helpers for IDE integrations.
|
|
2
|
+
|
|
3
|
+
Both Claude Code and Codex use the same ``<plugin>@<marketplace>`` key convention
|
|
4
|
+
and emit the same telemetry shape — only the marketplace layout and manifest
|
|
5
|
+
location differ. ``walk_enabled_plugins`` is the IDE-agnostic loop; each IDE
|
|
6
|
+
supplies the two callables that vary (``locate_dir`` + ``read_plugin``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Optional
|
|
12
|
+
|
|
13
|
+
from cycode.logger import get_logger
|
|
14
|
+
|
|
15
|
+
logger = get_logger('AI Guardrails Plugins')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_plugin_json(path: Path) -> Optional[dict]:
|
|
19
|
+
"""Load a JSON file inside a plugin directory; None if missing or invalid."""
|
|
20
|
+
if not path.exists():
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
return json.loads(path.read_text(encoding='utf-8'))
|
|
24
|
+
except Exception as e:
|
|
25
|
+
logger.debug('Failed to load plugin file, %s', {'path': str(path)}, exc_info=e)
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def walk_enabled_plugins(
|
|
30
|
+
plugin_entries: dict[str, Any],
|
|
31
|
+
is_enabled: Callable[[Any], bool],
|
|
32
|
+
locate_dir: Callable[[str, str], Optional[Path]],
|
|
33
|
+
read_plugin: Callable[[Path], tuple[dict, dict]],
|
|
34
|
+
) -> tuple[dict, dict]:
|
|
35
|
+
"""Iterate enabled plugins; merge their MCP servers and metadata.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
plugin_entries: ``{<plugin>@<marketplace>: settings}`` map from the IDE config.
|
|
39
|
+
is_enabled: returns True if ``settings`` indicates the plugin is on
|
|
40
|
+
(e.g. ``bool(settings)`` for Claude, ``settings.get('enabled')`` for Codex).
|
|
41
|
+
locate_dir: given ``(plugin_name, marketplace)``, returns the plugin's
|
|
42
|
+
filesystem path or None if it can't be resolved.
|
|
43
|
+
read_plugin: given the plugin path, returns ``(entry_fields, servers)``:
|
|
44
|
+
``entry_fields`` are extra metadata to attach to the inventory entry
|
|
45
|
+
(name/version/description/...), ``servers`` are MCP servers contributed.
|
|
46
|
+
|
|
47
|
+
Returns ``(merged_mcp_servers, enriched_plugins)``. Plugin keys without
|
|
48
|
+
``@`` (or that fail to resolve to a directory) still appear in the
|
|
49
|
+
inventory with just ``{'enabled': True}`` so we don't silently drop them.
|
|
50
|
+
"""
|
|
51
|
+
merged_mcp: dict = {}
|
|
52
|
+
enriched: dict = {}
|
|
53
|
+
|
|
54
|
+
for plugin_key, settings in plugin_entries.items():
|
|
55
|
+
if not is_enabled(settings):
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
entry: dict = {'enabled': True}
|
|
59
|
+
enriched[plugin_key] = entry
|
|
60
|
+
|
|
61
|
+
if '@' not in plugin_key:
|
|
62
|
+
continue
|
|
63
|
+
plugin_name, marketplace = plugin_key.split('@', 1)
|
|
64
|
+
|
|
65
|
+
plugin_dir = locate_dir(plugin_name, marketplace)
|
|
66
|
+
if plugin_dir is None:
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
plugin_fields, servers = read_plugin(plugin_dir)
|
|
70
|
+
entry.update(plugin_fields)
|
|
71
|
+
merged_mcp.update(servers)
|
|
72
|
+
|
|
73
|
+
return merged_mcp, enriched
|
|
@@ -108,6 +108,26 @@ class IDE(ABC):
|
|
|
108
108
|
``hooks_manager`` can treat them uniformly.
|
|
109
109
|
"""
|
|
110
110
|
|
|
111
|
+
def post_install(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
|
|
112
|
+
"""Run IDE-specific actions after the hooks file is written.
|
|
113
|
+
|
|
114
|
+
Default: no-op success. Override to perform extra setup that doesn't
|
|
115
|
+
belong in the hooks file itself — e.g. Codex enables a
|
|
116
|
+
``[features] codex_hooks = true`` flag in its TOML config.
|
|
117
|
+
|
|
118
|
+
Returns ``(success, message)``. If ``success`` is False, the overall
|
|
119
|
+
install is considered failed.
|
|
120
|
+
"""
|
|
121
|
+
return True, ''
|
|
122
|
+
|
|
123
|
+
def post_uninstall(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
|
|
124
|
+
"""Run IDE-specific cleanup after the hooks file is removed.
|
|
125
|
+
|
|
126
|
+
Default: no-op success. Override to undo whatever ``post_install``
|
|
127
|
+
wrote outside the hooks file.
|
|
128
|
+
"""
|
|
129
|
+
return True, ''
|
|
130
|
+
|
|
111
131
|
# --- runtime scan ---
|
|
112
132
|
|
|
113
133
|
@abstractmethod
|
|
@@ -7,6 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
from typing import ClassVar, Optional
|
|
8
8
|
|
|
9
9
|
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
|
|
10
|
+
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins
|
|
10
11
|
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
|
|
11
12
|
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
|
|
12
13
|
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
|
|
@@ -127,7 +128,7 @@ def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]:
|
|
|
127
128
|
"""Load and parse `~/.claude.json`. Returns None if missing/invalid."""
|
|
128
129
|
path = config_path or _CLAUDE_CONFIG_PATH
|
|
129
130
|
if not path.exists():
|
|
130
|
-
logger.debug('Claude config file not found',
|
|
131
|
+
logger.debug('Claude config file not found, %s', {'path': str(path)})
|
|
131
132
|
return None
|
|
132
133
|
try:
|
|
133
134
|
return json.loads(path.read_text(encoding='utf-8'))
|
|
@@ -150,7 +151,7 @@ def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]
|
|
|
150
151
|
"""Load and parse `~/.claude/settings.json`. Returns None if missing/invalid."""
|
|
151
152
|
path = settings_path or _CLAUDE_SETTINGS_PATH
|
|
152
153
|
if not path.exists():
|
|
153
|
-
logger.debug('Claude settings file not found',
|
|
154
|
+
logger.debug('Claude settings file not found, %s', {'path': str(path)})
|
|
154
155
|
return None
|
|
155
156
|
try:
|
|
156
157
|
return json.loads(path.read_text(encoding='utf-8'))
|
|
@@ -171,69 +172,47 @@ def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]:
|
|
|
171
172
|
return path if path.is_dir() else None
|
|
172
173
|
|
|
173
174
|
|
|
174
|
-
def
|
|
175
|
-
"""
|
|
175
|
+
def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]:
|
|
176
|
+
"""Read one Claude Code plugin's manifest + MCP servers.
|
|
176
177
|
|
|
177
|
-
|
|
178
|
+
Claude hardcodes the MCP file at ``<plugin_dir>/.mcp.json`` and always
|
|
179
|
+
wraps it as ``{"mcpServers": {...}}``.
|
|
178
180
|
"""
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
except Exception as e:
|
|
185
|
-
logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e)
|
|
186
|
-
return None
|
|
181
|
+
manifest = load_plugin_json(plugin_dir / '.claude-plugin' / 'plugin.json') or {}
|
|
182
|
+
entry: dict = {}
|
|
183
|
+
for field in ('name', 'version', 'description'):
|
|
184
|
+
if field in manifest:
|
|
185
|
+
entry[field] = manifest[field]
|
|
187
186
|
|
|
187
|
+
mcp_config = load_plugin_json(plugin_dir / '.mcp.json') or {}
|
|
188
|
+
servers: dict = mcp_config.get('mcpServers') or {}
|
|
189
|
+
if servers:
|
|
190
|
+
entry['mcp_server_names'] = list(servers.keys())
|
|
191
|
+
return entry, servers
|
|
188
192
|
|
|
189
|
-
def resolve_plugins(settings: dict) -> tuple[dict, dict]:
|
|
190
|
-
"""Resolve enabled plugins to their MCP servers and metadata.
|
|
191
193
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
- ``<path>/.mcp.json`` for MCP servers (merged into a flat dict)
|
|
195
|
-
- ``<path>/.claude-plugin/plugin.json`` for metadata (name, version, description)
|
|
194
|
+
def resolve_plugins(settings: dict) -> tuple[dict, dict]:
|
|
195
|
+
"""Walk Claude Code's ``enabledPlugins`` via the shared plugin walker.
|
|
196
196
|
|
|
197
|
-
|
|
197
|
+
Each enabled plugin's marketplace is resolved through
|
|
198
|
+
``extraKnownMarketplaces`` to a directory; the rest of the work
|
|
199
|
+
(manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``.
|
|
198
200
|
"""
|
|
199
201
|
enabled = settings.get('enabledPlugins') or {}
|
|
200
202
|
marketplaces = settings.get('extraKnownMarketplaces') or {}
|
|
201
|
-
merged_mcp: dict = {}
|
|
202
|
-
enriched: dict = {}
|
|
203
203
|
|
|
204
|
-
|
|
205
|
-
if not is_enabled:
|
|
206
|
-
continue
|
|
207
|
-
|
|
208
|
-
entry: dict = {'enabled': True}
|
|
209
|
-
enriched[plugin_key] = entry
|
|
210
|
-
|
|
211
|
-
if '@' not in plugin_key:
|
|
212
|
-
continue
|
|
213
|
-
|
|
214
|
-
_plugin_name, marketplace_name = plugin_key.split('@', 1)
|
|
204
|
+
def _locate(_plugin_name: str, marketplace_name: str) -> Optional[Path]:
|
|
215
205
|
marketplace = marketplaces.get(marketplace_name)
|
|
216
206
|
if not marketplace:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
plugin_path = _resolve_marketplace_path(marketplace)
|
|
220
|
-
if plugin_path is None:
|
|
221
|
-
continue
|
|
222
|
-
|
|
223
|
-
metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {}
|
|
224
|
-
for field in ('name', 'version', 'description'):
|
|
225
|
-
if field in metadata:
|
|
226
|
-
entry[field] = metadata[field]
|
|
227
|
-
|
|
228
|
-
mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {}
|
|
229
|
-
plugin_server_names = []
|
|
230
|
-
for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items():
|
|
231
|
-
merged_mcp[server_name] = server_cfg
|
|
232
|
-
plugin_server_names.append(server_name)
|
|
233
|
-
if plugin_server_names:
|
|
234
|
-
entry['mcp_server_names'] = plugin_server_names
|
|
207
|
+
return None
|
|
208
|
+
return _resolve_marketplace_path(marketplace)
|
|
235
209
|
|
|
236
|
-
return
|
|
210
|
+
return walk_enabled_plugins(
|
|
211
|
+
plugin_entries=enabled,
|
|
212
|
+
is_enabled=bool,
|
|
213
|
+
locate_dir=_locate,
|
|
214
|
+
read_plugin=_read_claude_plugin,
|
|
215
|
+
)
|
|
237
216
|
|
|
238
217
|
|
|
239
218
|
# --- IDE integration ----------------------------------------------------------
|
|
@@ -260,6 +239,7 @@ class ClaudeCode(IDE):
|
|
|
260
239
|
'hooks': {
|
|
261
240
|
'SessionStart': [
|
|
262
241
|
{
|
|
242
|
+
'matcher': 'startup|clear',
|
|
263
243
|
'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}],
|
|
264
244
|
}
|
|
265
245
|
],
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Codex CLI IDE integration for AI guardrails."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import ClassVar, Optional
|
|
8
|
+
|
|
9
|
+
import tomli_w
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 11):
|
|
12
|
+
import tomllib
|
|
13
|
+
else: # pragma: no cover - py<3.11 fallback
|
|
14
|
+
import tomli as tomllib
|
|
15
|
+
|
|
16
|
+
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
|
|
17
|
+
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins
|
|
18
|
+
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
|
|
19
|
+
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
|
|
20
|
+
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
|
|
21
|
+
from cycode.cli.utils.jwt_utils import decode_jwt_unverified
|
|
22
|
+
from cycode.logger import get_logger
|
|
23
|
+
|
|
24
|
+
logger = get_logger('AI Guardrails Codex')
|
|
25
|
+
|
|
26
|
+
_CONFIG_DIR_NAME = '.codex'
|
|
27
|
+
_HOOKS_FILE_NAME = 'hooks.json'
|
|
28
|
+
_CONFIG_TOML_NAME = 'config.toml'
|
|
29
|
+
_AUTH_JSON_NAME = 'auth.json'
|
|
30
|
+
_CODEX_HOME_ENV_VAR = 'CODEX_HOME'
|
|
31
|
+
|
|
32
|
+
_HOOK_EVENTS = ('UserPromptSubmit', 'PreToolUse:mcp')
|
|
33
|
+
_CODEX_EVENT_NAMES = frozenset(e.split(':', 1)[0] for e in _HOOK_EVENTS)
|
|
34
|
+
|
|
35
|
+
_SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide codex'
|
|
36
|
+
_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide codex'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _codex_home() -> Path:
|
|
40
|
+
"""Resolve Codex's user-scope home directory.
|
|
41
|
+
|
|
42
|
+
Honors ``$CODEX_HOME`` per Codex's documented override; falls back to
|
|
43
|
+
``~/.codex``.
|
|
44
|
+
"""
|
|
45
|
+
override = os.environ.get(_CODEX_HOME_ENV_VAR)
|
|
46
|
+
if override:
|
|
47
|
+
return Path(override)
|
|
48
|
+
return Path.home() / _CONFIG_DIR_NAME
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _codex_config_toml_path(scope: str, repo_path: Optional[Path] = None) -> Path:
|
|
52
|
+
"""Return the Codex ``config.toml`` path for the given scope."""
|
|
53
|
+
if scope == 'repo' and repo_path:
|
|
54
|
+
return repo_path / _CONFIG_DIR_NAME / _CONFIG_TOML_NAME
|
|
55
|
+
return _codex_home() / _CONFIG_TOML_NAME
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_codex_config(config_path: Optional[Path] = None) -> Optional[dict]:
|
|
59
|
+
"""Load and parse Codex's ``config.toml``. Returns None on missing/invalid."""
|
|
60
|
+
path = config_path or (_codex_home() / _CONFIG_TOML_NAME)
|
|
61
|
+
if not path.exists():
|
|
62
|
+
logger.debug('Codex config file not found, %s', {'path': str(path)})
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
with path.open('rb') as f:
|
|
66
|
+
return tomllib.load(f)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.debug('Failed to load Codex config file, %s', {'path': str(path)}, exc_info=e)
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _email_from_auth(auth_path: Optional[Path] = None) -> Optional[str]:
|
|
73
|
+
"""Best-effort extraction of the signed-in Codex user's email.
|
|
74
|
+
|
|
75
|
+
Reads ``~/.codex/auth.json`` and decodes the JWT in ``tokens.id_token``
|
|
76
|
+
to pull the ``email`` claim. Returns None if auth.json is missing
|
|
77
|
+
(``OPENAI_API_KEY``-only setups, OS keychain credentials) or unreadable.
|
|
78
|
+
"""
|
|
79
|
+
path = auth_path or (_codex_home() / _AUTH_JSON_NAME)
|
|
80
|
+
if not path.exists():
|
|
81
|
+
logger.debug('Codex auth file not found, %s', {'path': str(path)})
|
|
82
|
+
return None
|
|
83
|
+
try:
|
|
84
|
+
auth = json.loads(path.read_text(encoding='utf-8'))
|
|
85
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
86
|
+
logger.debug('Failed to load Codex auth file, %s', {'path': str(path)}, exc_info=e)
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
token = (auth.get('tokens') or {}).get('id_token')
|
|
90
|
+
if not token:
|
|
91
|
+
return None
|
|
92
|
+
claims = decode_jwt_unverified(token)
|
|
93
|
+
if not claims:
|
|
94
|
+
return None
|
|
95
|
+
return claims.get('email')
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resolve_codex_plugin_dir(plugin_name: str, marketplace: str) -> Optional[Path]:
|
|
99
|
+
"""Find ``~/.codex/plugins/cache/<marketplace>/<plugin>/<hash>/``.
|
|
100
|
+
|
|
101
|
+
The trailing segment is a content hash. If multiple are cached, pick the
|
|
102
|
+
most recently modified.
|
|
103
|
+
"""
|
|
104
|
+
base = _codex_home() / 'plugins' / 'cache' / marketplace / plugin_name
|
|
105
|
+
if not base.is_dir():
|
|
106
|
+
return None
|
|
107
|
+
candidates = [d for d in base.iterdir() if d.is_dir()]
|
|
108
|
+
if not candidates:
|
|
109
|
+
return None
|
|
110
|
+
return max(candidates, key=lambda d: d.stat().st_mtime)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]:
|
|
114
|
+
"""Read one Codex plugin's manifest + MCP servers.
|
|
115
|
+
|
|
116
|
+
Codex's manifest references the MCP file via a path string in the
|
|
117
|
+
``mcpServers`` field (default ``./.mcp.json``); the target file is either
|
|
118
|
+
a bare ``{name: cfg}`` map or wrapped in ``{"mcpServers": {...}}``.
|
|
119
|
+
"""
|
|
120
|
+
manifest = load_plugin_json(plugin_dir / '.codex-plugin' / 'plugin.json')
|
|
121
|
+
entry: dict = {}
|
|
122
|
+
if not manifest:
|
|
123
|
+
return entry, {}
|
|
124
|
+
|
|
125
|
+
for field in ('name', 'version', 'description'):
|
|
126
|
+
if field in manifest:
|
|
127
|
+
entry[field] = manifest[field]
|
|
128
|
+
|
|
129
|
+
mcp_ref = manifest.get('mcpServers')
|
|
130
|
+
if not mcp_ref:
|
|
131
|
+
return entry, {}
|
|
132
|
+
mcp_doc = load_plugin_json(plugin_dir / mcp_ref) or {}
|
|
133
|
+
servers = mcp_doc.get('mcpServers', mcp_doc)
|
|
134
|
+
if not isinstance(servers, dict):
|
|
135
|
+
servers = {}
|
|
136
|
+
if servers:
|
|
137
|
+
entry['mcp_server_names'] = list(servers.keys())
|
|
138
|
+
return entry, servers
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _resolve_codex_plugins(config: dict) -> tuple[dict, dict]:
|
|
142
|
+
"""Walk enabled ``[plugins."<plugin>@<marketplace>"]`` entries."""
|
|
143
|
+
return walk_enabled_plugins(
|
|
144
|
+
plugin_entries=config.get('plugins') or {},
|
|
145
|
+
is_enabled=lambda s: isinstance(s, dict) and bool(s.get('enabled')),
|
|
146
|
+
locate_dir=_resolve_codex_plugin_dir,
|
|
147
|
+
read_plugin=_read_codex_plugin,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _enable_codex_hooks_feature(scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
|
|
152
|
+
"""Set ``[features] hooks = true`` in Codex's ``config.toml``.
|
|
153
|
+
|
|
154
|
+
Codex's hook scripts are gated behind this feature flag. We preserve any
|
|
155
|
+
existing keys and create the file (+ parent dir) when missing.
|
|
156
|
+
"""
|
|
157
|
+
config_path = _codex_config_toml_path(scope, repo_path)
|
|
158
|
+
|
|
159
|
+
config: dict = {}
|
|
160
|
+
if config_path.exists():
|
|
161
|
+
try:
|
|
162
|
+
with config_path.open('rb') as f:
|
|
163
|
+
config = tomllib.load(f)
|
|
164
|
+
except Exception as e:
|
|
165
|
+
logger.error('Failed to parse Codex config.toml, %s', {'path': str(config_path)}, exc_info=e)
|
|
166
|
+
return False, f'Failed to parse existing Codex config at {config_path}'
|
|
167
|
+
|
|
168
|
+
features = config.get('features')
|
|
169
|
+
if not isinstance(features, dict):
|
|
170
|
+
features = {}
|
|
171
|
+
features['hooks'] = True
|
|
172
|
+
config['features'] = features
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
with config_path.open('wb') as f:
|
|
177
|
+
tomli_w.dump(config, f)
|
|
178
|
+
return True, f'Enabled hooks feature in {config_path}'
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.error('Failed to write Codex config.toml, %s', {'path': str(config_path)}, exc_info=e)
|
|
181
|
+
return False, f'Failed to write Codex config at {config_path}'
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Codex(IDE):
|
|
185
|
+
name: ClassVar[str] = 'codex'
|
|
186
|
+
display_name: ClassVar[str] = 'Codex'
|
|
187
|
+
hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS)
|
|
188
|
+
|
|
189
|
+
def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
|
|
190
|
+
if scope == 'repo' and repo_path:
|
|
191
|
+
return repo_path / _CONFIG_DIR_NAME / _HOOKS_FILE_NAME
|
|
192
|
+
return _codex_home() / _HOOKS_FILE_NAME
|
|
193
|
+
|
|
194
|
+
def render_hooks_config(self, async_mode: bool = False) -> dict:
|
|
195
|
+
# Codex's TOML `async: true` flag is unimplemented; shell-background via
|
|
196
|
+
# `&` is the working mechanism. SessionStart stays sync so the
|
|
197
|
+
# conversation context is registered before any scan hook fires.
|
|
198
|
+
bg = ' &' if async_mode else ''
|
|
199
|
+
scan_cmd = f'{_SCAN_COMMAND}{bg}'
|
|
200
|
+
return {
|
|
201
|
+
'hooks': {
|
|
202
|
+
'SessionStart': [
|
|
203
|
+
{
|
|
204
|
+
'matcher': 'startup|clear',
|
|
205
|
+
'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}],
|
|
206
|
+
}
|
|
207
|
+
],
|
|
208
|
+
'UserPromptSubmit': [
|
|
209
|
+
{
|
|
210
|
+
'hooks': [{'type': 'command', 'command': scan_cmd}],
|
|
211
|
+
}
|
|
212
|
+
],
|
|
213
|
+
'PreToolUse': [
|
|
214
|
+
{
|
|
215
|
+
'matcher': 'mcp__.*',
|
|
216
|
+
'hooks': [{'type': 'command', 'command': scan_cmd}],
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
def post_install(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
|
|
223
|
+
return _enable_codex_hooks_feature(scope, repo_path)
|
|
224
|
+
|
|
225
|
+
def matches_payload(self, raw_payload: dict) -> bool:
|
|
226
|
+
return raw_payload.get('hook_event_name', '') in _CODEX_EVENT_NAMES
|
|
227
|
+
|
|
228
|
+
def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
|
|
229
|
+
hook_event_name = raw_payload.get('hook_event_name', '')
|
|
230
|
+
tool_name = raw_payload.get('tool_name', '')
|
|
231
|
+
tool_input = raw_payload.get('tool_input')
|
|
232
|
+
|
|
233
|
+
if hook_event_name == 'UserPromptSubmit':
|
|
234
|
+
canonical_event: AiHookEventType | str = AiHookEventType.PROMPT
|
|
235
|
+
elif hook_event_name == 'PreToolUse' and tool_name.startswith('mcp__'):
|
|
236
|
+
canonical_event = AiHookEventType.MCP_EXECUTION
|
|
237
|
+
else:
|
|
238
|
+
canonical_event = hook_event_name
|
|
239
|
+
|
|
240
|
+
mcp_server_name = None
|
|
241
|
+
mcp_tool_name = None
|
|
242
|
+
mcp_arguments = None
|
|
243
|
+
if tool_name.startswith('mcp__'):
|
|
244
|
+
parts = tool_name.split('__')
|
|
245
|
+
if len(parts) >= 2:
|
|
246
|
+
mcp_server_name = parts[1]
|
|
247
|
+
if len(parts) >= 3:
|
|
248
|
+
mcp_tool_name = parts[2]
|
|
249
|
+
mcp_arguments = tool_input
|
|
250
|
+
|
|
251
|
+
return AIHookPayload(
|
|
252
|
+
event_name=canonical_event,
|
|
253
|
+
conversation_id=raw_payload.get('session_id'),
|
|
254
|
+
generation_id=raw_payload.get('turn_id'),
|
|
255
|
+
ide_user_email=_email_from_auth(),
|
|
256
|
+
model=raw_payload.get('model'),
|
|
257
|
+
ide_provider=self.name,
|
|
258
|
+
prompt=raw_payload.get('prompt', ''),
|
|
259
|
+
mcp_server_name=mcp_server_name,
|
|
260
|
+
mcp_tool_name=mcp_tool_name,
|
|
261
|
+
mcp_arguments=mcp_arguments,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def build_hook_response(self, decision: HookDecision) -> dict:
|
|
265
|
+
# Codex accepts the same hook response shapes as Claude Code:
|
|
266
|
+
# - PROMPT: empty for allow, {"decision": "block", "reason": ...} for deny
|
|
267
|
+
# - PreToolUse: hookSpecificOutput.permissionDecision
|
|
268
|
+
if decision.event_type == AiHookEventType.PROMPT:
|
|
269
|
+
if decision.action == DecisionAction.ALLOW:
|
|
270
|
+
return {}
|
|
271
|
+
return {'decision': 'block', 'reason': decision.user_message or ''}
|
|
272
|
+
|
|
273
|
+
if decision.action == DecisionAction.ALLOW:
|
|
274
|
+
return {
|
|
275
|
+
'hookSpecificOutput': {
|
|
276
|
+
'hookEventName': 'PreToolUse',
|
|
277
|
+
'permissionDecision': 'allow',
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
'hookSpecificOutput': {
|
|
282
|
+
'hookEventName': 'PreToolUse',
|
|
283
|
+
'permissionDecision': decision.action.value, # 'deny' or 'ask'
|
|
284
|
+
'permissionDecisionReason': decision.user_message or '',
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
|
|
289
|
+
return AIHookPayload(
|
|
290
|
+
conversation_id=raw_payload.get('session_id'),
|
|
291
|
+
ide_user_email=_email_from_auth(),
|
|
292
|
+
model=raw_payload.get('model'),
|
|
293
|
+
ide_provider=self.name,
|
|
294
|
+
ide_version=raw_payload.get('codex_version'),
|
|
295
|
+
source=raw_payload.get('source'),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def get_user_email(self) -> Optional[str]:
|
|
299
|
+
return _email_from_auth()
|
|
300
|
+
|
|
301
|
+
def get_session_context(self) -> tuple[dict, dict]:
|
|
302
|
+
config = _load_codex_config()
|
|
303
|
+
if not config:
|
|
304
|
+
return {}, {}
|
|
305
|
+
# Codex stores MCP servers under `[mcp_servers.<name>]`. Plugin-contributed
|
|
306
|
+
# servers (via `[plugins."<plugin>@<marketplace>"]`) merge on top.
|
|
307
|
+
mcp_servers: dict = dict(config.get('mcp_servers') or {})
|
|
308
|
+
plugin_mcp, enriched_plugins = _resolve_codex_plugins(config)
|
|
309
|
+
mcp_servers.update(plugin_mcp)
|
|
310
|
+
return mcp_servers, enriched_plugins
|
|
@@ -43,7 +43,7 @@ def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict
|
|
|
43
43
|
"""Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid."""
|
|
44
44
|
path = config_path or (Path.home() / '.cursor' / _MCP_CONFIG_FILENAME)
|
|
45
45
|
if not path.exists():
|
|
46
|
-
logger.debug('Cursor MCP config file not found',
|
|
46
|
+
logger.debug('Cursor MCP config file not found, %s', {'path': str(path)})
|
|
47
47
|
return None
|
|
48
48
|
try:
|
|
49
49
|
return json.loads(path.read_text(encoding='utf-8'))
|
|
@@ -10,6 +10,7 @@ touching any handler in this module.
|
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
13
14
|
from multiprocessing.pool import ThreadPool
|
|
14
15
|
from multiprocessing.pool import TimeoutError as PoolTimeoutError
|
|
15
16
|
from typing import Callable, Optional
|
|
@@ -178,23 +179,44 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
|
|
|
178
179
|
)
|
|
179
180
|
|
|
180
181
|
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class _ArgScanFeature:
|
|
184
|
+
"""Configuration for a "scan some text and decide" event.
|
|
185
|
+
|
|
186
|
+
MCP execution and command exec share identical scan-and-decide logic;
|
|
187
|
+
only the policy key, event type, and user-facing messages differ.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
policy_key: str # 'mcp' or 'command_exec'
|
|
191
|
+
scan_key: str # 'scan_arguments' or 'scan_command'
|
|
192
|
+
event_type: AiHookEventType
|
|
193
|
+
block_reason: BlockReason
|
|
194
|
+
deny_message: Callable[[str], str]
|
|
195
|
+
deny_agent_message: str
|
|
196
|
+
ask_message: Callable[[str], str]
|
|
197
|
+
ask_agent_message: str
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _handle_arg_scan(
|
|
201
|
+
ctx: typer.Context,
|
|
202
|
+
payload: AIHookPayload,
|
|
203
|
+
policy: dict,
|
|
204
|
+
feature: _ArgScanFeature,
|
|
205
|
+
scan_text: str,
|
|
206
|
+
) -> HookDecision:
|
|
207
|
+
"""Shared scan + decision flow for MCP_EXECUTION and COMMAND_EXEC events."""
|
|
183
208
|
ai_client = ctx.obj['ai_security_client']
|
|
184
209
|
|
|
185
|
-
|
|
186
|
-
if not get_policy_value(
|
|
187
|
-
ai_client.create_event(payload,
|
|
188
|
-
return HookDecision.allow(
|
|
210
|
+
feature_config = get_policy_value(policy, feature.policy_key, default={})
|
|
211
|
+
if not get_policy_value(feature_config, 'enabled', default=True):
|
|
212
|
+
ai_client.create_event(payload, feature.event_type, AIHookOutcome.ALLOWED)
|
|
213
|
+
return HookDecision.allow(feature.event_type)
|
|
189
214
|
|
|
190
215
|
mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
|
|
191
|
-
tool = payload.mcp_tool_name or 'unknown'
|
|
192
|
-
args = payload.mcp_arguments or {}
|
|
193
|
-
args_text = args if isinstance(args, str) else json.dumps(args)
|
|
194
216
|
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
|
|
195
217
|
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
|
|
196
|
-
clipped = truncate_utf8(
|
|
197
|
-
action = get_policy_value(
|
|
218
|
+
clipped = truncate_utf8(scan_text, max_bytes)
|
|
219
|
+
action = get_policy_value(feature_config, 'action', default=PolicyMode.BLOCK)
|
|
198
220
|
|
|
199
221
|
scan_id = None
|
|
200
222
|
block_reason = None
|
|
@@ -202,26 +224,25 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
202
224
|
error_message = None
|
|
203
225
|
|
|
204
226
|
try:
|
|
205
|
-
if get_policy_value(
|
|
227
|
+
if get_policy_value(feature_config, feature.scan_key, default=True):
|
|
206
228
|
violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms)
|
|
207
229
|
if violation_summary:
|
|
208
|
-
block_reason =
|
|
230
|
+
block_reason = feature.block_reason
|
|
209
231
|
if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
|
|
210
232
|
outcome = AIHookOutcome.BLOCKED
|
|
211
|
-
user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}'
|
|
212
233
|
return HookDecision.deny(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
234
|
+
feature.event_type,
|
|
235
|
+
feature.deny_message(violation_summary),
|
|
236
|
+
feature.deny_agent_message,
|
|
216
237
|
)
|
|
217
238
|
outcome = AIHookOutcome.WARNED
|
|
218
239
|
return HookDecision.ask(
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
240
|
+
feature.event_type,
|
|
241
|
+
feature.ask_message(violation_summary),
|
|
242
|
+
feature.ask_agent_message,
|
|
222
243
|
)
|
|
223
244
|
|
|
224
|
-
return HookDecision.allow(
|
|
245
|
+
return HookDecision.allow(feature.event_type)
|
|
225
246
|
except Exception as e:
|
|
226
247
|
outcome = (
|
|
227
248
|
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
|
|
@@ -232,7 +253,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
232
253
|
finally:
|
|
233
254
|
ai_client.create_event(
|
|
234
255
|
payload,
|
|
235
|
-
|
|
256
|
+
feature.event_type,
|
|
236
257
|
outcome,
|
|
237
258
|
scan_id=scan_id,
|
|
238
259
|
block_reason=block_reason,
|
|
@@ -240,6 +261,29 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
240
261
|
)
|
|
241
262
|
|
|
242
263
|
|
|
264
|
+
def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision:
|
|
265
|
+
"""Scan MCP tool arguments for secrets before execution."""
|
|
266
|
+
tool = payload.mcp_tool_name or 'unknown'
|
|
267
|
+
args = payload.mcp_arguments or {}
|
|
268
|
+
args_text = args if isinstance(args, str) else json.dumps(args)
|
|
269
|
+
return _handle_arg_scan(
|
|
270
|
+
ctx,
|
|
271
|
+
payload,
|
|
272
|
+
policy,
|
|
273
|
+
_ArgScanFeature(
|
|
274
|
+
policy_key='mcp',
|
|
275
|
+
scan_key='scan_arguments',
|
|
276
|
+
event_type=AiHookEventType.MCP_EXECUTION,
|
|
277
|
+
block_reason=BlockReason.SECRETS_IN_MCP_ARGS,
|
|
278
|
+
deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}',
|
|
279
|
+
deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.',
|
|
280
|
+
ask_message=lambda v: f'{v} in MCP tool call "{tool}". Allow execution?',
|
|
281
|
+
ask_agent_message='Possible secrets detected in tool arguments; proceed with caution.',
|
|
282
|
+
),
|
|
283
|
+
scan_text=args_text,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
243
287
|
def get_handler_for_event(event_type: str) -> Optional[HandlerFn]:
|
|
244
288
|
"""Look up the handler for a canonical event type."""
|
|
245
289
|
handlers: dict[str, HandlerFn] = {
|
cycode/cli/utils/jwt_utils.py
CHANGED
|
@@ -5,6 +5,14 @@ import jwt
|
|
|
5
5
|
_JWT_PAYLOAD_POSSIBLE_USER_ID_FIELD_NAMES = ('userId', 'internalId', 'token-user-id')
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
def decode_jwt_unverified(token: str) -> Optional[dict]:
|
|
9
|
+
"""Return JWT claims without signature verification, or None if the token is unreadable."""
|
|
10
|
+
try:
|
|
11
|
+
return jwt.decode(token, options={'verify_signature': False})
|
|
12
|
+
except jwt.PyJWTError:
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
|
|
8
16
|
def get_user_and_tenant_ids_from_access_token(access_token: str) -> tuple[Optional[str], Optional[str]]:
|
|
9
17
|
payload = jwt.decode(access_token, options={'verify_signature': False})
|
|
10
18
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cycode
|
|
3
|
-
Version: 3.15.4.
|
|
3
|
+
Version: 3.15.4.dev2
|
|
4
4
|
Summary: Boost security in your dev lifecycle via SAST, SCA, Secrets & IaC scanning.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENCE
|
|
@@ -34,6 +34,8 @@ Requires-Dist: pyyaml (>=6.0,<7.0)
|
|
|
34
34
|
Requires-Dist: requests (>=2.32.4,<3.0)
|
|
35
35
|
Requires-Dist: rich (>=13.9.4,<14)
|
|
36
36
|
Requires-Dist: tenacity (>=9.0.0,<9.1.0)
|
|
37
|
+
Requires-Dist: tomli (>=2.0.0,<3.0.0) ; python_version < "3.11"
|
|
38
|
+
Requires-Dist: tomli-w (>=1.0.0,<2.0.0)
|
|
37
39
|
Requires-Dist: typer (>=0.15.3,<0.16.0)
|
|
38
40
|
Requires-Dist: urllib3 (>=2.4.0,<3.0.0)
|
|
39
41
|
Project-URL: Repository, https://github.com/cycodehq/cycode-cli
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
cycode/__init__.py,sha256=
|
|
1
|
+
cycode/__init__.py,sha256=QDmVY8Xl94_IN6IhGe3VfDW0t23u1d5S_iQ9f1k08Ww,115
|
|
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=7ReEcVkRX9IaQ2I7jAj7Sl9smbtvxiuK8-9bitMEQik,7491
|
|
@@ -7,15 +7,17 @@ cycode/cli/apps/activation_manager.py,sha256=Hz9PDJFB-ZmYi4HSG8iYC-fR8j5v25VuUU-
|
|
|
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
9
|
cycode/cli/apps/ai_guardrails/consts.py,sha256=Js2QtSNYG9Kt0eo3vepRd5TFciCeJHEC9NN18zqKvlE,620
|
|
10
|
-
cycode/cli/apps/ai_guardrails/hooks_manager.py,sha256=
|
|
11
|
-
cycode/cli/apps/ai_guardrails/ides/__init__.py,sha256=
|
|
12
|
-
cycode/cli/apps/ai_guardrails/ides/
|
|
13
|
-
cycode/cli/apps/ai_guardrails/ides/
|
|
14
|
-
cycode/cli/apps/ai_guardrails/ides/
|
|
10
|
+
cycode/cli/apps/ai_guardrails/hooks_manager.py,sha256=c8okVel9KjeXWm5QTnvTraWsTwzrUTqqKd6C80C34Y4,9003
|
|
11
|
+
cycode/cli/apps/ai_guardrails/ides/__init__.py,sha256=JMXbQlq-7q1429w3nQq9Z4FZ8K0zdiUK6zCbilmD3JU,1689
|
|
12
|
+
cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py,sha256=_wJfdUHeCJGHQIOdoXQLuMJ4YGaOq_aLjY0ZUyzpsxU,2747
|
|
13
|
+
cycode/cli/apps/ai_guardrails/ides/base.py,sha256=tFWjkuTKBn-4IZUFXHThwKctB6CWOxnG9q9Yr9fscJA,6594
|
|
14
|
+
cycode/cli/apps/ai_guardrails/ides/claude_code.py,sha256=2Rpc22lKGdAmZOCIQtIOTh34g-J_AMUMCDUJocJHzag,13552
|
|
15
|
+
cycode/cli/apps/ai_guardrails/ides/codex.py,sha256=Ep2rNsULM4FzZnej0YXb9KyKBRGIj7qPs-eC78MZd3k,11939
|
|
16
|
+
cycode/cli/apps/ai_guardrails/ides/cursor.py,sha256=_u-DS4Pdvu_UiNh-W5i2ViHPV0IDlDvFrpRnisL3ks8,5235
|
|
15
17
|
cycode/cli/apps/ai_guardrails/install_command.py,sha256=vGZSIvHHVMS9_zhV_6lEhxqtmr5H6uykSq4AS5nxQYw,4278
|
|
16
18
|
cycode/cli/apps/ai_guardrails/scan/__init__.py,sha256=qJc82XiQGiAuc1sYY8Ij_A-qXpxgLPuayQq8xWlouMA,48
|
|
17
19
|
cycode/cli/apps/ai_guardrails/scan/consts.py,sha256=drAslw6vW3kxmbCs2qPCUbUPR7PJouT2lsXtu5sD-lQ,1094
|
|
18
|
-
cycode/cli/apps/ai_guardrails/scan/handlers.py,sha256=
|
|
20
|
+
cycode/cli/apps/ai_guardrails/scan/handlers.py,sha256=pf5PrUIVnGLEEE6QKPny9at5V6Ms5u2IEtFP72hKgqA,15523
|
|
19
21
|
cycode/cli/apps/ai_guardrails/scan/payload.py,sha256=pvT3UUqNMvdK3EVzzPjy4JMlOrF-WgxZ3fHN2AtN5eA,1126
|
|
20
22
|
cycode/cli/apps/ai_guardrails/scan/policy.py,sha256=39s8hnxgjny1l6XAO59wsRcAlpW-LG00GUnO0PfqvuY,2566
|
|
21
23
|
cycode/cli/apps/ai_guardrails/scan/scan_command.py,sha256=-Gl7cHELF1wlwLGno6ZVukFtK6NI6Z3-dTpIOsz8ors,6079
|
|
@@ -171,7 +173,7 @@ cycode/cli/utils/enum_utils.py,sha256=h_VTCfJ-0hnhwDsEznmx56rJrCb5FQ8u6PrI6p8MP3
|
|
|
171
173
|
cycode/cli/utils/get_api_client.py,sha256=wwHabfVCDbFjcIwOn5Raho8MEPiOAgkHlGUEfXKpl8U,3542
|
|
172
174
|
cycode/cli/utils/git_proxy.py,sha256=FPHMBiyLFK9X9vKYpKySRKJH6Dc9Cb3nO241Q95dASE,2911
|
|
173
175
|
cycode/cli/utils/ignore_utils.py,sha256=cODqhnOHA2kRo8rMY0YcmcKkmXNPOC9UTCmFu62RRqE,15567
|
|
174
|
-
cycode/cli/utils/jwt_utils.py,sha256=
|
|
176
|
+
cycode/cli/utils/jwt_utils.py,sha256=EGI-0CKhCGY8hIcZ9b9diq9hqtOUf8Ha8ukeVJIf974,818
|
|
175
177
|
cycode/cli/utils/path_utils.py,sha256=U5te1unzhs9pnU5d9BWExgFWElHQkgKvFxKiOF-lp-w,3245
|
|
176
178
|
cycode/cli/utils/progress_bar.py,sha256=bKBWHHdZsVkdDdWMJLfgLGR0cBYeB44P_DpRM8pvWqU,9528
|
|
177
179
|
cycode/cli/utils/scan_batch.py,sha256=5xKGVDVqoRxdKhuZkK11x4QrNqKmU20Q83E_fy8Nndk,5188
|
|
@@ -205,8 +207,8 @@ cycode/cyclient/report_client.py,sha256=Scq30NeJPzgXv0hPLO1U05AdE9i_2iu6cIrSKpEJ
|
|
|
205
207
|
cycode/cyclient/scan_client.py,sha256=6TK5FQkfrvV7PHqRnUzEn1PBNd2oPYVamvIixcUfe3c,16755
|
|
206
208
|
cycode/cyclient/scan_config_base.py,sha256=mXsPZGYCtp85rv5GIige40yQZXuRcEKUW-VQJ0vgFzk,1201
|
|
207
209
|
cycode/logger.py,sha256=EfZGRK6VC5rE_LAjIcRrHFiQCueylCDXoG6bvGkrIME,2111
|
|
208
|
-
cycode-3.15.4.
|
|
209
|
-
cycode-3.15.4.
|
|
210
|
-
cycode-3.15.4.
|
|
211
|
-
cycode-3.15.4.
|
|
212
|
-
cycode-3.15.4.
|
|
210
|
+
cycode-3.15.4.dev2.dist-info/METADATA,sha256=xiTfy56jgKUKJCRHNswSmoi0vEizW1tCcM1FUQazfp0,89206
|
|
211
|
+
cycode-3.15.4.dev2.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
212
|
+
cycode-3.15.4.dev2.dist-info/entry_points.txt,sha256=iDcVJM8ByLElVgvBgtYxDjw1kT7O8Mo0LcWZIT5L3Ig,45
|
|
213
|
+
cycode-3.15.4.dev2.dist-info/licenses/LICENCE,sha256=2Wx4N6mD_4xB7-E3hPkZ3MPhpJy__k_I8MaCSO-PDRo,1068
|
|
214
|
+
cycode-3.15.4.dev2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|