cycode 3.15.3.dev8__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/command_utils.py +2 -45
- cycode/cli/apps/ai_guardrails/consts.py +3 -135
- cycode/cli/apps/ai_guardrails/hooks_manager.py +123 -152
- cycode/cli/apps/ai_guardrails/ides/__init__.py +45 -0
- cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +73 -0
- cycode/cli/apps/ai_guardrails/ides/base.py +176 -0
- cycode/cli/apps/ai_guardrails/ides/claude_code.py +369 -0
- cycode/cli/apps/ai_guardrails/ides/codex.py +310 -0
- cycode/cli/apps/ai_guardrails/ides/cursor.py +119 -0
- cycode/cli/apps/ai_guardrails/install_command.py +14 -23
- cycode/cli/apps/ai_guardrails/scan/handlers.py +102 -101
- cycode/cli/apps/ai_guardrails/scan/payload.py +14 -255
- cycode/cli/apps/ai_guardrails/scan/scan_command.py +60 -48
- cycode/cli/apps/ai_guardrails/scan/types.py +8 -30
- cycode/cli/apps/ai_guardrails/session_start_command.py +14 -78
- cycode/cli/apps/ai_guardrails/status_command.py +13 -16
- cycode/cli/apps/ai_guardrails/uninstall_command.py +12 -22
- cycode/cli/utils/jwt_utils.py +8 -0
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/METADATA +3 -1
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/RECORD +24 -21
- cycode/cli/apps/ai_guardrails/scan/claude_config.py +0 -159
- cycode/cli/apps/ai_guardrails/scan/cursor_config.py +0 -36
- cycode/cli/apps/ai_guardrails/scan/response_builders.py +0 -135
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/WHEEL +0 -0
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/entry_points.txt +0 -0
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/licenses/LICENCE +0 -0
|
@@ -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
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Cursor IDE integration for AI guardrails."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import platform
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import ClassVar, Optional
|
|
7
|
+
|
|
8
|
+
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
|
|
9
|
+
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
|
|
10
|
+
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
|
|
11
|
+
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
|
|
12
|
+
from cycode.logger import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger('AI Guardrails Cursor')
|
|
15
|
+
|
|
16
|
+
_CURSOR_EVENT_MAPPING: dict[str, AiHookEventType] = {
|
|
17
|
+
'beforeSubmitPrompt': AiHookEventType.PROMPT,
|
|
18
|
+
'beforeReadFile': AiHookEventType.FILE_READ,
|
|
19
|
+
'beforeMCPExecution': AiHookEventType.MCP_EXECUTION,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_HOOKS_FILE_NAME = 'hooks.json'
|
|
23
|
+
_REPO_SUBDIR = '.cursor'
|
|
24
|
+
_MCP_CONFIG_FILENAME = 'mcp.json'
|
|
25
|
+
|
|
26
|
+
# Cursor was the original default IDE — its scan command omits --ide to stay
|
|
27
|
+
# byte-identical with already-installed hooks.json files. Session-start is
|
|
28
|
+
# always explicit because it was introduced after Claude Code support.
|
|
29
|
+
_SCAN_COMMAND = CYCODE_SCAN_PROMPT_COMMAND
|
|
30
|
+
_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide cursor'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _user_hooks_dir() -> Path:
|
|
34
|
+
"""Per-platform Cursor user-scope settings directory."""
|
|
35
|
+
if platform.system() == 'Darwin':
|
|
36
|
+
return Path.home() / '.cursor'
|
|
37
|
+
if platform.system() == 'Windows':
|
|
38
|
+
return Path.home() / 'AppData' / 'Roaming' / 'Cursor'
|
|
39
|
+
return Path.home() / '.config' / 'Cursor'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]:
|
|
43
|
+
"""Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid."""
|
|
44
|
+
path = config_path or (Path.home() / '.cursor' / _MCP_CONFIG_FILENAME)
|
|
45
|
+
if not path.exists():
|
|
46
|
+
logger.debug('Cursor MCP config file not found, %s', {'path': str(path)})
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(path.read_text(encoding='utf-8'))
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.debug('Failed to load Cursor MCP config file', exc_info=e)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Cursor(IDE):
|
|
56
|
+
name: ClassVar[str] = 'cursor'
|
|
57
|
+
display_name: ClassVar[str] = 'Cursor'
|
|
58
|
+
hook_events: ClassVar[list[str]] = list(_CURSOR_EVENT_MAPPING)
|
|
59
|
+
|
|
60
|
+
def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
|
|
61
|
+
if scope == 'repo' and repo_path:
|
|
62
|
+
return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME
|
|
63
|
+
return _user_hooks_dir() / _HOOKS_FILE_NAME
|
|
64
|
+
|
|
65
|
+
def render_hooks_config(self, async_mode: bool = False) -> dict:
|
|
66
|
+
command = f'{_SCAN_COMMAND} &' if async_mode else _SCAN_COMMAND
|
|
67
|
+
hooks = {event: [{'command': command}] for event in self.hook_events}
|
|
68
|
+
hooks['sessionStart'] = [{'command': _SESSION_START_COMMAND}]
|
|
69
|
+
return {'version': 1, 'hooks': hooks}
|
|
70
|
+
|
|
71
|
+
def matches_payload(self, raw_payload: dict) -> bool:
|
|
72
|
+
return raw_payload.get('hook_event_name', '') in _CURSOR_EVENT_MAPPING
|
|
73
|
+
|
|
74
|
+
def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
|
|
75
|
+
cursor_event_name = raw_payload.get('hook_event_name', '')
|
|
76
|
+
canonical_event = _CURSOR_EVENT_MAPPING.get(cursor_event_name, cursor_event_name)
|
|
77
|
+
return AIHookPayload(
|
|
78
|
+
event_name=canonical_event,
|
|
79
|
+
conversation_id=raw_payload.get('conversation_id'),
|
|
80
|
+
generation_id=raw_payload.get('generation_id'),
|
|
81
|
+
ide_user_email=raw_payload.get('user_email'),
|
|
82
|
+
model=raw_payload.get('model'),
|
|
83
|
+
ide_provider=self.name,
|
|
84
|
+
ide_version=raw_payload.get('cursor_version'),
|
|
85
|
+
prompt=raw_payload.get('prompt', ''),
|
|
86
|
+
file_path=raw_payload.get('file_path') or raw_payload.get('path'),
|
|
87
|
+
mcp_server_name=raw_payload.get('command'),
|
|
88
|
+
mcp_tool_name=raw_payload.get('tool_name') or raw_payload.get('tool'),
|
|
89
|
+
mcp_arguments=(raw_payload.get('arguments') or raw_payload.get('tool_input') or raw_payload.get('input')),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def build_hook_response(self, decision: HookDecision) -> dict:
|
|
93
|
+
if decision.event_type == AiHookEventType.PROMPT:
|
|
94
|
+
if decision.action == DecisionAction.ALLOW:
|
|
95
|
+
return {'continue': True}
|
|
96
|
+
return {'continue': False, 'user_message': decision.user_message or ''}
|
|
97
|
+
|
|
98
|
+
# FILE_READ / MCP_EXECUTION → permission shape
|
|
99
|
+
if decision.action == DecisionAction.ALLOW:
|
|
100
|
+
return {'permission': 'allow'}
|
|
101
|
+
return {
|
|
102
|
+
'permission': decision.action.value, # 'deny' or 'ask'
|
|
103
|
+
'user_message': decision.user_message or '',
|
|
104
|
+
'agent_message': decision.agent_message or '',
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
|
|
108
|
+
return AIHookPayload(
|
|
109
|
+
conversation_id=raw_payload.get('conversation_id'),
|
|
110
|
+
ide_user_email=raw_payload.get('user_email'),
|
|
111
|
+
model=raw_payload.get('model'),
|
|
112
|
+
ide_provider=self.name,
|
|
113
|
+
ide_version=raw_payload.get('cursor_version'),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def get_session_context(self) -> tuple[dict, dict]:
|
|
117
|
+
config = _load_cursor_mcp_config()
|
|
118
|
+
mcp_servers = dict((config or {}).get('mcpServers') or {}) if config else {}
|
|
119
|
+
return mcp_servers, {}
|
|
@@ -5,14 +5,10 @@ from typing import Annotated, Optional
|
|
|
5
5
|
|
|
6
6
|
import typer
|
|
7
7
|
|
|
8
|
-
from cycode.cli.apps.ai_guardrails.command_utils import
|
|
9
|
-
|
|
10
|
-
resolve_repo_path,
|
|
11
|
-
validate_and_parse_ide,
|
|
12
|
-
validate_scope,
|
|
13
|
-
)
|
|
14
|
-
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType, InstallMode, PolicyMode
|
|
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 InstallMode, PolicyMode
|
|
15
10
|
from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, install_hooks
|
|
11
|
+
from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides
|
|
16
12
|
|
|
17
13
|
|
|
18
14
|
def install_command(
|
|
@@ -29,9 +25,9 @@ def install_command(
|
|
|
29
25
|
str,
|
|
30
26
|
typer.Option(
|
|
31
27
|
'--ide',
|
|
32
|
-
help='IDE to install hooks for (
|
|
28
|
+
help=f'IDE to install hooks for ({", ".join(IDES)}, or "all" for every supported IDE).',
|
|
33
29
|
),
|
|
34
|
-
] =
|
|
30
|
+
] = DEFAULT_IDE_NAME,
|
|
35
31
|
repo_path: Annotated[
|
|
36
32
|
Optional[Path],
|
|
37
33
|
typer.Option(
|
|
@@ -55,35 +51,30 @@ def install_command(
|
|
|
55
51
|
) -> None:
|
|
56
52
|
"""Install AI guardrails hooks for supported IDEs.
|
|
57
53
|
|
|
58
|
-
|
|
59
|
-
and MCP tool calls for secrets before they
|
|
54
|
+
Configures the specified IDE to use Cycode for scanning prompts, file reads,
|
|
55
|
+
and MCP tool calls for secrets before they reach the AI model.
|
|
60
56
|
|
|
61
57
|
Examples:
|
|
62
58
|
cycode ai-guardrails install # Install in report mode (default)
|
|
63
59
|
cycode ai-guardrails install --mode block # Install in block mode
|
|
64
60
|
cycode ai-guardrails install --scope repo # Install for current repo only
|
|
65
|
-
cycode ai-guardrails install --ide
|
|
66
|
-
cycode ai-guardrails install --ide all # Install for
|
|
67
|
-
cycode ai-guardrails install --scope repo --repo-path /path/to/repo
|
|
61
|
+
cycode ai-guardrails install --ide claude-code # Install for a specific IDE
|
|
62
|
+
cycode ai-guardrails install --ide all # Install for every supported IDE
|
|
68
63
|
"""
|
|
69
|
-
# Validate inputs
|
|
70
64
|
validate_scope(scope)
|
|
71
65
|
repo_path = resolve_repo_path(scope, repo_path)
|
|
72
|
-
|
|
66
|
+
ides_to_install = resolve_ides(ide)
|
|
73
67
|
|
|
74
|
-
|
|
68
|
+
report_mode = mode == InstallMode.REPORT
|
|
75
69
|
|
|
76
70
|
results: list[tuple[str, bool, str]] = []
|
|
77
71
|
for current_ide in ides_to_install:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
success, message = install_hooks(scope, repo_path, ide=current_ide, report_mode=report_mode)
|
|
81
|
-
results.append((ide_name, success, message))
|
|
72
|
+
success, message = install_hooks(current_ide, scope, repo_path, report_mode=report_mode)
|
|
73
|
+
results.append((current_ide.display_name, success, message))
|
|
82
74
|
|
|
83
|
-
# Report results for each IDE
|
|
84
75
|
any_success = False
|
|
85
76
|
all_success = True
|
|
86
|
-
for
|
|
77
|
+
for _name, success, message in results:
|
|
87
78
|
if success:
|
|
88
79
|
console.print(f'[green]✓[/] {message}')
|
|
89
80
|
any_success = True
|