cycode 3.25.1.dev6__py3-none-any.whl → 3.25.2.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/app.py +9 -10
- cycode/cli/apps/activation_manager.py +6 -6
- cycode/cli/apps/ai_guardrails/command_utils.py +1 -2
- cycode/cli/apps/ai_guardrails/hooks_manager.py +6 -7
- cycode/cli/apps/ai_guardrails/ides/__init__.py +8 -9
- cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +11 -10
- cycode/cli/apps/ai_guardrails/ides/_skill_utils.py +1 -2
- cycode/cli/apps/ai_guardrails/ides/base.py +19 -21
- cycode/cli/apps/ai_guardrails/ides/claude_code.py +54 -16
- cycode/cli/apps/ai_guardrails/ides/codex.py +54 -20
- cycode/cli/apps/ai_guardrails/ides/copilot.py +23 -25
- cycode/cli/apps/ai_guardrails/ides/cursor.py +48 -10
- cycode/cli/apps/ai_guardrails/install_command.py +3 -3
- cycode/cli/apps/ai_guardrails/scan/guardrail_config.py +44 -22
- cycode/cli/apps/ai_guardrails/scan/handlers.py +79 -14
- cycode/cli/apps/ai_guardrails/scan/mcp_server_status.py +103 -0
- cycode/cli/apps/ai_guardrails/scan/payload.py +13 -14
- cycode/cli/apps/ai_guardrails/scan/policy.py +3 -3
- cycode/cli/apps/ai_guardrails/scan/scan_command.py +4 -4
- cycode/cli/apps/ai_guardrails/scan/types.py +1 -0
- cycode/cli/apps/ai_guardrails/session_start_command.py +46 -21
- cycode/cli/apps/ai_guardrails/status_command.py +2 -2
- cycode/cli/apps/ai_guardrails/uninstall_command.py +2 -2
- cycode/cli/apps/api/__init__.py +3 -3
- cycode/cli/apps/api/api_command.py +6 -6
- cycode/cli/apps/api/openapi_spec.py +4 -5
- cycode/cli/apps/auth/auth_common.py +4 -4
- cycode/cli/apps/configure/configure_command.py +2 -4
- cycode/cli/apps/configure/messages.py +2 -4
- cycode/cli/apps/configure/prompts.py +5 -7
- cycode/cli/apps/ignore/ignore_command.py +7 -7
- cycode/cli/apps/mcp/mcp_command.py +11 -11
- cycode/cli/apps/report/sbom/common.py +5 -5
- cycode/cli/apps/report/sbom/sbom_command.py +2 -2
- cycode/cli/apps/report/sbom/sbom_report_file.py +1 -2
- cycode/cli/apps/report_import/sbom/sbom_command.py +3 -3
- cycode/cli/apps/sca_options.py +3 -3
- cycode/cli/apps/scan/aggregation_report.py +4 -4
- cycode/cli/apps/scan/code_scanner.py +8 -7
- cycode/cli/apps/scan/commit_range_scanner.py +10 -10
- cycode/cli/apps/scan/detection_excluder.py +2 -4
- cycode/cli/apps/scan/pre_commit/pre_commit_command.py +3 -3
- cycode/cli/apps/scan/pre_push/pre_push_command.py +3 -3
- cycode/cli/apps/scan/pre_receive/pre_receive_command.py +2 -2
- cycode/cli/apps/scan/remote_url_resolver.py +6 -8
- cycode/cli/apps/scan/repository/repository_command.py +2 -2
- cycode/cli/apps/scan/scan_command.py +2 -2
- cycode/cli/apps/scan/scan_parameters.py +1 -3
- cycode/cli/apps/scan/scan_result.py +5 -5
- cycode/cli/console.py +2 -2
- cycode/cli/exceptions/custom_exceptions.py +1 -1
- cycode/cli/exceptions/handle_errors.py +1 -3
- cycode/cli/exceptions/handle_scan_errors.py +1 -2
- cycode/cli/files_collector/commit_range_documents.py +14 -14
- cycode/cli/files_collector/models/in_memory_zip.py +3 -3
- cycode/cli/files_collector/repository_documents.py +3 -3
- cycode/cli/files_collector/sca/base_restore_dependencies.py +7 -8
- cycode/cli/files_collector/sca/go/restore_go_dependencies.py +1 -2
- cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +2 -3
- cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +3 -4
- cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py +3 -4
- cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py +1 -2
- cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py +2 -3
- cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py +2 -3
- cycode/cli/files_collector/sca/npm/workspace/coverage.py +5 -5
- cycode/cli/files_collector/sca/npm/workspace/files.py +2 -3
- cycode/cli/files_collector/sca/npm/workspace/globs.py +1 -2
- cycode/cli/files_collector/sca/npm/workspace/resolvers.py +14 -16
- cycode/cli/files_collector/sca/php/restore_composer_dependencies.py +1 -2
- cycode/cli/files_collector/sca/python/restore_pip_dependencies.py +2 -3
- cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py +1 -2
- cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py +2 -3
- cycode/cli/files_collector/sca/python/restore_uv_dependencies.py +2 -3
- cycode/cli/files_collector/sca/sca_file_collector.py +5 -5
- cycode/cli/files_collector/zip_documents.py +1 -2
- cycode/cli/models.py +8 -8
- cycode/cli/printers/console_printer.py +9 -9
- cycode/cli/printers/json_printer.py +2 -2
- cycode/cli/printers/printer_base.py +3 -3
- cycode/cli/printers/rich_printer.py +2 -2
- cycode/cli/printers/tables/table.py +4 -4
- cycode/cli/printers/tables/table_models.py +2 -2
- cycode/cli/printers/tables/table_printer_base.py +2 -2
- cycode/cli/printers/text_printer.py +4 -4
- cycode/cli/printers/utils/code_snippet_syntax.py +1 -1
- cycode/cli/printers/utils/detection_data.py +3 -3
- cycode/cli/printers/utils/sca_ossf.py +4 -4
- cycode/cli/printers/utils/sca_policy_details.py +2 -1
- cycode/cli/user_settings/config_file_manager.py +10 -10
- cycode/cli/user_settings/configuration_manager.py +6 -6
- cycode/cli/user_settings/credentials_manager.py +5 -6
- cycode/cli/utils/get_api_client.py +5 -5
- cycode/cli/utils/git_proxy.py +8 -8
- cycode/cli/utils/host_info.py +21 -38
- cycode/cli/utils/ignore_utils.py +13 -15
- cycode/cli/utils/jwt_utils.py +2 -4
- cycode/cli/utils/path_utils.py +6 -6
- cycode/cli/utils/progress_bar.py +4 -4
- cycode/cli/utils/scan_batch.py +2 -1
- cycode/cli/utils/scan_utils.py +2 -2
- cycode/cli/utils/shell_executor.py +3 -4
- cycode/cli/utils/task_timer.py +4 -4
- cycode/cli/utils/trust_store.py +0 -17
- cycode/cli/utils/url_utils.py +1 -2
- cycode/cli/utils/version_checker.py +4 -5
- cycode/cli/utils/yaml_utils.py +1 -1
- cycode/config.py +1 -2
- cycode/cyclient/ai_security_manager_client.py +27 -21
- cycode/cyclient/auth_client.py +2 -4
- cycode/cyclient/base_token_auth_client.py +2 -2
- cycode/cyclient/client_creator.py +4 -6
- cycode/cyclient/cycode_client_base.py +10 -9
- cycode/cyclient/cycode_dev_based_client.py +1 -3
- cycode/cyclient/headers.py +1 -2
- cycode/cyclient/import_sbom_client.py +3 -4
- cycode/cyclient/models.py +88 -30
- cycode/cyclient/report_client.py +1 -2
- cycode/cyclient/scan_client.py +8 -7
- cycode/logger.py +4 -4
- {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/METADATA +12 -19
- {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/RECORD +125 -124
- {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/WHEEL +1 -1
- {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/entry_points.txt +0 -0
- {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/licenses/LICENCE +0 -0
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import re
|
|
5
6
|
import sys
|
|
7
|
+
from collections.abc import Iterator
|
|
6
8
|
from pathlib import Path
|
|
7
|
-
from typing import ClassVar
|
|
9
|
+
from typing import ClassVar
|
|
8
10
|
|
|
9
11
|
import tomli_w
|
|
10
12
|
|
|
@@ -15,7 +17,7 @@ else: # pragma: no cover - py<3.11 fallback
|
|
|
15
17
|
|
|
16
18
|
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
|
|
17
19
|
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
|
|
18
|
-
|
|
20
|
+
build_global_config_files,
|
|
19
21
|
load_plugin_json,
|
|
20
22
|
resolve_cached_plugin_dir,
|
|
21
23
|
walk_enabled_plugins,
|
|
@@ -59,14 +61,14 @@ def _codex_skills_dir() -> Path:
|
|
|
59
61
|
return _codex_home() / 'skills'
|
|
60
62
|
|
|
61
63
|
|
|
62
|
-
def _codex_config_toml_path(scope: str, repo_path:
|
|
64
|
+
def _codex_config_toml_path(scope: str, repo_path: Path | None = None) -> Path:
|
|
63
65
|
"""Return the Codex ``config.toml`` path for the given scope."""
|
|
64
66
|
if scope == 'repo' and repo_path:
|
|
65
67
|
return repo_path / _CONFIG_DIR_NAME / _CONFIG_TOML_NAME
|
|
66
68
|
return _codex_home() / _CONFIG_TOML_NAME
|
|
67
69
|
|
|
68
70
|
|
|
69
|
-
def _load_codex_config(config_path:
|
|
71
|
+
def _load_codex_config(config_path: Path | None = None) -> dict | None:
|
|
70
72
|
"""Load and parse Codex's ``config.toml``. Returns None on missing/invalid."""
|
|
71
73
|
path = config_path or (_codex_home() / _CONFIG_TOML_NAME)
|
|
72
74
|
if not path.exists():
|
|
@@ -80,7 +82,7 @@ def _load_codex_config(config_path: Optional[Path] = None) -> Optional[dict]:
|
|
|
80
82
|
return None
|
|
81
83
|
|
|
82
84
|
|
|
83
|
-
def _email_from_auth(auth_path:
|
|
85
|
+
def _email_from_auth(auth_path: Path | None = None) -> str | None:
|
|
84
86
|
"""Best-effort extraction of the signed-in Codex user's email.
|
|
85
87
|
|
|
86
88
|
Reads ``~/.codex/auth.json`` and decodes the JWT in ``tokens.id_token``
|
|
@@ -106,7 +108,7 @@ def _email_from_auth(auth_path: Optional[Path] = None) -> Optional[str]:
|
|
|
106
108
|
return claims.get('email')
|
|
107
109
|
|
|
108
110
|
|
|
109
|
-
def _resolve_codex_plugin_dir(plugin_name: str, marketplace: str) ->
|
|
111
|
+
def _resolve_codex_plugin_dir(plugin_name: str, marketplace: str) -> Path | None:
|
|
110
112
|
"""Find ``~/.codex/plugins/cache/<marketplace>/<plugin>/<hash>/``."""
|
|
111
113
|
return resolve_cached_plugin_dir(_codex_home() / 'plugins' / 'cache', marketplace, plugin_name)
|
|
112
114
|
|
|
@@ -158,7 +160,43 @@ def _resolve_codex_plugins(config: dict) -> dict:
|
|
|
158
160
|
)
|
|
159
161
|
|
|
160
162
|
|
|
161
|
-
def
|
|
163
|
+
def _sanitize_mcp_name(name: str) -> str:
|
|
164
|
+
"""Mirrors how Codex sanitizes a server name into a tool namespace."""
|
|
165
|
+
return re.sub(r'[^A-Za-z0-9_]', '_', name) or '_'
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _local_mcp_server_names() -> Iterator[str]:
|
|
169
|
+
"""Yield the config key of each locally configured Codex MCP server, plugin servers included."""
|
|
170
|
+
config = _load_codex_config()
|
|
171
|
+
if not config:
|
|
172
|
+
return
|
|
173
|
+
servers = config.get('mcp_servers')
|
|
174
|
+
if isinstance(servers, dict):
|
|
175
|
+
yield from servers
|
|
176
|
+
for entry in _resolve_codex_plugins(config).values():
|
|
177
|
+
yield from entry.get('mcp_server_names') or []
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _split_mcp_tool_name(tool_name: str) -> tuple[str | None, str | None]:
|
|
181
|
+
"""Split ``mcp__<namespace>__<tool>`` into ``(server config key, tool)``; unknown namespaces split on ``__``."""
|
|
182
|
+
rest = tool_name[len('mcp__') :]
|
|
183
|
+
names = list(_local_mcp_server_names())
|
|
184
|
+
# Codex appends `_<12 hex>` to a namespace on collision or length overflow.
|
|
185
|
+
for suffix in ('__', r'_+[0-9a-f]{12}__'):
|
|
186
|
+
best: tuple[int, str] | None = None
|
|
187
|
+
for name in names:
|
|
188
|
+
namespace = _sanitize_mcp_name(name).rstrip('_')
|
|
189
|
+
match = namespace and re.match(re.escape(namespace) + suffix, rest)
|
|
190
|
+
if match and (best is None or match.end() > best[0]):
|
|
191
|
+
best = (match.end(), name)
|
|
192
|
+
if best:
|
|
193
|
+
return best[1], rest[best[0] :]
|
|
194
|
+
|
|
195
|
+
parts = tool_name.split('__')
|
|
196
|
+
return (parts[1] if len(parts) >= 2 else None), (parts[2] if len(parts) >= 3 else None)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _enable_codex_hooks_feature(scope: str, repo_path: Path | None = None) -> tuple[bool, str]:
|
|
162
200
|
"""Set ``[features] hooks = true`` in Codex's ``config.toml``.
|
|
163
201
|
|
|
164
202
|
Codex's hook scripts are gated behind this feature flag. We preserve any
|
|
@@ -196,7 +234,7 @@ class Codex(IDE):
|
|
|
196
234
|
display_name: ClassVar[str] = 'Codex'
|
|
197
235
|
hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS)
|
|
198
236
|
|
|
199
|
-
def settings_path(self, scope: str, repo_path:
|
|
237
|
+
def settings_path(self, scope: str, repo_path: Path | None = None) -> Path:
|
|
200
238
|
if scope == 'repo' and repo_path:
|
|
201
239
|
return repo_path / _CONFIG_DIR_NAME / _HOOKS_FILE_NAME
|
|
202
240
|
return _codex_home() / _HOOKS_FILE_NAME
|
|
@@ -226,7 +264,7 @@ class Codex(IDE):
|
|
|
226
264
|
},
|
|
227
265
|
}
|
|
228
266
|
|
|
229
|
-
def post_install(self, scope: str, repo_path:
|
|
267
|
+
def post_install(self, scope: str, repo_path: Path | None = None) -> tuple[bool, str]:
|
|
230
268
|
return _enable_codex_hooks_feature(scope, repo_path)
|
|
231
269
|
|
|
232
270
|
def matches_payload(self, raw_payload: dict) -> bool:
|
|
@@ -247,12 +285,8 @@ class Codex(IDE):
|
|
|
247
285
|
mcp_server_name = None
|
|
248
286
|
mcp_tool_name = None
|
|
249
287
|
mcp_arguments = None
|
|
250
|
-
if
|
|
251
|
-
|
|
252
|
-
if len(parts) >= 2:
|
|
253
|
-
mcp_server_name = parts[1]
|
|
254
|
-
if len(parts) >= 3:
|
|
255
|
-
mcp_tool_name = parts[2]
|
|
288
|
+
if canonical_event == AiHookEventType.MCP_EXECUTION:
|
|
289
|
+
mcp_server_name, mcp_tool_name = _split_mcp_tool_name(tool_name)
|
|
256
290
|
mcp_arguments = tool_input
|
|
257
291
|
|
|
258
292
|
return AIHookPayload(
|
|
@@ -299,20 +333,20 @@ class Codex(IDE):
|
|
|
299
333
|
source=raw_payload.get('source'),
|
|
300
334
|
)
|
|
301
335
|
|
|
302
|
-
def get_user_email(self) ->
|
|
336
|
+
def get_user_email(self) -> str | None:
|
|
303
337
|
return _email_from_auth()
|
|
304
338
|
|
|
305
|
-
def get_session_context(self) -> tuple[
|
|
339
|
+
def get_session_context(self) -> tuple[list[dict], dict]:
|
|
306
340
|
config = _load_codex_config()
|
|
307
341
|
if not config:
|
|
308
|
-
return
|
|
342
|
+
return [], {}
|
|
309
343
|
# Codex stores MCP servers under `[mcp_servers.<name>]`; the global config
|
|
310
344
|
# file becomes its own session-context file. Plugins (via
|
|
311
345
|
# `[plugins."<plugin>@<marketplace>"]`) carry their own config files.
|
|
312
346
|
config_path = _codex_config_toml_path('user')
|
|
313
|
-
|
|
347
|
+
global_config_files = build_global_config_files(config_path, config.get('mcp_servers'))
|
|
314
348
|
enriched_plugins = _resolve_codex_plugins(config)
|
|
315
|
-
return
|
|
349
|
+
return global_config_files, enriched_plugins
|
|
316
350
|
|
|
317
351
|
def get_skills(self) -> list[dict]:
|
|
318
352
|
return walk_skill_dirs(_codex_skills_dir())
|
|
@@ -27,13 +27,13 @@ import platform
|
|
|
27
27
|
import re
|
|
28
28
|
from collections.abc import Iterable
|
|
29
29
|
from pathlib import Path
|
|
30
|
-
from typing import ClassVar
|
|
30
|
+
from typing import ClassVar
|
|
31
31
|
from urllib.parse import urlparse
|
|
32
32
|
from urllib.request import url2pathname
|
|
33
33
|
|
|
34
34
|
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
|
|
35
35
|
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
|
|
36
|
-
|
|
36
|
+
build_global_config_files,
|
|
37
37
|
load_plugin_json,
|
|
38
38
|
walk_enabled_plugins,
|
|
39
39
|
)
|
|
@@ -131,7 +131,7 @@ def _vscode_mcp_config_path() -> Path:
|
|
|
131
131
|
return _vscode_user_dir() / _MCP_CONFIG_FILENAME
|
|
132
132
|
|
|
133
133
|
|
|
134
|
-
def _load_vscode_mcp_config(config_path:
|
|
134
|
+
def _load_vscode_mcp_config(config_path: Path | None = None) -> dict | None:
|
|
135
135
|
"""Load and parse VS Code's user-level ``mcp.json``. Returns None if missing/invalid."""
|
|
136
136
|
path = config_path or _vscode_mcp_config_path()
|
|
137
137
|
if not path.exists():
|
|
@@ -144,7 +144,7 @@ def _load_vscode_mcp_config(config_path: Optional[Path] = None) -> Optional[dict
|
|
|
144
144
|
return None
|
|
145
145
|
|
|
146
146
|
|
|
147
|
-
def _load_jsonc(path: Path) ->
|
|
147
|
+
def _load_jsonc(path: Path) -> dict | None:
|
|
148
148
|
"""Parse a JSON file tolerating //-comment lines (Copilot's config.json ships
|
|
149
149
|
with a comment header; VS Code's settings.json is JSONC).
|
|
150
150
|
|
|
@@ -283,6 +283,15 @@ def _collect_installed_plugins() -> dict:
|
|
|
283
283
|
# --- MCP tool-name splitting ------------------------------------------------------
|
|
284
284
|
|
|
285
285
|
|
|
286
|
+
def _global_mcp_server_maps() -> list[tuple[Path, object]]:
|
|
287
|
+
"""``(path, servers)`` of each runtime's global MCP config: VS Code's ``servers``, the agent's ``mcpServers``."""
|
|
288
|
+
agent_config_path = _copilot_home() / _AGENT_MCP_CONFIG_FILENAME
|
|
289
|
+
return [
|
|
290
|
+
(_vscode_mcp_config_path(), (_load_vscode_mcp_config() or {}).get('servers')),
|
|
291
|
+
(agent_config_path, (_load_jsonc(agent_config_path) or {}).get('mcpServers')),
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
|
|
286
295
|
def _known_mcp_server_names() -> list[str]:
|
|
287
296
|
"""Config-declared MCP server names, across both runtimes' config files.
|
|
288
297
|
|
|
@@ -293,15 +302,7 @@ def _known_mcp_server_names() -> list[str]:
|
|
|
293
302
|
Best-effort inventory: servers contributed by extensions, ``chat.mcp.discovery``
|
|
294
303
|
imports, dev containers, or non-default profiles are not discoverable from disk.
|
|
295
304
|
"""
|
|
296
|
-
|
|
297
|
-
servers = (config or {}).get('servers')
|
|
298
|
-
names = list(servers.keys()) if isinstance(servers, dict) else []
|
|
299
|
-
|
|
300
|
-
agent_config = _load_jsonc(_copilot_home() / _AGENT_MCP_CONFIG_FILENAME) or {}
|
|
301
|
-
agent_servers = agent_config.get('mcpServers')
|
|
302
|
-
if isinstance(agent_servers, dict):
|
|
303
|
-
names.extend(agent_servers.keys())
|
|
304
|
-
|
|
305
|
+
names = [name for _, servers in _global_mcp_server_maps() if isinstance(servers, dict) for name in servers]
|
|
305
306
|
for plugin in _collect_installed_plugins().values():
|
|
306
307
|
names.extend(plugin.get('mcp_server_names') or [])
|
|
307
308
|
return names
|
|
@@ -320,7 +321,7 @@ def _server_name_variants(server_name: str) -> set[str]:
|
|
|
320
321
|
return {v for v in (server_name, underscored, collapsed) if v}
|
|
321
322
|
|
|
322
323
|
|
|
323
|
-
def _read_file_path(tool_name: str, tool_input: object) ->
|
|
324
|
+
def _read_file_path(tool_name: str, tool_input: object) -> str | None:
|
|
324
325
|
"""Path of a file-read tool call, or None when this isn't one.
|
|
325
326
|
|
|
326
327
|
The agent runtime reuses its read tool for directory listings, with a payload
|
|
@@ -349,7 +350,7 @@ def is_mcp_tool_name(tool_name: str) -> bool:
|
|
|
349
350
|
return tool_name.startswith(_MCP_TOOL_PREFIX) or _MCP_AGENT_SEPARATOR in tool_name
|
|
350
351
|
|
|
351
352
|
|
|
352
|
-
def split_mcp_tool_name(tool_name: str, server_names: Iterable[str]) -> tuple[
|
|
353
|
+
def split_mcp_tool_name(tool_name: str, server_names: Iterable[str]) -> tuple[str | None, str | None]:
|
|
353
354
|
"""Split an MCP tool name into ``(server, tool)``.
|
|
354
355
|
|
|
355
356
|
Handles both naming schemes: VS Code's ``mcp_<server>_<tool>`` and the agent
|
|
@@ -384,7 +385,7 @@ class Copilot(IDE):
|
|
|
384
385
|
display_name: ClassVar[str] = 'GitHub Copilot'
|
|
385
386
|
hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS)
|
|
386
387
|
|
|
387
|
-
def settings_path(self, scope: str, repo_path:
|
|
388
|
+
def settings_path(self, scope: str, repo_path: Path | None = None) -> Path:
|
|
388
389
|
# Dedicated Cycode-owned file (Copilot reads every *.json in the hooks
|
|
389
390
|
# dir), unlike the shared settings files of other IDEs.
|
|
390
391
|
if scope == 'repo' and repo_path:
|
|
@@ -418,7 +419,7 @@ class Copilot(IDE):
|
|
|
418
419
|
read_path = _read_file_path(tool_name, tool_input)
|
|
419
420
|
|
|
420
421
|
if hook_event_name == 'UserPromptSubmit':
|
|
421
|
-
canonical_event:
|
|
422
|
+
canonical_event: AiHookEventType | str = AiHookEventType.PROMPT
|
|
422
423
|
elif hook_event_name == 'PreToolUse' and read_path is not None:
|
|
423
424
|
canonical_event = AiHookEventType.FILE_READ
|
|
424
425
|
elif hook_event_name == 'PreToolUse' and is_mcp_tool_name(tool_name):
|
|
@@ -485,14 +486,11 @@ class Copilot(IDE):
|
|
|
485
486
|
source=raw_payload.get('source'),
|
|
486
487
|
)
|
|
487
488
|
|
|
488
|
-
def get_session_context(self) -> tuple[
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
build_global_config_file(_vscode_mcp_config_path(), config.get('servers')) if config else None
|
|
494
|
-
)
|
|
495
|
-
return global_config_file, _collect_installed_plugins()
|
|
489
|
+
def get_session_context(self) -> tuple[list[dict], dict]:
|
|
490
|
+
global_config_files = [
|
|
491
|
+
file for path, servers in _global_mcp_server_maps() for file in build_global_config_files(path, servers)
|
|
492
|
+
]
|
|
493
|
+
return global_config_files, _collect_installed_plugins()
|
|
496
494
|
|
|
497
495
|
def get_skills(self) -> list[dict]:
|
|
498
496
|
return walk_skill_dirs(_copilot_skills_dir())
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import platform
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import ClassVar
|
|
6
|
+
from typing import ClassVar
|
|
7
7
|
|
|
8
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._plugin_utils import
|
|
9
|
+
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import build_global_config_files
|
|
10
10
|
from cycode.cli.apps.ai_guardrails.ides._skill_utils import walk_skill_dirs
|
|
11
11
|
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
|
|
12
12
|
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
|
|
@@ -51,7 +51,7 @@ def _cursor_skills_dir() -> Path:
|
|
|
51
51
|
return Path.home() / '.cursor' / 'skills'
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
def _load_cursor_mcp_config(config_path:
|
|
54
|
+
def _load_cursor_mcp_config(config_path: Path | None = None) -> dict | None:
|
|
55
55
|
"""Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid."""
|
|
56
56
|
path = config_path or _cursor_mcp_config_path()
|
|
57
57
|
if not path.exists():
|
|
@@ -64,12 +64,50 @@ def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict
|
|
|
64
64
|
return None
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
def _cursor_mcp_config_paths(workspace_roots: object) -> list[Path]:
|
|
68
|
+
roots = workspace_roots if isinstance(workspace_roots, list) else []
|
|
69
|
+
paths = [Path(root) / _REPO_SUBDIR / _MCP_CONFIG_FILENAME for root in roots if isinstance(root, str) and root]
|
|
70
|
+
paths.append(_cursor_mcp_config_path())
|
|
71
|
+
return paths
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _server_command_line(server: dict) -> str | None:
|
|
75
|
+
command = server.get('command')
|
|
76
|
+
if not isinstance(command, str) or not command:
|
|
77
|
+
return None
|
|
78
|
+
args = server.get('args')
|
|
79
|
+
return ' '.join([command, *(str(arg) for arg in args)]) if isinstance(args, list) else command
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_mcp_server_name(raw_payload: dict) -> str | None:
|
|
83
|
+
"""Cursor sends the server's url or command, but the platform knows it by its mcp.json entry name."""
|
|
84
|
+
url = raw_payload.get('url')
|
|
85
|
+
command = raw_payload.get('command')
|
|
86
|
+
if not url and not command:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
lowered_command = command.lower() if command else None
|
|
90
|
+
for config_path in _cursor_mcp_config_paths(raw_payload.get('workspace_roots')):
|
|
91
|
+
servers = (_load_cursor_mcp_config(config_path) or {}).get('mcpServers')
|
|
92
|
+
if not isinstance(servers, dict):
|
|
93
|
+
continue
|
|
94
|
+
for name, server in servers.items():
|
|
95
|
+
if not isinstance(server, dict):
|
|
96
|
+
continue
|
|
97
|
+
if url and server.get('url') == url:
|
|
98
|
+
return name
|
|
99
|
+
if command and (command == _server_command_line(server) or lowered_command == name.lower()):
|
|
100
|
+
return name
|
|
101
|
+
|
|
102
|
+
return command
|
|
103
|
+
|
|
104
|
+
|
|
67
105
|
class Cursor(IDE):
|
|
68
106
|
name: ClassVar[str] = 'cursor'
|
|
69
107
|
display_name: ClassVar[str] = 'Cursor'
|
|
70
108
|
hook_events: ClassVar[list[str]] = list(_CURSOR_EVENT_MAPPING)
|
|
71
109
|
|
|
72
|
-
def settings_path(self, scope: str, repo_path:
|
|
110
|
+
def settings_path(self, scope: str, repo_path: Path | None = None) -> Path:
|
|
73
111
|
if scope == 'repo' and repo_path:
|
|
74
112
|
return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME
|
|
75
113
|
return _user_hooks_dir() / _HOOKS_FILE_NAME
|
|
@@ -95,7 +133,9 @@ class Cursor(IDE):
|
|
|
95
133
|
ide_version=raw_payload.get('cursor_version'),
|
|
96
134
|
prompt=raw_payload.get('prompt', ''),
|
|
97
135
|
file_path=raw_payload.get('file_path') or raw_payload.get('path'),
|
|
98
|
-
mcp_server_name=
|
|
136
|
+
mcp_server_name=(
|
|
137
|
+
_resolve_mcp_server_name(raw_payload) if canonical_event == AiHookEventType.MCP_EXECUTION else None
|
|
138
|
+
),
|
|
99
139
|
mcp_tool_name=raw_payload.get('tool_name') or raw_payload.get('tool'),
|
|
100
140
|
mcp_arguments=(raw_payload.get('arguments') or raw_payload.get('tool_input') or raw_payload.get('input')),
|
|
101
141
|
)
|
|
@@ -124,13 +164,11 @@ class Cursor(IDE):
|
|
|
124
164
|
ide_version=raw_payload.get('cursor_version'),
|
|
125
165
|
)
|
|
126
166
|
|
|
127
|
-
def get_session_context(self) -> tuple[
|
|
167
|
+
def get_session_context(self) -> tuple[list[dict], dict]:
|
|
128
168
|
config = _load_cursor_mcp_config()
|
|
129
169
|
if not config:
|
|
130
|
-
return
|
|
131
|
-
|
|
132
|
-
global_config_file = build_global_config_file(config_path, config.get('mcpServers'))
|
|
133
|
-
return global_config_file, {}
|
|
170
|
+
return [], {}
|
|
171
|
+
return build_global_config_files(_cursor_mcp_config_path(), config.get('mcpServers')), {}
|
|
134
172
|
|
|
135
173
|
def get_skills(self) -> list[dict]:
|
|
136
174
|
return walk_skill_dirs(_cursor_skills_dir())
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Install command for AI guardrails hooks."""
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import Annotated
|
|
4
|
+
from typing import Annotated
|
|
5
5
|
|
|
6
6
|
import typer
|
|
7
7
|
|
|
@@ -29,7 +29,7 @@ def install_command(
|
|
|
29
29
|
),
|
|
30
30
|
] = DEFAULT_IDE_NAME,
|
|
31
31
|
repo_path: Annotated[
|
|
32
|
-
|
|
32
|
+
Path | None,
|
|
33
33
|
typer.Option(
|
|
34
34
|
'--repo-path',
|
|
35
35
|
help='Repository path for repo-scoped installation (defaults to current directory).',
|
|
@@ -93,7 +93,7 @@ def install_command(
|
|
|
93
93
|
raise typer.Exit(1)
|
|
94
94
|
|
|
95
95
|
|
|
96
|
-
def _install_policy(scope: str, repo_path:
|
|
96
|
+
def _install_policy(scope: str, repo_path: Path | None) -> None:
|
|
97
97
|
policy_success, policy_message = create_policy_file(scope, repo_path)
|
|
98
98
|
if policy_success:
|
|
99
99
|
console.print(f'[green]✓[/] {policy_message}')
|
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
session-start fetches the tenant's resolved guardrail config from the platform and writes it
|
|
4
4
|
here; scans only read. Per-agent modes and sensitive-path globs are platform-owned - local
|
|
5
5
|
policy files never carry them. An absent or corrupt cache means built-in defaults (Report
|
|
6
|
-
everywhere + the default globs), always synchronous.
|
|
6
|
+
everywhere + the default globs, with the unauthorized MCP server guardrail Off), always synchronous.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
10
|
import time
|
|
11
11
|
from dataclasses import dataclass, field
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
from typing import Optional
|
|
14
13
|
|
|
15
14
|
from cycode.cli.apps.ai_guardrails.consts import GuardrailCellMode, PolicyMode
|
|
16
15
|
from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_SENSITIVE_PATH_GLOBS
|
|
@@ -23,7 +22,7 @@ logger = get_logger('AI Guardrails')
|
|
|
23
22
|
|
|
24
23
|
GUARDRAILS_CONFIG_FILE_NAME = 'ai-guardrails-config.json'
|
|
25
24
|
|
|
26
|
-
|
|
25
|
+
DEFAULT_TTL_SECONDS = 900
|
|
27
26
|
|
|
28
27
|
# Guardrail keys are the CLI's block-reason vocabulary. Anything else in the payload (a future
|
|
29
28
|
# guardrail this CLI doesn't implement) is ignored - unknown config must never fail closed.
|
|
@@ -34,14 +33,24 @@ _KNOWN_GUARDRAIL_KEYS = frozenset(
|
|
|
34
33
|
BlockReason.SECRETS_IN_FILE,
|
|
35
34
|
BlockReason.SENSITIVE_PATH,
|
|
36
35
|
BlockReason.SECRETS_IN_MCP_ARGS,
|
|
36
|
+
BlockReason.UNAUTHORIZED_MCP_SERVER,
|
|
37
37
|
)
|
|
38
38
|
)
|
|
39
39
|
|
|
40
|
+
# Off by default (not Report), so they never start enforcing on a tenant that hasn't configured them.
|
|
41
|
+
_DEFAULT_OFF_GUARDRAIL_KEYS = frozenset((BlockReason.UNAUTHORIZED_MCP_SERVER.value,))
|
|
42
|
+
|
|
40
43
|
|
|
41
44
|
def get_config_cache_path() -> Path:
|
|
42
45
|
return Path.home() / CYCODE_CONFIGURATION_DIRECTORY / GUARDRAILS_CONFIG_FILE_NAME
|
|
43
46
|
|
|
44
47
|
|
|
48
|
+
def default_mode_for(guardrail_key: str) -> str:
|
|
49
|
+
if guardrail_key in _DEFAULT_OFF_GUARDRAIL_KEYS:
|
|
50
|
+
return GuardrailCellMode.OFF.value
|
|
51
|
+
return GuardrailCellMode.REPORT.value
|
|
52
|
+
|
|
53
|
+
|
|
45
54
|
def _default_sensitive_globs() -> list:
|
|
46
55
|
return list(DEFAULT_SENSITIVE_PATH_GLOBS)
|
|
47
56
|
|
|
@@ -50,7 +59,7 @@ def _default_sensitive_globs() -> list:
|
|
|
50
59
|
class GuardrailConfig:
|
|
51
60
|
payload: dict
|
|
52
61
|
fetched_at: float
|
|
53
|
-
tenant_id:
|
|
62
|
+
tenant_id: str | None = None
|
|
54
63
|
_guardrails: dict = field(init=False, repr=False)
|
|
55
64
|
|
|
56
65
|
def __post_init__(self) -> None:
|
|
@@ -60,24 +69,34 @@ class GuardrailConfig:
|
|
|
60
69
|
if guardrail.get('key') in _KNOWN_GUARDRAIL_KEYS
|
|
61
70
|
}
|
|
62
71
|
|
|
63
|
-
|
|
72
|
+
@property
|
|
73
|
+
def ttl_seconds(self) -> float:
|
|
74
|
+
return self.payload.get('ttl_seconds') or DEFAULT_TTL_SECONDS
|
|
75
|
+
|
|
76
|
+
def _agents(self, guardrail_key: str) -> dict:
|
|
77
|
+
return (self._guardrails.get(guardrail_key) or {}).get('agents') or {}
|
|
78
|
+
|
|
79
|
+
def mode_for(self, guardrail_key: str, ide_name: str | None) -> str:
|
|
64
80
|
"""The platform keys the cells by our --ide names, so the lookup is direct."""
|
|
65
|
-
agents =
|
|
66
|
-
return str(agents.get((ide_name or '').lower(),
|
|
81
|
+
agents = self._agents(guardrail_key)
|
|
82
|
+
return str(agents.get((ide_name or '').lower(), default_mode_for(guardrail_key))).lower()
|
|
83
|
+
|
|
84
|
+
def is_off_for_every_agent(self, guardrail_key: str) -> bool:
|
|
85
|
+
return all(str(mode).lower() == GuardrailCellMode.OFF for mode in self._agents(guardrail_key).values())
|
|
67
86
|
|
|
68
|
-
def _modes_for_event(self, event_name: str, ide_name:
|
|
87
|
+
def _modes_for_event(self, event_name: str, ide_name: str | None) -> list:
|
|
69
88
|
return [
|
|
70
89
|
self.mode_for(key, ide_name)
|
|
71
90
|
for key, guardrail in self._guardrails.items()
|
|
72
91
|
if str(guardrail.get('event_type', '')).lower() == str(event_name).lower()
|
|
73
92
|
]
|
|
74
93
|
|
|
75
|
-
def is_event_off(self, event_name: str, ide_name:
|
|
94
|
+
def is_event_off(self, event_name: str, ide_name: str | None) -> bool:
|
|
76
95
|
"""Every guardrail for this event is Off - skip the scan entirely."""
|
|
77
96
|
modes = self._modes_for_event(event_name, ide_name)
|
|
78
97
|
return bool(modes) and all(mode == GuardrailCellMode.OFF for mode in modes)
|
|
79
98
|
|
|
80
|
-
def can_event_block(self, event_name: str, ide_name:
|
|
99
|
+
def can_event_block(self, event_name: str, ide_name: str | None) -> bool:
|
|
81
100
|
"""At least one guardrail for this event is in Block mode - the scan must stay synchronous."""
|
|
82
101
|
return GuardrailCellMode.BLOCK in self._modes_for_event(event_name, ide_name)
|
|
83
102
|
|
|
@@ -87,26 +106,25 @@ class GuardrailConfig:
|
|
|
87
106
|
return globs if isinstance(globs, list) and globs else _default_sensitive_globs()
|
|
88
107
|
|
|
89
108
|
def is_expired(self) -> bool:
|
|
90
|
-
|
|
91
|
-
return time.time() - self.fetched_at > ttl
|
|
109
|
+
return time.time() - self.fetched_at > self.ttl_seconds
|
|
92
110
|
|
|
93
|
-
def needs_refresh(self, tenant_id:
|
|
111
|
+
def needs_refresh(self, tenant_id: str | None) -> bool:
|
|
94
112
|
"""Expired, or fetched for another tenant (the user switched tenants since)."""
|
|
95
113
|
return self.is_expired() or self.tenant_id != tenant_id
|
|
96
114
|
|
|
97
115
|
|
|
98
|
-
def apply_platform_config(policy: dict, config:
|
|
116
|
+
def apply_platform_config(policy: dict, config: GuardrailConfig | None, ide_name: str | None) -> None:
|
|
99
117
|
"""Overlay the platform-owned enforcement config onto the local knobs-only policy.
|
|
100
118
|
|
|
101
119
|
The platform is the only mode source: no cache (cold start) means the built-in defaults -
|
|
102
|
-
Report everywhere with the default globs - which equal an unconfigured
|
|
103
|
-
config, so behaviour is uniform either way. Each matrix cell lands on its own
|
|
104
|
-
action, so
|
|
120
|
+
Report everywhere (unauthorized MCP server Off) with the default globs - which equal an unconfigured
|
|
121
|
+
tenant's platform config, so behaviour is uniform either way. Each matrix cell lands on its own
|
|
122
|
+
per-feature action, so guardrails sharing an event (e.g. the two FileRead ones) keep independent modes.
|
|
105
123
|
An all-Off event never reaches here at all: scan_command skips it.
|
|
106
124
|
"""
|
|
107
125
|
|
|
108
126
|
def cell(guardrail_key: str) -> str:
|
|
109
|
-
return config.mode_for(guardrail_key, ide_name) if config is not None else
|
|
127
|
+
return config.mode_for(guardrail_key, ide_name) if config is not None else default_mode_for(guardrail_key)
|
|
110
128
|
|
|
111
129
|
def action(guardrail_key: str) -> str:
|
|
112
130
|
return PolicyMode.BLOCK.value if cell(guardrail_key) == GuardrailCellMode.BLOCK else PolicyMode.WARN.value
|
|
@@ -123,10 +141,14 @@ def apply_platform_config(policy: dict, config: Optional[GuardrailConfig], ide_n
|
|
|
123
141
|
)
|
|
124
142
|
file_read['path_action'] = action(BlockReason.SENSITIVE_PATH)
|
|
125
143
|
|
|
126
|
-
policy.setdefault('mcp', {})
|
|
144
|
+
mcp = policy.setdefault('mcp', {})
|
|
145
|
+
mcp['scan_args'] = cell(BlockReason.SECRETS_IN_MCP_ARGS) != GuardrailCellMode.OFF
|
|
146
|
+
mcp['action'] = action(BlockReason.SECRETS_IN_MCP_ARGS)
|
|
147
|
+
mcp['check_server'] = cell(BlockReason.UNAUTHORIZED_MCP_SERVER) != GuardrailCellMode.OFF
|
|
148
|
+
mcp['server_action'] = action(BlockReason.UNAUTHORIZED_MCP_SERVER)
|
|
127
149
|
|
|
128
150
|
|
|
129
|
-
def save_guardrail_config(payload: dict, tenant_id:
|
|
151
|
+
def save_guardrail_config(payload: dict, tenant_id: str | None) -> None:
|
|
130
152
|
"""Persist a fetched resolved config; a failed write just leaves the previous cache in place."""
|
|
131
153
|
path = get_config_cache_path()
|
|
132
154
|
content = {'fetched_at': time.time(), 'tenant_id': tenant_id, 'payload': payload}
|
|
@@ -137,7 +159,7 @@ def save_guardrail_config(payload: dict, tenant_id: Optional[str]) -> None:
|
|
|
137
159
|
logger.debug('Failed to save guardrail config cache', exc_info=e)
|
|
138
160
|
|
|
139
161
|
|
|
140
|
-
def load_guardrail_config() ->
|
|
162
|
+
def load_guardrail_config() -> GuardrailConfig | None:
|
|
141
163
|
"""The cached platform config, or None when it is absent or corrupt (quarantined)."""
|
|
142
164
|
path = get_config_cache_path()
|
|
143
165
|
if not path.exists():
|
|
@@ -148,7 +170,7 @@ def load_guardrail_config() -> Optional[GuardrailConfig]:
|
|
|
148
170
|
content = json.load(file)
|
|
149
171
|
payload = content['payload']
|
|
150
172
|
if not isinstance(payload, dict):
|
|
151
|
-
raise
|
|
173
|
+
raise TypeError('payload is not an object')
|
|
152
174
|
return GuardrailConfig(
|
|
153
175
|
payload=payload, fetched_at=float(content['fetched_at']), tenant_id=content.get('tenant_id')
|
|
154
176
|
)
|