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.
Files changed (125) hide show
  1. cycode/__init__.py +1 -1
  2. cycode/cli/app.py +9 -10
  3. cycode/cli/apps/activation_manager.py +6 -6
  4. cycode/cli/apps/ai_guardrails/command_utils.py +1 -2
  5. cycode/cli/apps/ai_guardrails/hooks_manager.py +6 -7
  6. cycode/cli/apps/ai_guardrails/ides/__init__.py +8 -9
  7. cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +11 -10
  8. cycode/cli/apps/ai_guardrails/ides/_skill_utils.py +1 -2
  9. cycode/cli/apps/ai_guardrails/ides/base.py +19 -21
  10. cycode/cli/apps/ai_guardrails/ides/claude_code.py +54 -16
  11. cycode/cli/apps/ai_guardrails/ides/codex.py +54 -20
  12. cycode/cli/apps/ai_guardrails/ides/copilot.py +23 -25
  13. cycode/cli/apps/ai_guardrails/ides/cursor.py +48 -10
  14. cycode/cli/apps/ai_guardrails/install_command.py +3 -3
  15. cycode/cli/apps/ai_guardrails/scan/guardrail_config.py +44 -22
  16. cycode/cli/apps/ai_guardrails/scan/handlers.py +79 -14
  17. cycode/cli/apps/ai_guardrails/scan/mcp_server_status.py +103 -0
  18. cycode/cli/apps/ai_guardrails/scan/payload.py +13 -14
  19. cycode/cli/apps/ai_guardrails/scan/policy.py +3 -3
  20. cycode/cli/apps/ai_guardrails/scan/scan_command.py +4 -4
  21. cycode/cli/apps/ai_guardrails/scan/types.py +1 -0
  22. cycode/cli/apps/ai_guardrails/session_start_command.py +46 -21
  23. cycode/cli/apps/ai_guardrails/status_command.py +2 -2
  24. cycode/cli/apps/ai_guardrails/uninstall_command.py +2 -2
  25. cycode/cli/apps/api/__init__.py +3 -3
  26. cycode/cli/apps/api/api_command.py +6 -6
  27. cycode/cli/apps/api/openapi_spec.py +4 -5
  28. cycode/cli/apps/auth/auth_common.py +4 -4
  29. cycode/cli/apps/configure/configure_command.py +2 -4
  30. cycode/cli/apps/configure/messages.py +2 -4
  31. cycode/cli/apps/configure/prompts.py +5 -7
  32. cycode/cli/apps/ignore/ignore_command.py +7 -7
  33. cycode/cli/apps/mcp/mcp_command.py +11 -11
  34. cycode/cli/apps/report/sbom/common.py +5 -5
  35. cycode/cli/apps/report/sbom/sbom_command.py +2 -2
  36. cycode/cli/apps/report/sbom/sbom_report_file.py +1 -2
  37. cycode/cli/apps/report_import/sbom/sbom_command.py +3 -3
  38. cycode/cli/apps/sca_options.py +3 -3
  39. cycode/cli/apps/scan/aggregation_report.py +4 -4
  40. cycode/cli/apps/scan/code_scanner.py +8 -7
  41. cycode/cli/apps/scan/commit_range_scanner.py +10 -10
  42. cycode/cli/apps/scan/detection_excluder.py +2 -4
  43. cycode/cli/apps/scan/pre_commit/pre_commit_command.py +3 -3
  44. cycode/cli/apps/scan/pre_push/pre_push_command.py +3 -3
  45. cycode/cli/apps/scan/pre_receive/pre_receive_command.py +2 -2
  46. cycode/cli/apps/scan/remote_url_resolver.py +6 -8
  47. cycode/cli/apps/scan/repository/repository_command.py +2 -2
  48. cycode/cli/apps/scan/scan_command.py +2 -2
  49. cycode/cli/apps/scan/scan_parameters.py +1 -3
  50. cycode/cli/apps/scan/scan_result.py +5 -5
  51. cycode/cli/console.py +2 -2
  52. cycode/cli/exceptions/custom_exceptions.py +1 -1
  53. cycode/cli/exceptions/handle_errors.py +1 -3
  54. cycode/cli/exceptions/handle_scan_errors.py +1 -2
  55. cycode/cli/files_collector/commit_range_documents.py +14 -14
  56. cycode/cli/files_collector/models/in_memory_zip.py +3 -3
  57. cycode/cli/files_collector/repository_documents.py +3 -3
  58. cycode/cli/files_collector/sca/base_restore_dependencies.py +7 -8
  59. cycode/cli/files_collector/sca/go/restore_go_dependencies.py +1 -2
  60. cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +2 -3
  61. cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +3 -4
  62. cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py +3 -4
  63. cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py +1 -2
  64. cycode/cli/files_collector/sca/npm/restore_pnpm_dependencies.py +2 -3
  65. cycode/cli/files_collector/sca/npm/restore_yarn_dependencies.py +2 -3
  66. cycode/cli/files_collector/sca/npm/workspace/coverage.py +5 -5
  67. cycode/cli/files_collector/sca/npm/workspace/files.py +2 -3
  68. cycode/cli/files_collector/sca/npm/workspace/globs.py +1 -2
  69. cycode/cli/files_collector/sca/npm/workspace/resolvers.py +14 -16
  70. cycode/cli/files_collector/sca/php/restore_composer_dependencies.py +1 -2
  71. cycode/cli/files_collector/sca/python/restore_pip_dependencies.py +2 -3
  72. cycode/cli/files_collector/sca/python/restore_pipenv_dependencies.py +1 -2
  73. cycode/cli/files_collector/sca/python/restore_poetry_dependencies.py +2 -3
  74. cycode/cli/files_collector/sca/python/restore_uv_dependencies.py +2 -3
  75. cycode/cli/files_collector/sca/sca_file_collector.py +5 -5
  76. cycode/cli/files_collector/zip_documents.py +1 -2
  77. cycode/cli/models.py +8 -8
  78. cycode/cli/printers/console_printer.py +9 -9
  79. cycode/cli/printers/json_printer.py +2 -2
  80. cycode/cli/printers/printer_base.py +3 -3
  81. cycode/cli/printers/rich_printer.py +2 -2
  82. cycode/cli/printers/tables/table.py +4 -4
  83. cycode/cli/printers/tables/table_models.py +2 -2
  84. cycode/cli/printers/tables/table_printer_base.py +2 -2
  85. cycode/cli/printers/text_printer.py +4 -4
  86. cycode/cli/printers/utils/code_snippet_syntax.py +1 -1
  87. cycode/cli/printers/utils/detection_data.py +3 -3
  88. cycode/cli/printers/utils/sca_ossf.py +4 -4
  89. cycode/cli/printers/utils/sca_policy_details.py +2 -1
  90. cycode/cli/user_settings/config_file_manager.py +10 -10
  91. cycode/cli/user_settings/configuration_manager.py +6 -6
  92. cycode/cli/user_settings/credentials_manager.py +5 -6
  93. cycode/cli/utils/get_api_client.py +5 -5
  94. cycode/cli/utils/git_proxy.py +8 -8
  95. cycode/cli/utils/host_info.py +21 -38
  96. cycode/cli/utils/ignore_utils.py +13 -15
  97. cycode/cli/utils/jwt_utils.py +2 -4
  98. cycode/cli/utils/path_utils.py +6 -6
  99. cycode/cli/utils/progress_bar.py +4 -4
  100. cycode/cli/utils/scan_batch.py +2 -1
  101. cycode/cli/utils/scan_utils.py +2 -2
  102. cycode/cli/utils/shell_executor.py +3 -4
  103. cycode/cli/utils/task_timer.py +4 -4
  104. cycode/cli/utils/trust_store.py +0 -17
  105. cycode/cli/utils/url_utils.py +1 -2
  106. cycode/cli/utils/version_checker.py +4 -5
  107. cycode/cli/utils/yaml_utils.py +1 -1
  108. cycode/config.py +1 -2
  109. cycode/cyclient/ai_security_manager_client.py +27 -21
  110. cycode/cyclient/auth_client.py +2 -4
  111. cycode/cyclient/base_token_auth_client.py +2 -2
  112. cycode/cyclient/client_creator.py +4 -6
  113. cycode/cyclient/cycode_client_base.py +10 -9
  114. cycode/cyclient/cycode_dev_based_client.py +1 -3
  115. cycode/cyclient/headers.py +1 -2
  116. cycode/cyclient/import_sbom_client.py +3 -4
  117. cycode/cyclient/models.py +88 -30
  118. cycode/cyclient/report_client.py +1 -2
  119. cycode/cyclient/scan_client.py +8 -7
  120. cycode/logger.py +4 -4
  121. {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/METADATA +12 -19
  122. {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/RECORD +125 -124
  123. {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/WHEEL +1 -1
  124. {cycode-3.25.1.dev6.dist-info → cycode-3.25.2.dev2.dist-info}/entry_points.txt +0 -0
  125. {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, Optional
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
- build_global_config_file,
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: Optional[Path] = None) -> 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: Optional[Path] = None) -> Optional[dict]:
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: Optional[Path] = None) -> Optional[str]:
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) -> Optional[Path]:
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 _enable_codex_hooks_feature(scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
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: Optional[Path] = None) -> 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: Optional[Path] = None) -> tuple[bool, str]:
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 tool_name.startswith('mcp__'):
251
- parts = tool_name.split('__')
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) -> Optional[str]:
336
+ def get_user_email(self) -> str | None:
303
337
  return _email_from_auth()
