diff --git a/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py index 186dd37f..85bf091f 100644 --- a/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +++ b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py @@ -26,6 +26,19 @@ def load_plugin_json(path: Path) -> Optional[dict]: return None +def build_global_config_file(path: Path, mcp_servers: Optional[dict]) -> Optional[dict]: + """Wrap a global (non-plugin) MCP config into the session-context file shape. + + Returns ``{"path": , "content": <{"mcpServers": ...} JSON>}`` when + there are servers, else ``None``. ``content`` is normalized to the canonical + ``{"mcpServers": {...}}`` shape, dropping everything else in the source file. + """ + servers = mcp_servers or {} + if not servers: + return None + return {'path': str(path), 'content': json.dumps({'mcpServers': servers})} + + def walk_enabled_plugins( plugin_entries: dict[str, Any], is_enabled: Callable[[Any], bool], diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py index 92065590..28971db9 100644 --- a/cycode/cli/apps/ai_guardrails/ides/base.py +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -167,10 +167,16 @@ def get_user_email(self) -> Optional[str]: """ return None - def get_session_context(self) -> tuple[dict, dict]: - """Return ``(mcp_servers, enabled_plugins)`` for session-context reporting. + def get_session_context(self) -> tuple[Optional[dict], dict]: + """Return ``(global_config_file, enabled_plugins)`` for session-context reporting. - Default: empty dicts (no plugin system, no discoverable MCP config). + ``global_config_file`` is the IDE's global (non-plugin) MCP config as + ``{"path": , "content": }``, + or ``None`` when there is no global MCP config. ``enabled_plugins`` maps each + enabled plugin key to its metadata (including its own ``mcp_config_file`` + content and ``mcp_config_file_path``). + + Default: ``(None, {})`` (no plugin system, no discoverable MCP config). Override to surface MCP/plugin inventory. """ - return {}, {} + return None, {} diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index a5a9c079..968c8f28 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -7,7 +7,11 @@ from typing import ClassVar, Optional from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND -from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( + build_global_config_file, + load_plugin_json, + walk_enabled_plugins, +) from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -184,10 +188,13 @@ def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]: if field in manifest: entry[field] = manifest[field] - mcp_config = load_plugin_json(plugin_dir / '.mcp.json') or {} + mcp_config_path = plugin_dir / '.mcp.json' + mcp_config = load_plugin_json(mcp_config_path) or {} servers: dict = mcp_config.get('mcpServers') or {} if servers: entry['mcp_server_names'] = list(servers.keys()) + entry['mcp_config_file_path'] = str(mcp_config_path) + entry['mcp_config_file'] = json.dumps(mcp_config) return entry, servers @@ -354,15 +361,14 @@ def get_user_email(self) -> Optional[str]: config = load_claude_config() return _email_from_config(config) if config else None - def get_session_context(self) -> tuple[dict, dict]: + def get_session_context(self) -> tuple[Optional[dict], dict]: config = load_claude_config() - mcp_servers: dict = dict(get_mcp_servers(config) or {}) if config else {} + global_config_file = build_global_config_file(_CLAUDE_CONFIG_PATH, get_mcp_servers(config)) if config else None settings = load_claude_settings() if settings: - plugin_mcp, enriched_plugins = resolve_plugins(settings) - mcp_servers.update(plugin_mcp) + _, enriched_plugins = resolve_plugins(settings) else: enriched_plugins = {} - return mcp_servers, enriched_plugins + return global_config_file, enriched_plugins diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py index 8be9f20a..6e1a5fe9 100644 --- a/cycode/cli/apps/ai_guardrails/ides/codex.py +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -14,7 +14,11 @@ import tomli as tomllib from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND -from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( + build_global_config_file, + load_plugin_json, + walk_enabled_plugins, +) from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -129,12 +133,15 @@ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]: mcp_ref = manifest.get('mcpServers') if not mcp_ref: return entry, {} - mcp_doc = load_plugin_json(plugin_dir / mcp_ref) or {} + mcp_config_path = plugin_dir / mcp_ref + mcp_doc = load_plugin_json(mcp_config_path) or {} servers = mcp_doc.get('mcpServers', mcp_doc) if not isinstance(servers, dict): servers = {} if servers: entry['mcp_server_names'] = list(servers.keys()) + entry['mcp_config_file_path'] = str(mcp_config_path) + entry['mcp_config_file'] = json.dumps(mcp_doc) return entry, servers @@ -297,13 +304,14 @@ def build_session_payload(self, raw_payload: dict) -> AIHookPayload: def get_user_email(self) -> Optional[str]: return _email_from_auth() - def get_session_context(self) -> tuple[dict, dict]: + def get_session_context(self) -> tuple[Optional[dict], dict]: config = _load_codex_config() if not config: - return {}, {} - # Codex stores MCP servers under `[mcp_servers.]`. Plugin-contributed - # servers (via `[plugins."@"]`) merge on top. - mcp_servers: dict = dict(config.get('mcp_servers') or {}) - plugin_mcp, enriched_plugins = _resolve_codex_plugins(config) - mcp_servers.update(plugin_mcp) - return mcp_servers, enriched_plugins + return None, {} + # Codex stores MCP servers under `[mcp_servers.]`; the global config + # file becomes its own session-context file. Plugins (via + # `[plugins."@"]`) carry their own config files. + config_path = _codex_home() / _CONFIG_TOML_NAME + global_config_file = build_global_config_file(config_path, config.get('mcp_servers')) + _, enriched_plugins = _resolve_codex_plugins(config) + return global_config_file, enriched_plugins diff --git a/cycode/cli/apps/ai_guardrails/ides/cursor.py b/cycode/cli/apps/ai_guardrails/ides/cursor.py index aa218542..411bfcbd 100644 --- a/cycode/cli/apps/ai_guardrails/ides/cursor.py +++ b/cycode/cli/apps/ai_guardrails/ides/cursor.py @@ -6,6 +6,7 @@ from typing import ClassVar, Optional from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import build_global_config_file from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -113,7 +114,10 @@ def build_session_payload(self, raw_payload: dict) -> AIHookPayload: ide_version=raw_payload.get('cursor_version'), ) - def get_session_context(self) -> tuple[dict, dict]: + def get_session_context(self) -> tuple[Optional[dict], dict]: config = _load_cursor_mcp_config() - mcp_servers = dict((config or {}).get('mcpServers') or {}) if config else {} - return mcp_servers, {} + if not config: + return None, {} + config_path = Path.home() / '.cursor' / _MCP_CONFIG_FILENAME + global_config_file = build_global_config_file(config_path, config.get('mcpServers')) + return global_config_file, {} diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index cda53c62..0fed9738 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -1,5 +1,8 @@ """Handle AI guardrails session start: auth, conversation creation, session context.""" +import getpass +import platform +import socket import sys from typing import TYPE_CHECKING, Annotated, Optional @@ -20,14 +23,25 @@ logger = get_logger('AI Guardrails') +def _get_logged_in_user() -> Optional[str]: + """Best-effort OS account name (whoami). None if it can't be resolved.""" + try: + return getpass.getuser() + except Exception: + return None + + def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None: """Report IDE session context to the AI security manager. Never raises.""" try: - mcp_servers, enabled_plugins = ide.get_session_context() - if not mcp_servers and not enabled_plugins: + global_config_file, enabled_plugins = ide.get_session_context() + if not global_config_file and not enabled_plugins: return ai_client.report_session_context( - mcp_servers=mcp_servers, + hostname=socket.gethostname(), + platform=platform.system(), + logged_in_user=_get_logged_in_user(), + global_config_file=global_config_file, enabled_plugins=enabled_plugins, user_email=user_email, ) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index f9b7b124..19955410 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -93,15 +93,21 @@ def create_event( def report_session_context( self, - mcp_servers: Optional[dict] = None, + hostname: Optional[str] = None, + platform: Optional[str] = None, + logged_in_user: Optional[str] = None, + global_config_file: Optional[dict] = None, enabled_plugins: Optional[dict] = None, user_email: Optional[str] = None, ) -> None: """Report session context to the backend.""" body: dict = { - 'mcp_servers': mcp_servers, - 'enabled_plugins': enabled_plugins, + 'hostname': hostname, + 'platform': platform, + 'logged_in_user': logged_in_user, 'user_email': user_email, + 'global_config_file': global_config_file, + 'enabled_plugins': enabled_plugins, } try: diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py index f997abe3..d1e28c0b 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -8,7 +8,12 @@ from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.ides.base import HookDecision -from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode, _email_from_config, load_claude_config +from cycode.cli.apps.ai_guardrails.ides.claude_code import ( + ClaudeCode, + _email_from_config, + _read_claude_plugin, + load_claude_config, +) from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -214,6 +219,37 @@ def test_email_none_when_no_oauth(mocker: MockerFixture) -> None: assert unified.ide_user_email is None +# _read_claude_plugin + + +def test_read_claude_plugin_includes_mcp_config_file(tmp_path: Path) -> None: + mcp_content = {'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}} + (tmp_path / '.mcp.json').write_text(json.dumps(mcp_content)) + + entry, servers = _read_claude_plugin(tmp_path) + + assert 'mcp_config_file' in entry + assert json.loads(entry['mcp_config_file']) == mcp_content + assert entry['mcp_config_file_path'] == str(tmp_path / '.mcp.json') + assert servers == mcp_content['mcpServers'] + + +def test_read_claude_plugin_no_mcp_config_file_when_no_servers(tmp_path: Path) -> None: + (tmp_path / '.mcp.json').write_text(json.dumps({'mcpServers': {}})) + + entry, servers = _read_claude_plugin(tmp_path) + + assert 'mcp_config_file' not in entry + assert servers == {} + + +def test_read_claude_plugin_no_mcp_config_file_when_missing(tmp_path: Path) -> None: + entry, servers = _read_claude_plugin(tmp_path) + + assert 'mcp_config_file' not in entry + assert servers == {} + + # Session context @@ -222,8 +258,8 @@ def test_session_context_no_config() -> None: patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', return_value=None), patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_settings', return_value=None), ): - servers, plugins = ClaudeCode().get_session_context() - assert servers == {} + global_config_file, plugins = ClaudeCode().get_session_context() + assert global_config_file is None assert plugins == {} diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py index 5b656364..285b889c 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_codex.py +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -16,6 +16,7 @@ _email_from_auth, _enable_codex_hooks_feature, _load_codex_config, + _read_codex_plugin, ) from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -324,13 +325,60 @@ def test_session_context_reads_mcp_servers() -> None: 'cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config', return_value={'mcp_servers': mcp}, ): - servers, plugins = Codex().get_session_context() - assert servers == mcp + global_config_file, plugins = Codex().get_session_context() + assert global_config_file is not None + assert global_config_file['path'].endswith('config.toml') + assert global_config_file['content'] == json.dumps({'mcpServers': mcp}) assert plugins == {} def test_session_context_no_config() -> None: with patch('cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config', return_value=None): - servers, plugins = Codex().get_session_context() - assert servers == {} + global_config_file, plugins = Codex().get_session_context() + assert global_config_file is None assert plugins == {} + + +def _write_codex_plugin(plugin_dir: Path, mcp_doc: dict) -> None: + """Lay out a Codex plugin: manifest referencing .mcp.json + the MCP file itself.""" + (plugin_dir / '.codex-plugin').mkdir(parents=True, exist_ok=True) + (plugin_dir / '.codex-plugin' / 'plugin.json').write_text(json.dumps({'name': 'demo', 'mcpServers': '.mcp.json'})) + (plugin_dir / '.mcp.json').write_text(json.dumps(mcp_doc)) + + +def test_read_codex_plugin_includes_mcp_config_file(tmp_path: Path) -> None: + mcp_content = {'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}} + _write_codex_plugin(tmp_path, mcp_content) + + entry, servers = _read_codex_plugin(tmp_path) + + assert json.loads(entry['mcp_config_file']) == mcp_content + assert entry['mcp_config_file_path'] == str(tmp_path / '.mcp.json') + assert servers == mcp_content['mcpServers'] + + +def test_read_codex_plugin_mcp_config_file_bare_map(tmp_path: Path) -> None: + # Codex MCP files may be a bare {name: cfg} map with no mcpServers wrapper. + mcp_content = {'dummy-server': {'command': 'dummy-command'}} + _write_codex_plugin(tmp_path, mcp_content) + + entry, servers = _read_codex_plugin(tmp_path) + + assert json.loads(entry['mcp_config_file']) == mcp_content + assert servers == mcp_content + + +def test_read_codex_plugin_no_mcp_config_file_when_no_servers(tmp_path: Path) -> None: + _write_codex_plugin(tmp_path, {'mcpServers': {}}) + + entry, servers = _read_codex_plugin(tmp_path) + + assert 'mcp_config_file' not in entry + assert servers == {} + + +def test_read_codex_plugin_no_mcp_config_file_when_no_manifest(tmp_path: Path) -> None: + entry, servers = _read_codex_plugin(tmp_path) + + assert 'mcp_config_file' not in entry + assert servers == {} diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py index 7d7a5427..3bdbc19d 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_contract.py +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -105,9 +105,12 @@ def test_build_session_payload_tags_ide(ide: IDE) -> None: def test_get_session_context_returns_pair(ide: IDE) -> None: - """Session context must always be a ``(mcp_servers, plugins)`` 2-tuple of dicts.""" - mcp_servers, plugins = ide.get_session_context() - assert isinstance(mcp_servers, dict) + """Session context must be a ``(global_config_file, plugins)`` pair. + + ``global_config_file`` is ``None`` or a ``{"path", "content"}`` dict; ``plugins`` is a dict. + """ + global_config_file, plugins = ide.get_session_context() + assert global_config_file is None or isinstance(global_config_file, dict) assert isinstance(plugins, dict) diff --git a/tests/cli/commands/ai_guardrails/ides/test_cursor.py b/tests/cli/commands/ai_guardrails/ides/test_cursor.py index bb058f6f..4d082d3a 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_cursor.py +++ b/tests/cli/commands/ai_guardrails/ides/test_cursor.py @@ -133,22 +133,23 @@ def test_session_payload_carries_cursor_fields() -> None: assert session.ide_provider == 'cursor' -def test_session_context_loads_mcp_servers(tmp_path: Path) -> None: - """Cursor reads MCP servers from ~/.cursor/mcp.json.""" +def test_session_context_loads_mcp_servers() -> None: + """Cursor wraps ~/.cursor/mcp.json into a global_config_file.""" mcp_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} - config_path = tmp_path / 'mcp.json' - config_path.write_text(json.dumps({'mcpServers': mcp_servers})) with patch('cycode.cli.apps.ai_guardrails.ides.cursor._load_cursor_mcp_config') as load: load.return_value = {'mcpServers': mcp_servers} - servers, plugins = Cursor().get_session_context() + global_config_file, plugins = Cursor().get_session_context() - assert servers == mcp_servers + assert global_config_file == { + 'path': str(Path.home() / '.cursor' / 'mcp.json'), + 'content': json.dumps({'mcpServers': mcp_servers}), + } assert plugins == {} def test_session_context_no_config_returns_empty() -> None: with patch('cycode.cli.apps.ai_guardrails.ides.cursor._load_cursor_mcp_config', return_value=None): - servers, plugins = Cursor().get_session_context() - assert servers == {} + global_config_file, plugins = Cursor().get_session_context() + assert global_config_file is None assert plugins == {} diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py index 0ae57226..d048c8b3 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -3,7 +3,7 @@ import json from io import StringIO from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch import pytest import typer @@ -234,7 +234,13 @@ def test_claude_code_reports_mcp_servers( session_start_command(mock_ctx, ide='claude-code') mock_ai_client.report_session_context.assert_called_once_with( - mcp_servers=mcp_servers, + hostname=ANY, + platform=ANY, + logged_in_user=ANY, + global_config_file={ + 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), + 'content': json.dumps({'mcpServers': mcp_servers}), + }, enabled_plugins={'cycode-dev@cycode-marketplace': {'enabled': True}}, user_email='test@test.com', ) @@ -244,7 +250,7 @@ def test_claude_code_reports_mcp_servers( @patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_claude_code_merges_plugin_mcp_servers_and_metadata( +def test_claude_code_reports_global_file_and_plugin_metadata( mock_get_auth: MagicMock, mock_get_client: MagicMock, mock_load_config: MagicMock, @@ -252,8 +258,8 @@ def test_claude_code_merges_plugin_mcp_servers_and_metadata( mock_ctx: MagicMock, tmp_path: Path, ) -> None: - """Plugin MCP servers from /.mcp.json should merge into mcp_servers, - and plugin metadata from .claude-plugin/plugin.json should enrich enabled_plugins.""" + """The global config file carries only the global MCP servers; the plugin's own + .mcp.json content + path + metadata enrich enabled_plugins (no merge into the global).""" mock_get_auth.return_value = MagicMock() mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client @@ -282,10 +288,14 @@ def test_claude_code_merges_plugin_mcp_servers_and_metadata( with patch('sys.stdin', new=StringIO(json.dumps(payload))): session_start_command(mock_ctx, ide='claude-code') + plugin_mcp = {'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}} mock_ai_client.report_session_context.assert_called_once_with( - mcp_servers={ - 'gitlab': {'command': 'npx'}, - 'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}, + hostname=ANY, + platform=ANY, + logged_in_user=ANY, + global_config_file={ + 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), + 'content': json.dumps({'mcpServers': user_mcp_servers}), }, enabled_plugins={ 'cycode-dev@cycode-marketplace': { @@ -294,6 +304,8 @@ def test_claude_code_merges_plugin_mcp_servers_and_metadata( 'version': '1.0.28', 'description': 'Shared skills', 'mcp_server_names': ['aspire'], + 'mcp_config_file_path': str(plugin_dir / '.mcp.json'), + 'mcp_config_file': json.dumps(plugin_mcp), } }, user_email=None, @@ -348,7 +360,15 @@ def test_cursor_reports_mcp_servers( session_start_command(mock_ctx, ide='cursor') mock_ai_client.report_session_context.assert_called_once_with( - mcp_servers=mcp_servers, enabled_plugins={}, user_email=None + hostname=ANY, + platform=ANY, + logged_in_user=ANY, + global_config_file={ + 'path': str(Path.home() / '.cursor' / 'mcp.json'), + 'content': json.dumps({'mcpServers': mcp_servers}), + }, + enabled_plugins={}, + user_email=None, )