Skip to content

Commit d651b3c

Browse files
RoniCycodeclaude
andauthored
CM-65100-add-file-support-codex-and-claude (#463)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 67aab55 commit d651b3c

12 files changed

Lines changed: 239 additions & 73 deletions

File tree

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

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,26 @@ def load_plugin_json(path: Path) -> Optional[dict]:
2626
return None
2727

2828

29+
def build_global_config_file(path: Path, mcp_servers: Optional[dict]) -> Optional[dict]:
30+
"""Wrap a global (non-plugin) MCP config into the session-context file shape.
31+
32+
Returns ``{"path": <full path>, "content": <{"mcpServers": ...} JSON>}`` when
33+
there are servers, else ``None``. ``content`` is normalized to the canonical
34+
``{"mcpServers": {...}}`` shape, dropping everything else in the source file.
35+
"""
36+
servers = mcp_servers or {}
37+
if not servers:
38+
return None
39+
return {'path': str(path), 'content': json.dumps({'mcpServers': servers})}
40+
41+
2942
def walk_enabled_plugins(
3043
plugin_entries: dict[str, Any],
3144
is_enabled: Callable[[Any], bool],
3245
locate_dir: Callable[[str, str], Optional[Path]],
3346
read_plugin: Callable[[Path], tuple[dict, dict]],
34-
) -> tuple[dict, dict]:
35-
"""Iterate enabled plugins; merge their MCP servers and metadata.
47+
) -> dict:
48+
"""Iterate enabled plugins and build their inventory metadata.
3649
3750
Args:
3851
plugin_entries: ``{<plugin>@<marketplace>: settings}`` map from the IDE config.
@@ -42,13 +55,13 @@ def walk_enabled_plugins(
4255
filesystem path or None if it can't be resolved.
4356
read_plugin: given the plugin path, returns ``(entry_fields, servers)``:
4457
``entry_fields`` are extra metadata to attach to the inventory entry
45-
(name/version/description/...), ``servers`` are MCP servers contributed.
58+
(name/version/description/...); ``servers`` are the plugin's MCP
59+
servers, which ``read_plugin`` uses to derive that metadata.
4660
47-
Returns ``(merged_mcp_servers, enriched_plugins)``. Plugin keys without
48-
``@`` (or that fail to resolve to a directory) still appear in the
49-
inventory with just ``{'enabled': True}`` so we don't silently drop them.
61+
Returns ``enriched_plugins``. Plugin keys without ``@`` (or that fail to
62+
resolve to a directory) still appear in the inventory with just
63+
``{'enabled': True}`` so we don't silently drop them.
5064
"""
51-
merged_mcp: dict = {}
5265
enriched: dict = {}
5366

5467
for plugin_key, settings in plugin_entries.items():
@@ -66,8 +79,7 @@ def walk_enabled_plugins(
6679
if plugin_dir is None:
6780
continue
6881

69-
plugin_fields, servers = read_plugin(plugin_dir)
82+
plugin_fields, _ = read_plugin(plugin_dir)
7083
entry.update(plugin_fields)
71-
merged_mcp.update(servers)
7284

73-
return merged_mcp, enriched
85+
return enriched

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,16 @@ def get_user_email(self) -> Optional[str]:
167167
"""
168168
return None
169169

170-
def get_session_context(self) -> tuple[dict, dict]:
171-
"""Return ``(mcp_servers, enabled_plugins)`` for session-context reporting.
170+
def get_session_context(self) -> tuple[Optional[dict], dict]:
171+
"""Return ``(global_config_file, enabled_plugins)`` for session-context reporting.
172172
173-
Default: empty dicts (no plugin system, no discoverable MCP config).
173+
``global_config_file`` is the IDE's global (non-plugin) MCP config as
174+
``{"path": <full path>, "content": <normalized {"mcpServers": ...} JSON>}``,
175+
or ``None`` when there is no global MCP config. ``enabled_plugins`` maps each
176+
enabled plugin key to its metadata (including its own ``mcp_config_file``
177+
content and ``mcp_config_file_path``).
178+
179+
Default: ``(None, {})`` (no plugin system, no discoverable MCP config).
174180
Override to surface MCP/plugin inventory.
175181
"""
176-
return {}, {}
182+
return None, {}

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

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
from typing import ClassVar, Optional
88

99
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
10-
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins
10+
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
11+
build_global_config_file,
12+
load_plugin_json,
13+
walk_enabled_plugins,
14+
)
1115
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
1216
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
1317
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -184,14 +188,17 @@ def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]:
184188
if field in manifest:
185189
entry[field] = manifest[field]
186190

187-
mcp_config = load_plugin_json(plugin_dir / '.mcp.json') or {}
191+
mcp_config_path = plugin_dir / '.mcp.json'
192+
mcp_config = load_plugin_json(mcp_config_path) or {}
188193
servers: dict = mcp_config.get('mcpServers') or {}
189194
if servers:
190195
entry['mcp_server_names'] = list(servers.keys())
196+
entry['mcp_config_file_path'] = str(mcp_config_path)
197+
entry['mcp_config_file'] = json.dumps(mcp_config)
191198
return entry, servers
192199

193200

194-
def resolve_plugins(settings: dict) -> tuple[dict, dict]:
201+
def resolve_plugins(settings: dict) -> dict:
195202
"""Walk Claude Code's ``enabledPlugins`` via the shared plugin walker.
196203
197204
Each enabled plugin's marketplace is resolved through
@@ -354,15 +361,11 @@ def get_user_email(self) -> Optional[str]:
354361
config = load_claude_config()
355362
return _email_from_config(config) if config else None
356363

357-
def get_session_context(self) -> tuple[dict, dict]:
364+
def get_session_context(self) -> tuple[Optional[dict], dict]:
358365
config = load_claude_config()
359-
mcp_servers: dict = dict(get_mcp_servers(config) or {}) if config else {}
366+
global_config_file = build_global_config_file(_CLAUDE_CONFIG_PATH, get_mcp_servers(config)) if config else None
360367

361368
settings = load_claude_settings()
362-
if settings:
363-
plugin_mcp, enriched_plugins = resolve_plugins(settings)
364-
mcp_servers.update(plugin_mcp)
365-
else:
366-
enriched_plugins = {}
369+
enriched_plugins = resolve_plugins(settings) if settings else {}
367370

368-
return mcp_servers, enriched_plugins
371+
return global_config_file, enriched_plugins

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

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
import tomli as tomllib
1515

1616
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
17-
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins
17+
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
18+
build_global_config_file,
19+
load_plugin_json,
20+
walk_enabled_plugins,
21+
)
1822
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
1923
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
2024
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -129,16 +133,19 @@ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]:
129133
mcp_ref = manifest.get('mcpServers')
130134
if not mcp_ref:
131135
return entry, {}
132-
mcp_doc = load_plugin_json(plugin_dir / mcp_ref) or {}
136+
mcp_config_path = plugin_dir / mcp_ref
137+
mcp_doc = load_plugin_json(mcp_config_path) or {}
133138
servers = mcp_doc.get('mcpServers', mcp_doc)
134139
if not isinstance(servers, dict):
135140
servers = {}
136141
if servers:
137142
entry['mcp_server_names'] = list(servers.keys())
143+
entry['mcp_config_file_path'] = str(mcp_config_path)
144+
entry['mcp_config_file'] = json.dumps(mcp_doc)
138145
return entry, servers
139146