304
338
 
305
- def get_session_context(self) -> tuple[Optional[dict], dict]:
339
+ def get_session_context(self) -> tuple[list[dict], dict]:
306
340
  config = _load_codex_config()
307
341
  if not config:
308
- return None, {}
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
- global_config_file = build_global_config_file(config_path, config.get('mcp_servers'))
347
+ global_config_files = build_global_config_files(config_path, config.get('mcp_servers'))
314
348
  enriched_plugins = _resolve_codex_plugins(config)
315
- return global_config_file, enriched_plugins
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, Optional, Union
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
- build_global_config_file,
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: Optional[Path] = None) -> Optional[dict]:
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) -> Optional[dict]:
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
- config = _load_vscode_mcp_config()
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) -> Optional[str]:
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[Optional[str], Optional[str]]:
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: Optional[Path] = None) -> 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: Union[AiHookEventType, str] = AiHookEventType.PROMPT
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[Optional[dict], dict]:
489
- # VS Code's mcp.json uses `servers` as its top-level key; normalized to the
490
- # canonical mcpServers shape by build_global_config_file.
491
- config = _load_vscode_mcp_config()
492
- global_config_file = (
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, Optional
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 build_global_config_file
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: Optional[Path] = None) -> Optional[dict]:
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: Optional[Path] = None) -> 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=raw_payload.get('command'),
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[Optional[dict], dict]:
167
+ def get_session_context(self) -> tuple[list[dict], dict]:
128
168
  config = _load_cursor_mcp_config()
