cycode 3.25.1.dev6__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.dev6' # 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:
@@ -21,6 +21,7 @@ if TYPE_CHECKING:
21
21
 
22
22
  from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode, PolicyMode
23
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
24
25
  from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
25
26
  from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value
26
27
  from cycode.cli.apps.ai_guardrails.scan.types import (
@@ -217,6 +218,16 @@ class _ArgScanFeature:
217
218
  deny_agent_message: str
218
219
  ask_message: Callable[[str], str]
219
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
220
231
 
221
232
 
222
233
  def _handle_arg_scan(
@@ -225,8 +236,12 @@ def _handle_arg_scan(
225
236
  policy: dict,
226
237
  feature: _ArgScanFeature,
227
238
  scan_text: str,
239
+ pre_scan_finding: Optional[_PreScanFinding] = None,
228
240
  ) -> HookDecision:
229
- """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
+ """
230
245
  ai_client = ctx.obj['ai_security_client']
231
246
 
232
247
  max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
@@ -239,6 +254,18 @@ def _handle_arg_scan(
239
254
  error_message = None
240
255
 
241
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
+
242
269
  scan_outcome = _scan_text_for_secrets(
243
270
  ctx,
244
271
  clipped,
@@ -282,8 +309,43 @@ def _handle_arg_scan(
282
309
  )
283
310
 
284
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
+
285
347
  def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision:
286
- """Scan MCP tool arguments for secrets before execution."""
348
+ """Check the MCP server is authorized, then scan the tool arguments for secrets."""
287
349
  tool = payload.mcp_tool_name or 'unknown'
288
350
  args = payload.mcp_arguments or {}
289
351
  args_text = args if isinstance(args, str) else json.dumps(args)
@@ -297,8 +359,10 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
297
359
  deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.',
298
360
  ask_message=lambda v: f'Allow MCP tool call "{tool}"? {v}',
299
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),
300
363
  ),
301
364
  scan_text=args_text,
365
+ pre_scan_finding=_check_mcp_server_authorization(payload, policy),
302
366
  )
303
367
 
304
368
 
@@ -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)
@@ -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.dev6
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=-tYAF_Tsm27vNIZkcIR8PI9pCAyGposJv9-Q-QJc9lg,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=8OHZUm-_JQ6Lf5YgJz82ScvqgzjrAX4Qa3-ne8VQvyk,17838
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
@@ -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.dev6.dist-info/METADATA,sha256=tt1_1H0Da8JXvQ4Kv0K6nm2ejCgJ0bYwnUNzQe03a3s,96824
227
- cycode-3.25.1.dev6.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
228
- cycode-3.25.1.dev6.dist-info/entry_points.txt,sha256=iDcVJM8ByLElVgvBgtYxDjw1kT7O8Mo0LcWZIT5L3Ig,45
229
- cycode-3.25.1.dev6.dist-info/licenses/LICENCE,sha256=2Wx4N6mD_4xB7-E3hPkZ3MPhpJy__k_I8MaCSO-PDRo,1068
230
- cycode-3.25.1.dev6.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,,