140147

141-
def _resolve_codex_plugins(config: dict) -> tuple[dict, dict]:
148+
def _resolve_codex_plugins(config: dict) -> dict:
142149
"""Walk enabled ``[plugins."<plugin>@<marketplace>"]`` entries."""
143150
return walk_enabled_plugins(
144151
plugin_entries=config.get('plugins') or {},
@@ -297,13 +304,14 @@ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
297304
def get_user_email(self) -> Optional[str]:
298305
return _email_from_auth()
299306

300-
def get_session_context(self) -> tuple[dict, dict]:
307+
def get_session_context(self) -> tuple[Optional[dict], dict]:
301308
config = _load_codex_config()
302309
if not config:
303-
return {}, {}
304-
# Codex stores MCP servers under `[mcp_servers.<name>]`. Plugin-contributed
305-
# servers (via `[plugins."<plugin>@<marketplace>"]`) merge on top.
306-
mcp_servers: dict = dict(config.get('mcp_servers') or {})
307-
plugin_mcp, enriched_plugins = _resolve_codex_plugins(config)
308-
mcp_servers.update(plugin_mcp)
309-
return mcp_servers, enriched_plugins
310+
return None, {}
311+
# Codex stores MCP servers under `[mcp_servers.<name>]`; the global config
312+
# file becomes its own session-context file. Plugins (via
313+
# `[plugins."<plugin>@<marketplace>"]`) carry their own config files.
314+
config_path = _codex_config_toml_path('user')
315+
global_config_file = build_global_config_file(config_path, config.get('mcp_servers'))
316+
enriched_plugins = _resolve_codex_plugins(config)
317+
return global_config_file, enriched_plugins

cycode/cli/apps/ai_guardrails/ides/cursor.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import ClassVar, Optional
77

88
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
910
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
1011
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
1112
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -39,9 +40,14 @@ def _user_hooks_dir() -> Path:
3940
return Path.home() / '.config' / 'Cursor'
4041

4142

43+
def _cursor_mcp_config_path() -> Path:
44+
"""User-scope Cursor MCP config path (``~/.cursor/mcp.json``, all platforms)."""
45+
return Path.home() / '.cursor' / _MCP_CONFIG_FILENAME
46+
47+
4248
def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]:
4349
"""Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid."""
44-
path = config_path or (Path.home() / '.cursor' / _MCP_CONFIG_FILENAME)
50+
path = config_path or _cursor_mcp_config_path()
4551
if not path.exists():
4652
logger.debug('Cursor MCP config file not found, %s', {'path': str(path)})
4753
return None
@@ -113,7 +119,10 @@ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
113119
ide_version=raw_payload.get('cursor_version'),
114120
)
115121