129
169
  if not config:
130
- return None, {}
131
- config_path = _cursor_mcp_config_path()
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, Optional
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
- Optional[Path],
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: Optional[Path]) -> None:
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
- _DEFAULT_TTL_SECONDS = 900
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: Optional[str] = None
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
- def mode_for(self, guardrail_key: str, ide_name: Optional[str]) -> str:
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 = (self._guardrails.get(guardrail_key) or {}).get('agents') or {}
66
- return str(agents.get((ide_name or '').lower(), GuardrailCellMode.REPORT.value)).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: Optional[str]) -> list:
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: Optional[str]) -> bool:
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: Optional[str]) -> bool:
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
- ttl = self.payload.get('ttl_seconds') or _DEFAULT_TTL_SECONDS
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: Optional[str]) -> bool:
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: Optional[GuardrailConfig], ide_name: Optional[str]) -> None:
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 tenant's platform
103
- config, so behaviour is uniform either way. Each matrix cell lands on its own per-feature
104
- action, so the two FileRead guardrails (content scan vs. sensitive path) keep independent modes.
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 GuardrailCellMode.REPORT.value
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', {})['action'] = action(BlockReason.SECRETS_IN_MCP_ARGS)
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: Optional[str]) -> None:
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() -> Optional[GuardrailConfig]:
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 ValueError('payload is not an object')
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
  )