cycode 3.25.1.dev5__py3-none-any.whl → 3.25.2.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cycode/__init__.py CHANGED
@@ -5,4 +5,4 @@ import time as _time
5
5
  # end-to-end scan duration from the moment the user actually triggered it.
6
6
  _BOOT_WALL: float = _time.time()
7
7
 
8
- __version__ = '3.25.1.dev5' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag
8
+ __version__ = '3.25.2.dev1' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag
@@ -35,23 +35,22 @@ def get_ide(name: str) -> IDE:
35
35
  return ide
36
36
 
37
37
 
38
- def collect_all_session_contexts() -> tuple[dict[str, dict], dict]:
38
+ def collect_all_session_contexts() -> tuple[list[dict], dict]:
39
39
  """Sweep every registered IDE's session context, regardless of which IDE triggered the hook.
40
40
 
41
- Returns ``(config_files_by_ide, plugins)``: the global MCP config file of each IDE that has
42
- one (keyed by IDE name), and the enabled plugins merged across IDEs (first registered IDE
43
- wins on a duplicate plugin key - plugins are IDE-agnostic marketplace artifacts).
41
+ Returns ``(config_files, plugins)``: the global MCP config files of every IDE, and the
42
+ enabled plugins merged across IDEs (first registered IDE wins on a duplicate plugin key -
43
+ plugins are IDE-agnostic marketplace artifacts).
44
44
  """
45
- config_files_by_ide: dict[str, dict] = {}
45
+ config_files: list[dict] = []
46
46
  plugins: dict = {}
47
47
  for ide in IDES.values():
48
- global_config_file, enabled_plugins = ide.get_session_context()
49
- if global_config_file:
50
- config_files_by_ide[ide.name] = global_config_file
48
+ global_config_files, enabled_plugins = ide.get_session_context()
49
+ config_files.extend(global_config_files)
51
50
  for plugin_key, plugin in (enabled_plugins or {}).items():
52
51
  plugins.setdefault(plugin_key, plugin)
53
52
 
54
- return config_files_by_ide, plugins
53
+ return config_files, plugins
55
54
 
56
55
 
57
56
  def collect_all_skills() -> list[dict]:
@@ -42,17 +42,17 @@ def load_plugin_json(path: Path) -> Optional[dict]:
42
42
  return None
43
43
 
44
44
 
45
- def build_global_config_file(path: Path, mcp_servers: Optional[dict]) -> Optional[dict]:
45
+ def build_global_config_files(path: Path, mcp_servers: Optional[dict]) -> list[dict]:
46
46
  """Wrap a global (non-plugin) MCP config into the session-context file shape.
47
47
 
48
- Returns ``{"path": <full path>, "content": <{"mcpServers": ...} JSON>}`` when
49
- there are servers, else ``None``. ``content`` is normalized to the canonical
48
+ Returns ``[{"path": <full path>, "content": <{"mcpServers": ...} JSON>}]`` when
49
+ there are servers, else ``[]``. ``content`` is normalized to the canonical
50
50
  ``{"mcpServers": {...}}`` shape, dropping everything else in the source file.
51
51
  """
52
- servers = mcp_servers or {}
52
+ servers = mcp_servers if isinstance(mcp_servers, dict) else {}
53
53
  if not servers:
54
- return None
55
- return {'path': str(path), 'content': json.dumps({'mcpServers': servers})}
54
+ return []
55
+ return [{'path': str(path), 'content': json.dumps({'mcpServers': servers})}]
56
56
 
57
57
 
58
58
  def walk_enabled_plugins(
@@ -176,19 +176,19 @@ class IDE(ABC):
176
176
  """
177
177
  return None
178
178
 
179
- def get_session_context(self) -> tuple[Optional[dict], dict]:
180
- """Return ``(global_config_file, enabled_plugins)`` for session-context reporting.
179
+ def get_session_context(self) -> tuple[list[dict], dict]:
180
+ """Return ``(global_config_files, enabled_plugins)`` for session-context reporting.
181
181
 
182
- ``global_config_file`` is the IDE's global (non-plugin) MCP config as
183
- ``{"path": <full path>, "content": <normalized {"mcpServers": ...} JSON>}``,
184
- or ``None`` when there is no global MCP config. ``enabled_plugins`` maps each
185
- enabled plugin key to its metadata (including its own ``mcp_config_file``
186
- content and ``mcp_config_file_path``).
182
+ ``global_config_files`` are the IDE's global (non-plugin) MCP configs, each as
183
+ ``{"path": <full path>, "content": <normalized {"mcpServers": ...} JSON>}``;
184
+ empty when there is none. ``enabled_plugins`` maps each enabled plugin key to
185
+ its metadata (including its own ``mcp_config_file`` content and
186
+ ``mcp_config_file_path``).
187
187
 
188
- Default: ``(None, {})`` (no plugin system, no discoverable MCP config).
188
+ Default: ``([], {})`` (no plugin system, no discoverable MCP config).
189
189
  Override to surface MCP/plugin inventory.
190
190
  """
191
- return None, {}
191
+ return [], {}
192
192
 
193
193
  def get_skills(self) -> list[dict]:
194
194
  """Return the IDE's user-scope skills as ``[{"path", "content"}]``.
@@ -198,7 +198,7 @@ class IDE(ABC):
198
198
  read these files off endpoints can only ever return raw content.
199
199
 
200
200
  Kept separate from ``get_session_context`` rather than folded into its
201
- ``global_config_file`` slot, which is normalized to an MCP server map.
201
+ ``global_config_files``, which are normalized to an MCP server map.
202
202
 
203
203
  Default: ``[]`` (the IDE has no skill system). Override to surface skills.
204
204
  """
@@ -1,6 +1,7 @@
1
1
  """Claude Code IDE integration for AI guardrails."""
2
2
 
3
3
  import json
4
+ import re
4
5
  from collections.abc import Iterator
5
6
  from copy import deepcopy
6
7
  from pathlib import Path
@@ -8,7 +9,7 @@ from typing import ClassVar, Optional
8
9
 
9
10
  from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
10
11
  from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
11
- build_global_config_file,
12
+ build_global_config_files,
12
13
  load_plugin_json,
13
14
  resolve_cached_plugin_dir,
14
15
  walk_enabled_plugins,
@@ -253,6 +254,41 @@ def resolve_plugins(settings: dict) -> dict:
253
254
  )
254
255
 
255
256
 
257
+ def _sanitize_mcp_name(name: str) -> str:
258
+ """Mirrors how Claude Code sanitizes a server name inside a tool name."""
259
+ return re.sub(r'[^a-z0-9_-]', '_', name.lower())
260
+
261
+
262
+ def _local_mcp_servers(config: Optional[dict], cwd: Optional[str]) -> Iterator[tuple[str, str]]:
263
+ """Yield ``(name as Claude Code namespaces it, config key)`` for each locally configured MCP server."""
264
+ sources = []
265
+ if config:
266
+ sources.append(get_mcp_servers(config))
267
+ if cwd:
268
+ sources.append(((config.get('projects') or {}).get(cwd) or {}).get('mcpServers'))
269
+ if cwd:
270
+ sources.append((load_plugin_json(Path(cwd) / '.mcp.json') or {}).get('mcpServers'))
271
+ for servers in sources:
272
+ if isinstance(servers, dict):
273
+ for name in servers:
274
+ yield name, name
275
+
276
+ settings = load_claude_settings()
277
+ for plugin_key, entry in (resolve_plugins(settings) if settings else {}).items():
278
+ plugin_name = plugin_key.split('@', 1)[0]
279
+ for server in entry.get('mcp_server_names') or []:
280
+ yield f'plugin_{plugin_name}_{server}', server
281
+
282
+
283
+ def _resolve_mcp_server_name(server: str, config: Optional[dict], cwd: Optional[str]) -> str:
284
+ """Map the server segment of ``mcp__<server>__<tool>`` back to its config key; unknown names pass through."""
285
+ wanted = _sanitize_mcp_name(server)
286
+ for namespaced, name in _local_mcp_servers(config, cwd):
287
+ if _sanitize_mcp_name(namespaced) == wanted:
288
+ return name
289
+ return server
290
+
291
+
256
292
  # --- IDE integration ----------------------------------------------------------