116-
def get_session_context(self) -> tuple[dict, dict]:
122+
def get_session_context(self) -> tuple[Optional[dict], dict]:
117123
config = _load_cursor_mcp_config()
118-
mcp_servers = dict((config or {}).get('mcpServers') or {}) if config else {}
119-
return mcp_servers, {}
124+
if not config:
125+
return None, {}
126+
config_path = _cursor_mcp_config_path()
127+
global_config_file = build_global_config_file(config_path, config.get('mcpServers'))
128+
return global_config_file, {}

cycode/cli/apps/ai_guardrails/session_start_command.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Handle AI guardrails session start: auth, conversation creation, session context."""
22

3+
import os
4+
import platform
5+
import socket
36
import sys
47
from typing import TYPE_CHECKING, Annotated, Optional
58

@@ -20,14 +23,25 @@
2023
logger = get_logger('AI Guardrails')
2124

2225

26+
def _get_logged_in_user() -> Optional[str]:
27+
"""Best-effort OS account name (whoami). None if it can't be resolved."""
28+
try:
29+
return os.getlogin()
30+
except Exception:
31+
return None
32+
33+
2334
def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None:
2435
"""Report IDE session context to the AI security manager. Never raises."""
2536
try:
26-
mcp_servers, enabled_plugins = ide.get_session_context()
27-
if not mcp_servers and not enabled_plugins:
37+
global_config_file, enabled_plugins = ide.get_session_context()
38+
if not global_config_file and not enabled_plugins:
2839
return
2940
ai_client.report_session_context(
30-
mcp_servers=mcp_servers,
41+
hostname=socket.gethostname(),
42+
platform=platform.system(),
43+
logged_in_user=_get_logged_in_user(),
44+
global_config_file=global_config_file,
3145
enabled_plugins=enabled_plugins,
3246
user_email=user_email,
3347
)

cycode/cyclient/ai_security_manager_client.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,21 @@ def create_event(
9393

9494
def report_session_context(
9595
self,
96-
mcp_servers: Optional[dict] = None,
96+
hostname: Optional[str] = None,
97+
platform: Optional[str] = None,
98+
logged_in_user: Optional[str] = None,
99+
global_config_file: Optional[dict] = None,
97100
enabled_plugins: Optional[dict] = None,
98101
user_email: Optional[str] = None,
99102
) -> None:
100103
"""Report session context to the backend."""
101104
body: dict = {
102-
'mcp_servers': mcp_servers,
103-
'enabled_plugins': enabled_plugins,
105+
'hostname': hostname,
106+
'platform': platform,
107+
'logged_in_user': logged_in_user,
104108
'user_email': user_email,
109+
'global_config_file': global_config_file,
110+
'enabled_plugins': enabled_plugins,
105111
}
106112

107113
try:

tests/cli/commands/ai_guardrails/ides/test_claude_code.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88
from pytest_mock import MockerFixture
99

1010
from cycode.cli.apps.ai_guardrails.ides.base import HookDecision
11-
from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode, _email_from_config, load_claude_config
11+
from cycode.cli.apps.ai_guardrails.ides.claude_code import (
12+
ClaudeCode,
13+
_email_from_config,
14+
_read_claude_plugin,
15+
load_claude_config,
16+
)
1217
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
1318

1419

@@ -214,6 +219,37 @@ def test_email_none_when_no_oauth(mocker: MockerFixture) -> None:
214219
assert unified.ide_user_email is None
215220

216221

222+
# _read_claude_plugin
223+
224+
225+
def test_read_claude_plugin_includes_mcp_config_file(tmp_path: Path) -> None:
226+
mcp_content = {'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}}
227+
(tmp_path / '.mcp.json').write_text(json.dumps(mcp_content))
228+
229+
entry, servers = _read_claude_plugin(tmp_path)
230+
231+
assert 'mcp_config_file' in entry
232+
assert json.loads(entry['mcp_config_file']) == mcp_content
233+
assert entry['mcp_config_file_path'] == str(tmp_path / '.mcp.json')
234+
assert servers == mcp_content['mcpServers']
235+
236+
237+
def test_read_claude_plugin_no_mcp_config_file_when_no_servers(tmp_path: Path) -> None:
238+
(tmp_path / '.mcp.json').write_text(json.dumps({'mcpServers': {}}))
239+
240+
entry, servers = _read_claude_plugin(tmp_path)
241+
242+
assert 'mcp_config_file' not in entry
243+
assert servers == {}
244+
245+
246+
def test_read_claude_plugin_no_mcp_config_file_when_missing(tmp_path: Path) -> None:
247+
entry, servers = _read_claude_plugin(tmp_path)
248+
249+
assert 'mcp_config_file' not in entry
250+
assert servers == {}
251+
252+
217253
# Session context
218254

219255

@@ -222,8 +258,8 @@ def test_session_context_no_config() -> None:
222258
patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', return_value=None),
223259
patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_settings', return_value=None),
224260
):
225-
servers, plugins = ClaudeCode().get_session_context()
226-
assert servers == {}
261+
global_config_file, plugins = ClaudeCode().get_session_context()
262+
assert global_config_file is None
227263
assert plugins == {}
228264

229265

0 commit comments

Comments
 (0)