Skip to content

Commit f78ff31

Browse files
Altruistusclaude
andauthored
CM-73403: Enforce the unauthorized MCP server guardrail (#551)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 367b0c3 commit f78ff31

26 files changed

Lines changed: 1338 additions & 154 deletions

‎cycode/cli/apps/ai_guardrails/ides/__init__.py‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,23 +35,22 @@ def get_ide(name: str) -> IDE:
3535
return ide
3636

3737

38-
def collect_all_session_contexts() -> tuple[dict[str, dict], dict]:
38+
def collect_all_session_contexts() -> tuple[list[dict], dict]:
3939
"""Sweep every registered IDE's session context, regardless of which IDE triggered the hook.
4040
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).
4444
"""
45-
config_files_by_ide: dict[str, dict] = {}
45+
config_files: list[dict] = []
4646
plugins: dict = {}
4747
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)
5150
for plugin_key, plugin in (enabled_plugins or {}).items():
5251
plugins.setdefault(plugin_key, plugin)
5352

54-
return config_files_by_ide, plugins
53+
return config_files, plugins
5554

5655

5756
def collect_all_skills() -> list[dict]:

‎cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,17 @@ def load_plugin_json(path: Path) -> Optional[dict]:
4242
return None
4343

4444

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]:
4646
"""Wrap a global (non-plugin) MCP config into the session-context file shape.
4747
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
5050
``{"mcpServers": {...}}`` shape, dropping everything else in the source file.
5151
"""
52-
servers = mcp_servers or {}
52+
servers = mcp_servers if isinstance(mcp_servers, dict) else {}
5353
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})}]
5656

5757

5858
def walk_enabled_plugins(

‎cycode/cli/apps/ai_guardrails/ides/base.py‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -176,19 +176,19 @@ def get_user_email(self) -> Optional[str]:
176176
"""
177177
return None
178178

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.
181181
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``).
187187
188-
Default: ``(None, {})`` (no plugin system, no discoverable MCP config).
188+
Default: ``([], {})`` (no plugin system, no discoverable MCP config).
189189
Override to surface MCP/plugin inventory.
190190
"""
191-
return None, {}
191+
return [], {}
192192

193193
def get_skills(self) -> list[dict]:
194194
"""Return the IDE's user-scope skills as ``[{"path", "content"}]``.
@@ -198,7 +198,7 @@ def get_skills(self) -> list[dict]:
198198
read these files off endpoints can only ever return raw content.
199199
200200
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.
202202
203203
Default: ``[]`` (the IDE has no skill system). Override to surface skills.
204204
"""

‎cycode/cli/apps/ai_guardrails/ides/claude_code.py‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"""Claude Code IDE integration for AI guardrails."""
22

33
import json
4+
import re
45
from collections.abc import Iterator
56
from copy import deepcopy
67
from pathlib import Path
78
from typing import ClassVar, Optional
89

910
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
1011
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
11-
build_global_config_file,
12+
build_global_config_files,
1213
load_plugin_json,
1314
resolve_cached_plugin_dir,
1415
walk_enabled_plugins,
@@ -253,6 +254,41 @@ def _locate(plugin_name: str, marketplace_name: str) -> Optional[Path]:
253254
)
254255

255256

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+
256292
# --- IDE integration ----------------------------------------------------------
257293

258294

@@ -339,6 +375,8 @@ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
339375

340376
config = load_claude_config()
341377
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'))
342380

343381
return AIHookPayload(
344382
event_name=canonical_event,
@@ -396,14 +434,14 @@ def get_user_email(self) -> Optional[str]:
396434
config = load_claude_config()
397435
return _email_from_config(config) if config else None
398436

399-
def get_session_context(self) -> tuple[Optional[dict], dict]:
437+
def get_session_context(self) -> tuple[list[dict], dict]:
400438
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 []
402440

403441
settings = load_claude_settings()
404442
enriched_plugins = resolve_plugins(settings) if settings else {}
405443

406-
return global_config_file, enriched_plugins
444+
return global_config_files, enriched_plugins
407445

408446
def get_skills(self) -> list[dict]:
409447
return walk_skill_dirs(_claude_skills_dir())

‎cycode/cli/apps/ai_guardrails/ides/codex.py‎

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import json
44
import os
5+
import re
56
import sys
7+
from collections.abc import Iterator
68
from pathlib import Path
79
from typing import ClassVar, Optional
810

@@ -15,7 +17,7 @@
1517

1618
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
1719
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
18-
build_global_config_file,
20+
build_global_config_files,
1921
load_plugin_json,
2022
resolve_cached_plugin_dir,
2123
walk_enabled_plugins,
@@ -158,6 +160,42 @@ def _resolve_codex_plugins(config: dict) -> dict:
158160
)
159161

160162

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+
161199
def _enable_codex_hooks_feature(scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
162200
"""Set ``[features] hooks = true`` in Codex's ``config.toml``.
163201
@@ -247,12 +285,8 @@ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
247285
mcp_server_name = None
248286
mcp_tool_name = None
249287
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)
256290
mcp_arguments = tool_input
257291

258292
return AIHookPayload(
@@ -302,17 +336,17 @@ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
302336
def get_user_email(self) -> Optional[str]:
303337
return _email_from_auth()
304338

305-
def get_session_context(self) -> tuple[Optional[dict], dict]:
339+
def get_session_context(self) -> tuple[list[dict], dict]:
306340
config = _load_codex_config()
307341
if not config:
308-
return None, {}
342+
return [], {}
309343
# Codex stores MCP servers under `[mcp_servers.<name>]`; the global config
310344
# file becomes its own session-context file. Plugins (via
311345
# `[plugins."<plugin>@<marketplace>"]`) carry their own config files.
312346
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'))
314348
enriched_plugins = _resolve_codex_plugins(config)
315-
return global_config_file, enriched_plugins
349+
return global_config_files, enriched_plugins
316350

317351
def get_skills(self) -> list[dict]:
318352
return walk_skill_dirs(_codex_skills_dir())

‎cycode/cli/apps/ai_guardrails/ides/copilot.py‎

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333

3434
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
3535
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
36-
build_global_config_file,
36+
build_global_config_files,
3737
load_plugin_json,
3838
walk_enabled_plugins,
3939
)
@@ -283,6 +283,15 @@ def _collect_installed_plugins() -> dict:
283283
# --- MCP tool-name splitting ------------------------------------------------------
284284

285285

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+
286295
def _known_mcp_server_names() -> list[str]:
287296
"""Config-declared MCP server names, across both runtimes' config files.
288297
@@ -293,15 +302,7 @@ def _known_mcp_server_names() -> list[str]:
293302
Best-effort inventory: servers contributed by extensions, ``chat.mcp.discovery``
294303
imports, dev containers, or non-default profiles are not discoverable from disk.
295304
"""
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]
305306
for plugin in _collect_installed_plugins().values():
306307
names.extend(plugin.get('mcp_server_names') or [])
307308
return names
@@ -485,14 +486,11 @@ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
485486
source=raw_payload.get('source'),
486487
)
487488

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()
496494

497495
def get_skills(self) -> list[dict]:
498496
return walk_skill_dirs(_copilot_skills_dir())

0 commit comments

Comments
 (0)