257
293
 
258
294
 
@@ -339,6 +375,8 @@ class ClaudeCode(IDE):
339
375
 
340
376
  config = load_claude_config()
341
377
  ide_user_email = _email_from_config(config) if config else None
378
+ if mcp_server_name:
379
+ mcp_server_name = _resolve_mcp_server_name(mcp_server_name, config, raw_payload.get('cwd'))
342
380
 
343
381
  return AIHookPayload(
344
382
  event_name=canonical_event,
@@ -396,14 +434,14 @@ class ClaudeCode(IDE):
396
434
  config = load_claude_config()
397
435
  return _email_from_config(config) if config else None
398
436
 
399
- def get_session_context(self) -> tuple[Optional[dict], dict]:
437
+ def get_session_context(self) -> tuple[list[dict], dict]:
400
438
  config = load_claude_config()
401
- global_config_file = build_global_config_file(_CLAUDE_CONFIG_PATH, get_mcp_servers(config)) if config else None
439
+ global_config_files = build_global_config_files(_CLAUDE_CONFIG_PATH, get_mcp_servers(config)) if config else []
402
440
 
403
441
  settings = load_claude_settings()
404
442
  enriched_plugins = resolve_plugins(settings) if settings else {}
405
443
 
406
- return global_config_file, enriched_plugins
444
+ return global_config_files, enriched_plugins
407
445
 
408
446
  def get_skills(self) -> list[dict]:
409
447
  return walk_skill_dirs(_claude_skills_dir())
@@ -2,7 +2,9 @@
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
9
  from typing import ClassVar, Optional
8
10
 
@@ -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,
@@ -158,6 +160,42 @@ def _resolve_codex_plugins(config: dict) -> dict:
158
160
  )
159
161
 
160
162
 
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[Optional[str], Optional[str]]:
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: Optional[tuple[int, str]] = 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
+
161
199
  def _enable_codex_hooks_feature(scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
162
200
  """Set ``[features] hooks = true`` in Codex's ``config.toml``.
163
201
 
@@ -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(
@@ -302,17 +336,17 @@ class Codex(IDE):
302
336
  def get_user_email(self) -> Optional[str]:
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())
@@ -33,7 +33,7 @@ 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
  )
@@ -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
@@ -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())
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  from typing import ClassVar, Optional
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
@@ -64,6 +64,44 @@ 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) -> Optional[str]:
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) -> Optional[str]:
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'
@@ -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())
@@ -3,7 +3,7 @@
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
@@ -23,7 +23,7 @@ logger = get_logger('AI Guardrails')
23
23
 
24
24
  GUARDRAILS_CONFIG_FILE_NAME = 'ai-guardrails-config.json'
25
25
 
26
- _DEFAULT_TTL_SECONDS = 900
26
+ DEFAULT_TTL_SECONDS = 900
27
27
 
28
28
  # Guardrail keys are the CLI's block-reason vocabulary. Anything else in the payload (a future
29
29
  # guardrail this CLI doesn't implement) is ignored - unknown config must never fail closed.
@@ -34,14 +34,24 @@ _KNOWN_GUARDRAIL_KEYS = frozenset(
34
34
  BlockReason.SECRETS_IN_FILE,
35
35
  BlockReason.SENSITIVE_PATH,
36
36
  BlockReason.SECRETS_IN_MCP_ARGS,
37
+ BlockReason.UNAUTHORIZED_MCP_SERVER,
37
38
  )
38
39
  )
39
40
 
41
+ # Off by default (not Report), so they never start enforcing on a tenant that hasn't configured them.
42
+ _DEFAULT_OFF_GUARDRAIL_KEYS = frozenset((BlockReason.UNAUTHORIZED_MCP_SERVER.value,))
43
+
40
44
 
41
45
  def get_config_cache_path() -> Path:
42
46
  return Path.home() / CYCODE_CONFIGURATION_DIRECTORY / GUARDRAILS_CONFIG_FILE_NAME
43
47
 
44
48
 
49
+ def default_mode_for(guardrail_key: str) -> str:
50
+ if guardrail_key in _DEFAULT_OFF_GUARDRAIL_KEYS:
51
+ return GuardrailCellMode.OFF.value
52
+ return GuardrailCellMode.REPORT.value
53
+
54
+
45
55
  def _default_sensitive_globs() -> list:
46
56
  return list(DEFAULT_SENSITIVE_PATH_GLOBS)
47
57
 
@@ -60,10 +70,20 @@ class GuardrailConfig:
60
70
  if guardrail.get('key') in _KNOWN_GUARDRAIL_KEYS
61
71
  }
62
72
 
73
+ @property
74
+ def ttl_seconds(self) -> float:
75
+ return self.payload.get('ttl_seconds') or DEFAULT_TTL_SECONDS
76
+
77
+ def _agents(self, guardrail_key: str) -> dict:
78
+ return (self._guardrails.get(guardrail_key) or {}).get('agents') or {}
79
+
63
80
  def mode_for(self, guardrail_key: str, ide_name: Optional[str]) -> str:
64
81
  """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()
82
+ agents = self._agents(guardrail_key)
83
+ return str(agents.get((ide_name or '').lower(), default_mode_for(guardrail_key))).lower()
84
+
85
+ def is_off_for_every_agent(self, guardrail_key: str) -> bool:
86
+ return all(str(mode).lower() == GuardrailCellMode.OFF for mode in self._agents(guardrail_key).values())
67
87
 
68
88
  def _modes_for_event(self, event_name: str, ide_name: Optional[str]) -> list:
69
89
  return [
@@ -87,8 +107,7 @@ class GuardrailConfig:
87
107
  return globs if isinstance(globs, list) and globs else _default_sensitive_globs()
88
108
 
89
109
  def is_expired(self) -> bool:
90
- ttl = self.payload.get('ttl_seconds') or _DEFAULT_TTL_SECONDS
91
- return time.time() - self.fetched_at > ttl
110
+ return time.time() - self.fetched_at > self.ttl_seconds
92
111
 
93
112
  def needs_refresh(self, tenant_id: Optional[str]) -> bool:
94
113
  """Expired, or fetched for another tenant (the user switched tenants since)."""
@@ -99,14 +118,14 @@ def apply_platform_config(policy: dict, config: Optional[GuardrailConfig], ide_n
99
118
  """Overlay the platform-owned enforcement config onto the local knobs-only policy.
100
119
 
101
120
  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.
121
+ Report everywhere (unauthorized MCP server Off) with the default globs - which equal an unconfigured
122
+ tenant's platform config, so behaviour is uniform either way. Each matrix cell lands on its own
123
+ per-feature action, so guardrails sharing an event (e.g. the two FileRead ones) keep independent modes.
105
124
  An all-Off event never reaches here at all: scan_command skips it.
106
125
  """
107
126
 
108
127
  def cell(guardrail_key: str) -> str:
109
- return config.mode_for(guardrail_key, ide_name) if config is not None else GuardrailCellMode.REPORT.value
128
+ return config.mode_for(guardrail_key, ide_name) if config is not None else default_mode_for(guardrail_key)
110
129
 
111
130
  def action(guardrail_key: str) -> str:
112
131
  return PolicyMode.BLOCK.value if cell(guardrail_key) == GuardrailCellMode.BLOCK else PolicyMode.WARN.value
@@ -123,7 +142,11 @@ def apply_platform_config(policy: dict, config: Optional[GuardrailConfig], ide_n
123
142
  )
124
143
  file_read['path_action'] = action(BlockReason.SENSITIVE_PATH)
125
144
 
126
- policy.setdefault('mcp', {})['action'] = action(BlockReason.SECRETS_IN_MCP_ARGS)
145
+ mcp = policy.setdefault('mcp', {})
146
+ mcp['scan_args'] = cell(BlockReason.SECRETS_IN_MCP_ARGS) != GuardrailCellMode.OFF
147
+ mcp['action'] = action(BlockReason.SECRETS_IN_MCP_ARGS)
148
+ mcp['check_server'] = cell(BlockReason.UNAUTHORIZED_MCP_SERVER) != GuardrailCellMode.OFF
149
+ mcp['server_action'] = action(BlockReason.UNAUTHORIZED_MCP_SERVER)
127
150
 
128
151
 
129
152
  def save_guardrail_config(payload: dict, tenant_id: Optional[str]) -> None:
@@ -10,10 +10,9 @@ touching any handler in this module.
10
10
 
11
11
  import json
12
12
  import os
13
+ import threading
13
14
  from dataclasses import dataclass
14
- from multiprocessing.pool import ThreadPool
15
- from multiprocessing.pool import TimeoutError as PoolTimeoutError
16
- from typing import TYPE_CHECKING, Callable, NamedTuple, Optional
15
+ from typing import TYPE_CHECKING, Any, Callable, NamedTuple, Optional
17
16
 
18
17
  import typer
19
18
 
@@ -22,6 +21,7 @@ if TYPE_CHECKING:
22
21
 
23
22
  from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode, PolicyMode
24
23
  from cycode.cli.apps.ai_guardrails.ides.base import HookDecision
24
+ from cycode.cli.apps.ai_guardrails.scan.mcp_server_status import is_enforced, load_mcp_server_statuses
25
25
  from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
26
26
  from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value
27
27
  from cycode.cli.apps.ai_guardrails.scan.types import (
@@ -218,6 +218,16 @@ class _ArgScanFeature:
218
218
  deny_agent_message: str
219
219
  ask_message: Callable[[str], str]
220
220
  ask_agent_message: str
221
+ scan_enabled: bool = True
222
+
223
+
224
+ class _PreScanFinding(NamedTuple):
225
+ """A finding on the call itself (e.g. an unauthorized MCP server), made before the text scan."""
226
+
227
+ block_reason: BlockReason
228
+ mode: GuardrailsMode
229
+ deny_message: str
230
+ deny_agent_message: str
221
231
 
222
232
 
223
233
  def _handle_arg_scan(
@@ -226,8 +236,12 @@ def _handle_arg_scan(
226
236
  policy: dict,
227
237
  feature: _ArgScanFeature,
228
238
  scan_text: str,
239
+ pre_scan_finding: Optional[_PreScanFinding] = None,
229
240
  ) -> HookDecision:
230
- """Shared scan + decision flow for MCP_EXECUTION and COMMAND_EXEC events."""
241
+ """Shared scan + decision flow for MCP_EXECUTION and COMMAND_EXEC events.
242
+
243
+ A Report-mode pre-scan finding still scans, and a secret found then takes over the response.
244
+ """
231
245
  ai_client = ctx.obj['ai_security_client']
232
246
 
233
247
  max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
@@ -240,6 +254,18 @@ def _handle_arg_scan(
240
254
  error_message = None
241
255
 
242
256
  try:
257
+ if pre_scan_finding is not None:
258
+ block_reason = pre_scan_finding.block_reason
259
+ if pre_scan_finding.mode == GuardrailsMode.BLOCK:
260
+ outcome = AIHookOutcome.BLOCKED
261
+ return HookDecision.deny(
262
+ feature.event_type, pre_scan_finding.deny_message, pre_scan_finding.deny_agent_message
263
+ )
264
+ outcome = AIHookOutcome.WARNED
265
+
266
+ if not feature.scan_enabled:
267
+ return HookDecision.allow(feature.event_type)
268
+
243
269
  scan_outcome = _scan_text_for_secrets(
244
270
  ctx,
245
271
  clipped,
@@ -283,8 +309,43 @@ def _handle_arg_scan(
283
309
  )
284
310
 
285
311
 
312
+ def _check_mcp_server_authorization(payload: AIHookPayload, policy: dict) -> Optional[_PreScanFinding]:
313
+ """Fails open: no server name or no cached statuses lets the call through."""
314
+ mcp_config = get_policy_value(policy, 'mcp', default={})
315
+ if not get_policy_value(mcp_config, 'check_server', default=False):
316
+ return None
317
+
318
+ server_name = payload.mcp_server_name
319
+ if not server_name:
320
+ logger.debug('No MCP server name in the payload; skipping the server authorization check')
321
+ return None
322
+
323
+ statuses = load_mcp_server_statuses()
324
+ if statuses is None:
325
+ logger.debug('No cached MCP server statuses; skipping the server authorization check')
326
+ return None
327
+
328
+ status = statuses.status_of(server_name)
329
+ if not is_enforced(status):
330
+ return None
331
+
332
+ logger.debug('MCP server is not authorized, %s', {'mcp_server_name': server_name, 'status': status})
333
+ return _PreScanFinding(
334
+ block_reason=BlockReason.UNAUTHORIZED_MCP_SERVER,
335
+ mode=get_effective_mode(mcp_config, action_key='server_action'),
336
+ deny_message=(
337
+ f"Cycode blocked MCP server '{server_name}': it is not authorized in your organization. "
338
+ 'Contact your admin to authorize it.'
339
+ ),
340
+ deny_agent_message=(
341
+ f"The MCP server '{server_name}' is not authorized in this organization. "
342
+ 'Do not retry its tools or reach it another way.'
343
+ ),
344
+ )
345
+
346
+
286
347
  def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision:
287
- """Scan MCP tool arguments for secrets before execution."""
348
+ """Check the MCP server is authorized, then scan the tool arguments for secrets."""
288
349
  tool = payload.mcp_tool_name or 'unknown'
289
350
  args = payload.mcp_arguments or {}
290
351
  args_text = args if isinstance(args, str) else json.dumps(args)
@@ -298,8 +359,10 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
298
359
  deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.',
299
360
  ask_message=lambda v: f'Allow MCP tool call "{tool}"? {v}',
300
361
  ask_agent_message='Possible secrets detected in tool arguments; proceed with caution.',
362
+ scan_enabled=get_policy_value(policy, 'mcp', 'scan_args', default=True),
301
363
  ),
302
364
  scan_text=args_text,
365
+ pre_scan_finding=_check_mcp_server_authorization(payload, policy),
303
366
  )
304
367
 
305
368
 
@@ -370,6 +433,13 @@ def _setup_scan_context(ctx: typer.Context) -> typer.Context:
370
433
  return ctx
371
434
 
372
435
 
436
+ def _run_scan(scan_func: Callable, documents: list[Document], scan_result: dict[str, Any]) -> None:
437
+ try:
438
+ scan_result['value'] = scan_func(documents)
439
+ except BaseException as e:
440
+ scan_result['error'] = e
441
+
442
+
373
443
  def _perform_scan(
374
444
  ctx: typer.Context, documents: list[Document], scan_parameters: dict, timeout_seconds: float
375
445
  ) -> ScanOutcome:
@@ -384,15 +454,17 @@ def _perform_scan(
384
454
  ctx, is_git_diff=False, is_commit_range=False, scan_parameters=scan_parameters
385
455
  )
386
456
 
387
- # Use ThreadPool.apply_async with timeout to abort if scan takes too long
388
- # This uses the same ThreadPool mechanism as run_parallel_batched_scan but with timeout support
389
- with ThreadPool(processes=1) as pool:
390
- result = pool.apply_async(scan_batch_thread_func, (documents,))
391
- try:
392
- _, error, local_scan_result = result.get(timeout=timeout_seconds)
393
- except PoolTimeoutError:
394
- logger.debug('Scan timed out after %s seconds', timeout_seconds)
395
- raise RuntimeError(f'Scan timed out after {timeout_seconds} seconds') from None
457
+ scan_result: dict[str, Any] = {}
458
+ scan_thread = threading.Thread(target=_run_scan, args=(scan_batch_thread_func, documents, scan_result), daemon=True)
459
+ scan_thread.start()
460
+ scan_thread.join(timeout_seconds)
461
+ if scan_thread.is_alive():
462
+ logger.debug('Scan timed out after %s seconds', timeout_seconds)
463
+ raise RuntimeError(f'Scan timed out after {timeout_seconds} seconds')
464
+ if 'error' in scan_result:
465
+ raise scan_result['error']
466
+
467
+ _, error, local_scan_result = scan_result['value']
396
468
 
397
469
  # Check if scan failed - raise exception to trigger fail_open policy
398
470
  if error:
@@ -0,0 +1,103 @@
1
+ """MCP server authorization status cache: session-start writes it, the pre-MCP-execution hook reads it.
2
+
3
+ An absent or corrupt cache means no status is known, so the guardrail fails open.
4
+ """
5
+
6
+ import json
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any, Optional
11
+
12
+ from marshmallow import EXCLUDE, Schema, fields, post_load
13
+
14
+ from cycode.cli.apps.ai_guardrails.scan.guardrail_config import DEFAULT_TTL_SECONDS
15
+ from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY
16
+ from cycode.cli.utils.path_utils import atomic_write_text, quarantine_corrupt_file
17
+ from cycode.cyclient.models import McpServerAuthorizationStatus, McpServerStatus, McpServerStatusSchema
18
+ from cycode.logger import get_logger
19
+
20
+ logger = get_logger('AI Guardrails')
21
+
22
+ MCP_SERVER_STATUSES_FILE_NAME = 'ai-guardrails-mcp-servers.json'
23
+
24
+
25
+ # An alias shared by several servers (e.g. configured differently per device) gets the most restrictive status.
26
+ _RESTRICTIVENESS = {
27
+ McpServerAuthorizationStatus.AUTHORIZED: 0,
28
+ McpServerAuthorizationStatus.UNREVIEWED: 1,
29
+ McpServerAuthorizationStatus.UNAUTHORIZED: 2,
30
+ }
31
+
32
+
33
+ def get_mcp_server_statuses_cache_path() -> Path:
34
+ return Path.home() / CYCODE_CONFIGURATION_DIRECTORY / MCP_SERVER_STATUSES_FILE_NAME
35
+
36
+
37
+ def is_enforced(status: Optional[McpServerAuthorizationStatus]) -> bool:
38
+ """``status`` is None when the platform never saw the server."""
39
+ return status == McpServerAuthorizationStatus.UNAUTHORIZED
40
+
41
+
42
+ @dataclass
43
+ class McpServerStatuses:
44
+ servers: list[McpServerStatus]
45
+ fetched_at: float
46
+ tenant_id: Optional[str] = None
47
+ ttl_seconds: float = DEFAULT_TTL_SECONDS
48
+ _by_alias: dict = field(init=False, repr=False)
49
+
50
+ def __post_init__(self) -> None:
51
+ # The platform matches aliases case-insensitively, so the CLI does too.
52
+ self._by_alias = {}
53
+ for server in self.servers:
54
+ if not server.alias:
55
+ continue
56
+ key = server.alias.lower()
57
+ known = self._by_alias.get(key)
58
+ if known is None or _RESTRICTIVENESS[server.status] > _RESTRICTIVENESS[known]:
59
+ self._by_alias[key] = server.status
60
+
61
+ def status_of(self, alias: str) -> Optional[McpServerAuthorizationStatus]:
62
+ return self._by_alias.get(alias.lower())
63
+
64
+
65
+ class McpServerStatusesSchema(Schema):
66
+ class Meta:
67
+ unknown = EXCLUDE
68
+
69
+ servers = fields.List(fields.Nested(McpServerStatusSchema), required=True)
70
+ fetched_at = fields.Float(required=True)
71
+ tenant_id = fields.String(allow_none=True, load_default=None)
72
+ ttl_seconds = fields.Float(allow_none=True, load_default=None)
73
+
74
+ @post_load
75
+ def build_dto(self, data: dict[str, Any], **_) -> McpServerStatuses:
76
+ data['ttl_seconds'] = data['ttl_seconds'] or DEFAULT_TTL_SECONDS
77
+ return McpServerStatuses(**data)
78
+
79
+
80
+ def save_mcp_server_statuses(
81
+ servers: list[McpServerStatus], tenant_id: Optional[str], ttl_seconds: float = DEFAULT_TTL_SECONDS
82
+ ) -> None:
83
+ path = get_mcp_server_statuses_cache_path()
84
+ statuses = McpServerStatuses(servers=servers, fetched_at=time.time(), tenant_id=tenant_id, ttl_seconds=ttl_seconds)
85
+ try:
86
+ path.parent.mkdir(parents=True, exist_ok=True)
87
+ atomic_write_text(str(path), json.dumps(McpServerStatusesSchema().dump(statuses)))
88
+ except Exception as e:
89
+ logger.debug('Failed to save MCP server statuses cache', exc_info=e)
90
+
91
+
92
+ def load_mcp_server_statuses() -> Optional[McpServerStatuses]:
93
+ path = get_mcp_server_statuses_cache_path()
94
+ if not path.exists():
95
+ return None
96
+
97
+ try:
98
+ with open(path, encoding='UTF-8') as file:
99
+ return McpServerStatusesSchema().load(json.load(file))
100
+ except Exception as e:
101
+ logger.warning('MCP server statuses cache is corrupt and will be moved aside', exc_info=e)
102
+ quarantine_corrupt_file(str(path))
103
+ return None
@@ -40,6 +40,7 @@ class BlockReason(StrEnum):
40
40
  SECRETS_IN_FILE = 'secrets_in_file'
41
41
  SECRETS_IN_MCP_ARGS = 'secrets_in_mcp_args'
42
42
  SENSITIVE_PATH = 'sensitive_path'
43
+ UNAUTHORIZED_MCP_SERVER = 'unauthorized_mcp_server'
43
44
  SCAN_FAILURE = 'scan_failure'
44
45
 
45
46
 
@@ -15,7 +15,14 @@ from cycode.cli.apps.ai_guardrails.ides import (
15
15
  collect_all_skills,
16
16
  get_ide,
17
17
  )
18
- from cycode.cli.apps.ai_guardrails.scan.guardrail_config import load_guardrail_config, save_guardrail_config
18
+ from cycode.cli.apps.ai_guardrails.scan.guardrail_config import (
19
+ DEFAULT_TTL_SECONDS,
20
+ GuardrailConfig,
21
+ load_guardrail_config,
22
+ save_guardrail_config,
23
+ )
24
+ from cycode.cli.apps.ai_guardrails.scan.mcp_server_status import save_mcp_server_statuses
25
+ from cycode.cli.apps.ai_guardrails.scan.types import BlockReason
19
26
  from cycode.cli.apps.ai_guardrails.scan.utils import read_stdin_text, safe_json_parse
20
27
  from cycode.cli.apps.auth.auth_common import get_authorization_info
21
28
  from cycode.cli.apps.auth.auth_manager import AuthManager
@@ -49,14 +56,21 @@ def _session_context_digest(report: dict) -> str:
49
56
  return hashlib.sha256(canonical.encode('utf-8')).hexdigest()
50
57
 
51
58
 
52
- def _should_skip_report(digest: str, tenant_id: Optional[str]) -> bool:
59
+ def _report_ttl_seconds(config: Optional[GuardrailConfig]) -> float:
60
+ """The response carries the MCP server statuses, so the guardrail's TTL applies while it is enabled."""
61
+ if config is None or config.is_off_for_every_agent(BlockReason.UNAUTHORIZED_MCP_SERVER):
62
+ return _SESSION_CONTEXT_TTL_SECONDS
63
+ return config.ttl_seconds
64
+
65
+
66
+ def _should_skip_report(digest: str, tenant_id: Optional[str], ttl_seconds: float) -> bool:
53
67
  """Skip when the same payload was already sent for this tenant and the TTL hasn't expired."""
54
68
  try:
55
69
  cache = json.loads(_session_context_cache_path().read_text(encoding='utf-8'))
56
70
  return (
57
71
  cache.get('hash') == digest
58
72
  and cache.get('tenant_id') == tenant_id
59
- and time.time() - float(cache.get('sent_at', 0)) < _SESSION_CONTEXT_TTL_SECONDS
73
+ and time.time() - float(cache.get('sent_at', 0)) < ttl_seconds
60
74
  )
61
75
  except Exception:
62
76
  # Missing/corrupt cache reads as a miss - over-sending is harmless
@@ -78,6 +92,7 @@ def _report_session_context(
78
92
  ai_client: 'AISecurityManagerClient',
79
93
  user_email: Optional[str],
80
94
  tenant_id: Optional[str],
95
+ config: Optional[GuardrailConfig],
81
96
  ) -> None:
82
97
  """Report the device + cross-IDE session context to the AI security manager. Never raises.
83
98
 
@@ -86,7 +101,7 @@ def _report_session_context(
86
101
  until the TTL expires.
87
102
  """
88
103
  try:
89
- config_files_by_ide, enabled_plugins = collect_all_session_contexts()
104
+ config_files, enabled_plugins = collect_all_session_contexts()
90
105
  report = {
91
106
  'hostname': get_hostname(),
92
107
  'platform_name': get_platform_name(),
@@ -94,7 +109,7 @@ def _report_session_context(
94
109
  'serial_number': get_serial_number(),
95
110
  'last_login_user': get_last_login_user(),
96
111
  # Sorted by path so the digest is stable regardless of IDE registry order.
97
- 'config_files': sorted(config_files_by_ide.values(), key=lambda f: f['path']),
112
+ 'config_files': sorted(config_files, key=lambda f: f['path']),
98
113
  'enabled_plugins': enabled_plugins,
99
114
  # Already deduplicated and sorted by path, for the same digest-stability reason.
100
115
  # Editing a skill body changes the digest and so re-reports the device's inventory.
@@ -103,12 +118,19 @@ def _report_session_context(
103
118
  }
104
119
 
105
120
  digest = _session_context_digest(report)
106
- if _should_skip_report(digest, tenant_id):
121
+ if _should_skip_report(digest, tenant_id, _report_ttl_seconds(config)):
107
122
  logger.debug('Session context unchanged; skipping report')
108
123
  return
109
124
 
110
- if ai_client.report_session_context(**report):
111
- _save_report_cache(digest, tenant_id)
125
+ response = ai_client.report_session_context(**report)
126
+ if response is None:
127
+ return
128
+
129
+ _save_report_cache(digest, tenant_id)
130
+ if response.mcp_servers is not None:
131
+ ttl_seconds = config.ttl_seconds if config is not None else DEFAULT_TTL_SECONDS
132
+ save_mcp_server_statuses(response.mcp_servers, tenant_id, ttl_seconds)
133
+ logger.debug('MCP server statuses cache updated')
112
134
  except Exception as e:
113
135
  logger.debug('Failed to report session context', exc_info=e)
114
136
 
@@ -166,14 +188,14 @@ def session_start_command(
166
188
  except Exception as e:
167
189
  logger.debug('Failed to create conversation during session start', exc_info=e)
168
190
 
169
- # Report session context (device + cross-IDE MCP servers and plugins)
170
- _report_session_context(ai_client, session_payload.ide_user_email, auth_info.tenant_id)
171
-
172
191
  # SessionStart precedes the first prompt hook in every IDE, so scans normally find a cache.
173
- _sync_guardrail_config(ai_client, auth_info.tenant_id)
192
+ config = _sync_guardrail_config(ai_client, auth_info.tenant_id)
193
+
194
+ # Report session context (device + cross-IDE MCP servers and plugins)
195
+ _report_session_context(ai_client, session_payload.ide_user_email, auth_info.tenant_id, config)
174
196
 
175
197
 
176
- def _sync_guardrail_config(ai_client: 'AISecurityManagerClient', tenant_id: Optional[str]) -> None:
198
+ def _sync_guardrail_config(ai_client: 'AISecurityManagerClient', tenant_id: Optional[str]) -> Optional[GuardrailConfig]:
177
199
  """Refresh the guardrail config cache when it is expired or belongs to another tenant.
178
200
 
179
201
  Every step here swallows its own failures - a broken cache or an unreachable platform
@@ -182,9 +204,12 @@ def _sync_guardrail_config(ai_client: 'AISecurityManagerClient', tenant_id: Opti
182
204
  cached = load_guardrail_config()
183
205
  if cached is not None and not cached.needs_refresh(tenant_id):
184
206
  logger.debug('Guardrail config cache is fresh, skipping fetch')
185
- return
207
+ return cached
186
208
 
187
209
  resolved = ai_client.get_resolved_guardrails()
188
- if resolved:
189
- save_guardrail_config(resolved, tenant_id)
190
- logger.debug('Guardrail config cache updated')
210
+ if not resolved:
211
+ return cached
212
+
213
+ save_guardrail_config(resolved, tenant_id)
214
+ logger.debug('Guardrail config cache updated')
215
+ return GuardrailConfig(payload=resolved, fetched_at=time.time(), tenant_id=tenant_id)
@@ -1,5 +1,6 @@
1
1
  import types
2
2
  from abc import ABC, abstractmethod
3
+ from functools import cache
3
4
  from typing import TYPE_CHECKING, Optional
4
5
 
5
6
  _GIT_ERROR_MESSAGE = """
@@ -10,11 +11,6 @@ You can help Cycode CLI to locate the Git executable
10
11
  by setting the GIT_PYTHON_GIT_EXECUTABLE=<path/to/git> environment variable.
11
12
  """.strip().replace('\n', ' ')
12
13
 
13
- try:
14
- import git
15
- except ImportError:
16
- git = None
17
-
18
14
  if TYPE_CHECKING:
19
15
  from git import PathLike, Repo
20
16
 
@@ -23,6 +19,16 @@ class GitProxyError(Exception):
23
19
  pass
24
20
 
25
21
 
22
+ # GitPython runs `git version` on import, so it is imported on first use rather than at CLI startup
23
+ @cache
24
+ def _import_git() -> Optional[types.ModuleType]:
25
+ try:
26
+ import git
27
+ except ImportError:
28
+ return None
29
+ return git
30
+
31
+
26
32
  class _AbstractGitProxy(ABC):
27
33
  @abstractmethod
28
34
  def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo': ...
@@ -52,46 +58,54 @@ class _DummyGitProxy(_AbstractGitProxy):
52
58
 
53
59
 
54
60
  class _GitProxy(_AbstractGitProxy):
61
+ def __init__(self, git_module: types.ModuleType) -> None:
62
+ self._git = git_module
63
+
55
64
  def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo':
56
- return git.Repo(path, *args, **kwargs)
65
+ return self._git.Repo(path, *args, **kwargs)
57
66
 
58
67
  def get_null_tree(self) -> object:
59
- return git.NULL_TREE
68
+ return self._git.NULL_TREE
60
69
 
61
70
  def get_invalid_git_repository_error(self) -> type[BaseException]:
62
- return git.InvalidGitRepositoryError
71
+ return self._git.InvalidGitRepositoryError
63
72
 
64
73
  def get_git_command_error(self) -> type[BaseException]:
65
- return git.GitCommandError
74
+ return self._git.GitCommandError
66
75
 
67
76
 
68
77
  def get_git_proxy(git_module: Optional[types.ModuleType]) -> _AbstractGitProxy:
69
- return _GitProxy() if git_module else _DummyGitProxy()
78
+ return _GitProxy(git_module) if git_module else _DummyGitProxy()
70
79
 
71
80
 
72
81
  class GitProxyManager(_AbstractGitProxy):
73
82
  """We are using this manager for easy unit testing and mocking of the git module."""
74
83
 
75
84
  def __init__(self) -> None:
76
- self._git_proxy = get_git_proxy(git)
85
+ self._git_proxy: Optional[_AbstractGitProxy] = None
86
+
87
+ def _get_git_proxy(self) -> _AbstractGitProxy:
88
+ if self._git_proxy is None:
89
+ self._git_proxy = get_git_proxy(_import_git())
90
+ return self._git_proxy
77
91
 
78
92
  def _set_dummy_git_proxy(self) -> None:
79
93
  self._git_proxy = _DummyGitProxy()
80
94
 
81
95
  def _set_git_proxy(self) -> None:
82
- self._git_proxy = _GitProxy()
96
+ self._git_proxy = _GitProxy(_import_git())
83
97
 
84
98
  def get_repo(self, path: Optional['PathLike'] = None, *args, **kwargs) -> 'Repo':
85
- return self._git_proxy.get_repo(path, *args, **kwargs)
99
+ return self._get_git_proxy().get_repo(path, *args, **kwargs)
86
100
 
87
101
  def get_null_tree(self) -> object:
88
- return self._git_proxy.get_null_tree()
102
+ return self._get_git_proxy().get_null_tree()
89
103
 
90
104
  def get_invalid_git_repository_error(self) -> type[BaseException]:
91
- return self._git_proxy.get_invalid_git_repository_error()
105
+ return self._get_git_proxy().get_invalid_git_repository_error()
92
106
 
93
107
  def get_git_command_error(self) -> type[BaseException]:
94
- return self._git_proxy.get_git_command_error()
108
+ return self._get_git_proxy().get_git_command_error()
95
109
 
96
110
 
97
111
  git_proxy = GitProxyManager()
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Optional
5
5
  from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError
6
6
  from cycode.cyclient.cycode_client_base import CycodeClientBase
7
7
  from cycode.cyclient.logger import logger
8
+ from cycode.cyclient.models import SessionContextResponse, SessionContextResponseSchema
8
9
 
9
10
  if TYPE_CHECKING:
10
11
  from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
@@ -114,8 +115,8 @@ class AISecurityManagerClient:
114
115
  enabled_plugins: Optional[dict] = None,
115
116
  skill_files: Optional[list[dict]] = None,
116
117
  user_email: Optional[str] = None,
117
- ) -> bool:
118
- """Report session context to the backend. Returns whether the report was accepted."""
118
+ ) -> Optional[SessionContextResponse]:
119
+ """Report session context to the backend. Returns None when the report was not accepted."""
119
120
  body: dict = {
120
121
  'hostname': hostname,
121
122
  'platform_name': platform_name,
@@ -129,9 +130,14 @@ class AISecurityManagerClient:
129
130
  }
130
131
 
131
132
  try:
132
- self.client.post(self._build_endpoint_path(self._SESSION_CONTEXT_PATH), body=body)
133
- return True
133
+ response = self.client.post(self._build_endpoint_path(self._SESSION_CONTEXT_PATH), body=body)
134
134
  except Exception as e:
135
135
  logger.debug('Failed to report session context', exc_info=e)
136
136
  # Don't fail the session if reporting fails
137
- return False
137
+ return None
138
+
139
+ try:
140
+ return SessionContextResponseSchema().load(response.json())
141
+ except Exception as e:
142
+ logger.debug('Failed to parse the session context response', exc_info=e)
143
+ return SessionContextResponse()
cycode/cyclient/models.py CHANGED
@@ -1,7 +1,8 @@
1
1
  from dataclasses import dataclass
2
+ from enum import Enum
2
3
  from typing import Any, Optional
3
4
 
4
- from marshmallow import EXCLUDE, Schema, fields, post_load
5
+ from marshmallow import EXCLUDE, Schema, fields, missing, post_load
5
6
 
6
7
 
7
8
  class Detection(Schema):
@@ -578,3 +579,60 @@ class ScanConfigurationSchema(Schema):
578
579
  @post_load
579
580
  def build_dto(self, data: dict[str, Any], **_) -> 'ScanConfiguration':
580
581
  return ScanConfiguration(**data)
582
+
583
+
584
+ class McpServerAuthorizationStatus(str, Enum):
585
+ AUTHORIZED = 'Authorized'
586
+ UNREVIEWED = 'Unreviewed'
587
+ UNAUTHORIZED = 'Unauthorized'
588
+
589
+ @classmethod
590
+ def parse(cls, raw_status: object) -> 'McpServerAuthorizationStatus':
591
+ """An unknown status reads as Unreviewed: no decision was made on the server."""
592
+ lowered = str(raw_status or '').lower()
593
+ return next((status for status in cls if status.value.lower() == lowered), cls.UNREVIEWED)
594
+
595
+
596
+ class McpServerAuthorizationStatusField(fields.Field):
597
+ def _serialize(self, value: Optional[McpServerAuthorizationStatus], *_, **__) -> Optional[str]:
598
+ return value.value if value is not None else None
599
+
600
+ def deserialize(self, value: object, *_, **__) -> McpServerAuthorizationStatus:
601
+ return McpServerAuthorizationStatus.parse(None if value is missing else value)
602
+
603
+
604
+ @dataclass
605
+ class McpServerStatus:
606
+ alias: Optional[str] = None
607
+ normalized_id: Optional[str] = None
608
+ status: McpServerAuthorizationStatus = McpServerAuthorizationStatus.UNREVIEWED
609
+
610
+
611
+ class McpServerStatusSchema(Schema):
612
+ class Meta:
613
+ unknown = EXCLUDE
614
+
615
+ alias = fields.String(allow_none=True, load_default=None)
616
+ normalized_id = fields.String(allow_none=True, load_default=None)
617
+ status = McpServerAuthorizationStatusField()
618
+
619
+ @post_load
620
+ def build_dto(self, data: dict[str, Any], **_) -> McpServerStatus:
621
+ return McpServerStatus(**data)
622
+
623
+
624
+ @dataclass
625
+ class SessionContextResponse:
626
+ # None when the response carries no (or malformed) MCP server statuses.
627
+ mcp_servers: Optional[list[McpServerStatus]] = None
628
+
629
+
630
+ class SessionContextResponseSchema(Schema):
631
+ class Meta:
632
+ unknown = EXCLUDE
633
+
634
+ mcp_servers = fields.List(fields.Nested(McpServerStatusSchema), allow_none=True, load_default=None)
635
+
636
+ @post_load
637
+ def build_dto(self, data: dict[str, Any], **_) -> SessionContextResponse:
638
+ return SessionContextResponse(**data)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cycode
3
- Version: 3.25.1.dev5
3
+ Version: 3.25.2.dev1
4
4
  Summary: Boost security in your dev lifecycle via SAST, SCA, Secrets & IaC scanning.
5
5
  License-Expression: MIT
6
6
  License-File: LICENCE
@@ -1,4 +1,4 @@
1
- cycode/__init__.py,sha256=6WKcMLjP5XCkzdZKqwYz8GeKUDhC-Ytqa32ZzGrlzBY,396
1
+ cycode/__init__.py,sha256=DoaTBfeYc_ypdzVwGnWIXrteh9HwF94hYZUf03QS72M,396
2
2
  cycode/__main__.py,sha256=Z3bD5yrA7yPvAChcADQrqCaZd0ChGI1gdiwALwbWJ6U,104
3
3
  cycode/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  cycode/cli/app.py,sha256=AlR2durAEbsa47PDfIj7JtMvJDWA_Dq6wPtVuMJYSCs,10250
@@ -8,26 +8,27 @@ cycode/cli/apps/ai_guardrails/__init__.py,sha256=NsqB1Ca83BIjJMcDSt6suec6Ed0iNna
8
8
  cycode/cli/apps/ai_guardrails/command_utils.py,sha256=NVwd0-2RGRKIqhsQ-4LNDR1D0gVm_o7n-z5LxG2bqAo,800
9
9
  cycode/cli/apps/ai_guardrails/consts.py,sha256=CO3j7CiVoc0CuWtIjYEWmGSXNAd2DfiPhffW-gZMRT4,1152
10
10
  cycode/cli/apps/ai_guardrails/hooks_manager.py,sha256=gIP4LGyOL1OFmCfxM1nrjedrBpCwLc_mcb7k40bIz_Q,9398
11
- cycode/cli/apps/ai_guardrails/ides/__init__.py,sha256=5odqWNfvvbFfAtR8ioEytXZ5VczWAOGyr1xuASC_OnQ,3346
12
- cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py,sha256=XPIc9pZFEgdGVTSSAl9yqI48F88GKlFnJ5FsLUhHjCE,3981
11
+ cycode/cli/apps/ai_guardrails/ides/__init__.py,sha256=wUvkI0b0RvoLNaQcEjX3v4YgAzz8gKmUxpIkjvWlWIE,3240
12
+ cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py,sha256=ieho3uuo0ddExiVVyEPT3Pu6ZGyw4FAePFusJnPUu3w,4013
13
13
  cycode/cli/apps/ai_guardrails/ides/_skill_utils.py,sha256=Xk8z-uquTMvg4JxNwZxnclR79ME7ZKq7rxht3aFQUeo,3972
14
- cycode/cli/apps/ai_guardrails/ides/base.py,sha256=B_wrSO48Hl__Xk92X4rKmwnY0RkP8jAJtE84wIW-Qnk,7985
15
- cycode/cli/apps/ai_guardrails/ides/claude_code.py,sha256=JCSB2PRPtZETFSskxV02hUwNRKExx7mXxtEmdxt_ruE,15707
16
- cycode/cli/apps/ai_guardrails/ides/codex.py,sha256=6MsjPaNlYkho0y8cYOUX9_9kH0Ow9drQ4q0ahfWNyoc,12252
17
- cycode/cli/apps/ai_guardrails/ides/copilot.py,sha256=XK2CLdB3oLJrP5Z-pGNsLbnwfehv4AW4vTLdvSE3eb0,21894
18
- cycode/cli/apps/ai_guardrails/ides/cursor.py,sha256=cqi12ELV5ZF7ANUA9n33gc8Hj9hURqEBLJ3gzf-v68w,5850
14
+ cycode/cli/apps/ai_guardrails/ides/base.py,sha256=fMfcOtqfs3KM1oD54ePS--YaWfk3YuCXtRn8IpAfhEk,7962
15
+ cycode/cli/apps/ai_guardrails/ides/claude_code.py,sha256=7oEhrbu2XatqBylR_xrJQPStU8v1lfFKeT00lximnFM,17354
16
+ cycode/cli/apps/ai_guardrails/ides/codex.py,sha256=IdSVDClifhHQZLKxM4mFbUkn8j2dh2fT5PNoISTwu-s,13729
17
+ cycode/cli/apps/ai_guardrails/ides/copilot.py,sha256=AWYQM9ulmyV5o_RyzfK4nkjCxF724F0bi1RhfXTgunY,21892
18
+ cycode/cli/apps/ai_guardrails/ides/cursor.py,sha256=b8VusI2pxG3GKkkS2y84zr4xiMqz-1UBe-8NLFVMjIA,7419
19
19
  cycode/cli/apps/ai_guardrails/install_command.py,sha256=3T-Wj_Fr871uhfTe77CGqrkeAwvHafK_aEv7BZ0jAJU,4069
20
20
  cycode/cli/apps/ai_guardrails/scan/__init__.py,sha256=qJc82XiQGiAuc1sYY8Ij_A-qXpxgLPuayQq8xWlouMA,48
21
21
  cycode/cli/apps/ai_guardrails/scan/consts.py,sha256=8H8JXlgm65sYkgTXqsBInHhHe80UPjqY4vumZXB4GGM,1060
22
22
  cycode/cli/apps/ai_guardrails/scan/detach.py,sha256=8BRgBq9Qi8nXEx6NKlE8scSRPkO30XCfsiI5PLvq6Gk,2700
23
- cycode/cli/apps/ai_guardrails/scan/guardrail_config.py,sha256=KHYZCiF86zpuUScex46oQ8dsA5zu30WmCXU54O6Pphg,6871
24
- cycode/cli/apps/ai_guardrails/scan/handlers.py,sha256=pHs_T6uTAT4QlJ_MMhc-EdjdfH9IdVczZ6XMdmaWPKg,17760
23
+ cycode/cli/apps/ai_guardrails/scan/guardrail_config.py,sha256=Cm-a52lGIRn7u5utHNOak42wX5_EK9Du4JVuS-kNhMU,7970
24
+ cycode/cli/apps/ai_guardrails/scan/handlers.py,sha256=utVeDKLKzE0DtvMysipTRYS-3J_SqVlaxwY39yZS-_M,20527
25
+ cycode/cli/apps/ai_guardrails/scan/mcp_server_status.py,sha256=BrxS6YSvdpp8fappgR_PY96lex1kMqG68bgI5n0D-E8,3895
25
26
  cycode/cli/apps/ai_guardrails/scan/payload.py,sha256=sWsWq5yXP54MVCajsb180kLuprI_M2kspMOzQGh7r_o,1534
26
27
  cycode/cli/apps/ai_guardrails/scan/policy.py,sha256=3HuDoL_NYE3lyHRAlIMleq6pi-stIDQwotHsKhUFhTQ,5000
27
28
  cycode/cli/apps/ai_guardrails/scan/scan_command.py,sha256=fgV32phgkBPwqSau7oFDISZ58CVpq214JlYwZQM9bnM,8318
28
- cycode/cli/apps/ai_guardrails/scan/types.py,sha256=ybQm242QN0l_4SSNX4xMHXxzqEK-MW-hfIOixI7zvGU,1497
29
+ cycode/cli/apps/ai_guardrails/scan/types.py,sha256=6nh_40KSTuWabsaVqkZEl712m6lAI8k9OuhPo8KhJdw,1553
29
30
  cycode/cli/apps/ai_guardrails/scan/utils.py,sha256=4CIZAILS9m5-JsFbUderwBVzFAjqAVVVRhWgE9oLPhM,5292
30
- cycode/cli/apps/ai_guardrails/session_start_command.py,sha256=oEW-OsHXkf8P7SX1vKa9Ddlv_hASRiQ42JZR3MnJfRg,7171
31
+ cycode/cli/apps/ai_guardrails/session_start_command.py,sha256=BohIVr7d1VmesxKEC0vUOp_xuTa5DJIPmu4EEW5aFHU,8267
31
32
  cycode/cli/apps/ai_guardrails/status_command.py,sha256=Uqss68TEPCYPXpLix6Bh-4J3g-khxWsAqlIGYH5x4bQ,3203
32
33
  cycode/cli/apps/ai_guardrails/uninstall_command.py,sha256=dOmePfZmlHAPy2zEJM1yMtSuDqvzDwtqgmLKYK-T9PI,2698
33
34
  cycode/cli/apps/ai_remediation/__init__.py,sha256=8vYthY9RQeJqEni3AIF5sryz8n-XJQ6VNqG4aEFBAdY,553
@@ -185,7 +186,7 @@ cycode/cli/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,
185
186
  cycode/cli/utils/binary_utils.py,sha256=PxP-rVJ1lEiOam-DQ1XU68oWtfwup4sCFHXv54cfZkY,2528
186
187
  cycode/cli/utils/enum_utils.py,sha256=h_VTCfJ-0hnhwDsEznmx56rJrCb5FQ8u6PrI6p8MP3E,187
187
188
  cycode/cli/utils/get_api_client.py,sha256=wwHabfVCDbFjcIwOn5Raho8MEPiOAgkHlGUEfXKpl8U,3542
188
- cycode/cli/utils/git_proxy.py,sha256=FPHMBiyLFK9X9vKYpKySRKJH6Dc9Cb3nO241Q95dASE,2911
189
+ cycode/cli/utils/git_proxy.py,sha256=S_CwJxBOBO1lvp5Eoh_-vMFIxiFU2YZ_BhpQCufI54A,3486
189
190
  cycode/cli/utils/host_info.py,sha256=Clln3qCJR00rw6HndD0bAIv329n_bOEx42TKW6FqXFQ,6379
190
191
  cycode/cli/utils/ignore_utils.py,sha256=cODqhnOHA2kRo8rMY0YcmcKkmXNPOC9UTCmFu62RRqE,15567
191
192
  cycode/cli/utils/jwt_utils.py,sha256=EGI-0CKhCGY8hIcZ9b9diq9hqtOUf8Ha8ukeVJIf974,818
@@ -202,7 +203,7 @@ cycode/cli/utils/version_checker.py,sha256=0f5PaTk02ZkDxzBqZOeMV9mU_CWcx6HKW80jU
202
203
  cycode/cli/utils/yaml_utils.py,sha256=eyOcfNtqvmRMKrKY5jCdwwKKOsXAFchcBAoES0F_o7c,2167
203
204
  cycode/config.py,sha256=jHORGZQcAXkAGSf2XreC-RQoc8sdNWja69QKtPWTbWo,1044
204
205
  cycode/cyclient/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
205
- cycode/cyclient/ai_security_manager_client.py,sha256=90fvqH_h2-dI9YqQ6oo4YBfa777p5mSr8JSM1ZZJJcE,5486
206
+ cycode/cyclient/ai_security_manager_client.py,sha256=MidcAzMtAJdOJtpgRsotJNQUOmRqTdgs4_A4_poNB6A,5840
206
207
  cycode/cyclient/ai_security_manager_service_config.py,sha256=83pQzgOb93JW6E-dznJkI4c0NEXmQRlx9YZKMmjVwp8,808
207
208
  cycode/cyclient/auth_client.py,sha256=TwbmZ358Ancf-Q-IZolvfljZ8691_6botsqd0R0PLPk,2105
208
209
  cycode/cyclient/base_token_auth_client.py,sha256=V907nf_1TvtfzwDal2bAgmXvgqaaX_ERUvSRipujz30,5490
@@ -218,13 +219,13 @@ cycode/cyclient/cycode_token_based_client.py,sha256=frbrv1jzF388SXqHNNkZ95Hbx7Vj
218
219
  cycode/cyclient/headers.py,sha256=NMlVH9hAxZ-CyWaZJio29Mc5KFcFvNvnJTshueM3hFI,1337
219
220
  cycode/cyclient/import_sbom_client.py,sha256=M0RAn2dDh9woI3SUkgSHCQxhbARoLpyAM3amOausz8E,2749
220
221
  cycode/cyclient/logger.py,sha256=oTkay7QzoOIVQ71cGOy4ukkijYGA3IKJlHkL24Px5ds,70
221
- cycode/cyclient/models.py,sha256=Bv3K10KmUlF_VG81GGMeyFbJWOR3sQZh6j-EDdeeKK4,15661
222
+ cycode/cyclient/models.py,sha256=KyazUWcdMW3LCEplIoObKHngJNT-HH26xGCqLalNBTI,17638
222
223
  cycode/cyclient/report_client.py,sha256=Scq30NeJPzgXv0hPLO1U05AdE9i_2iu6cIrSKpEJ-cM,4399
223
224
  cycode/cyclient/scan_client.py,sha256=DqAZ7u6Z_cvw9A9RlLkAQUgLRwPCCAsUq5U9umt4F7Y,16955
224
225
  cycode/cyclient/scan_config_base.py,sha256=mXsPZGYCtp85rv5GIige40yQZXuRcEKUW-VQJ0vgFzk,1201
225
226
  cycode/logger.py,sha256=EfZGRK6VC5rE_LAjIcRrHFiQCueylCDXoG6bvGkrIME,2111
226
- cycode-3.25.1.dev5.dist-info/METADATA,sha256=NdMkWG7AUN3Z3cckV3wmSsHHYlPCU5GnT1pEeT1iO00,96824
227
- cycode-3.25.1.dev5.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
228
- cycode-3.25.1.dev5.dist-info/entry_points.txt,sha256=iDcVJM8ByLElVgvBgtYxDjw1kT7O8Mo0LcWZIT5L3Ig,45
229
- cycode-3.25.1.dev5.dist-info/licenses/LICENCE,sha256=2Wx4N6mD_4xB7-E3hPkZ3MPhpJy__k_I8MaCSO-PDRo,1068
230
- cycode-3.25.1.dev5.dist-info/RECORD,,
227
+ cycode-3.25.2.dev1.dist-info/METADATA,sha256=8MDn9Sw-Mjdx7tfPV3uDI7O9vR23_eKj4R1WQz2utW0,96824
228
+ cycode-3.25.2.dev1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
229
+ cycode-3.25.2.dev1.dist-info/entry_points.txt,sha256=iDcVJM8ByLElVgvBgtYxDjw1kT7O8Mo0LcWZIT5L3Ig,45
230
+ cycode-3.25.2.dev1.dist-info/licenses/LICENCE,sha256=2Wx4N6mD_4xB7-E3hPkZ3MPhpJy__k_I8MaCSO-PDRo,1068
231
+ cycode-3.25.2.dev1.dist-info/RECORD,,