diff --git a/cycode/cli/apps/ai_guardrails/ides/__init__.py b/cycode/cli/apps/ai_guardrails/ides/__init__.py index e598c5a5..127431ef 100644 --- a/cycode/cli/apps/ai_guardrails/ides/__init__.py +++ b/cycode/cli/apps/ai_guardrails/ides/__init__.py @@ -34,6 +34,25 @@ def get_ide(name: str) -> IDE: return ide +def collect_all_session_contexts() -> tuple[dict[str, dict], dict]: + """Sweep every registered IDE's session context, regardless of which IDE triggered the hook. + + Returns ``(config_files_by_ide, plugins)``: the global MCP config file of each IDE that has + one (keyed by IDE name), and the enabled plugins merged across IDEs (first registered IDE + wins on a duplicate plugin key - plugins are IDE-agnostic marketplace artifacts). + """ + config_files_by_ide: dict[str, dict] = {} + plugins: dict = {} + for ide in IDES.values(): + global_config_file, enabled_plugins = ide.get_session_context() + if global_config_file: + config_files_by_ide[ide.name] = global_config_file + for plugin_key, plugin in (enabled_plugins or {}).items(): + plugins.setdefault(plugin_key, plugin) + + return config_files_by_ide, plugins + + def resolve_ides(name: str) -> list[IDE]: """Resolve an ``--ide`` argument to one or all IDE instances. diff --git a/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py index 124fdc99..6f7d8917 100644 --- a/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +++ b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py @@ -15,6 +15,22 @@ logger = get_logger('AI Guardrails Plugins') +def resolve_cached_plugin_dir(cache_root: Path, marketplace: str, plugin_name: str) -> Optional[Path]: + """Find ``////``. + + Both Claude Code and Codex cache installed plugin content in this layout (the trailing + segment is a version for Claude, a content hash for Codex). If multiple are cached, pick + the most recently modified (name as a deterministic tie-breaker). + """ + base = cache_root / marketplace / plugin_name + if not base.is_dir(): + return None + candidates = [d for d in base.iterdir() if d.is_dir()] + if not candidates: + return None + return max(candidates, key=lambda d: (d.stat().st_mtime, d.name)) + + def load_plugin_json(path: Path) -> Optional[dict]: """Load a JSON file inside a plugin directory; None if missing or invalid.""" if not path.exists(): diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 06989d87..17e7563d 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -10,6 +10,7 @@ from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( build_global_config_file, load_plugin_json, + resolve_cached_plugin_dir, walk_enabled_plugins, ) from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision @@ -164,6 +165,11 @@ def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict] return None +def _plugins_cache_dir() -> Path: + """Claude Code's local plugin content cache: ``~/.claude/plugins/cache////``.""" + return Path.home() / '.claude' / 'plugins' / 'cache' + + def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]: """Resolve filesystem path for a directory-type marketplace.""" source = marketplace.get('source', {}) @@ -194,25 +200,29 @@ def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]: 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) + entry['mcp_config_file'] = json.dumps({'mcpServers': servers}) return entry, servers def resolve_plugins(settings: dict) -> dict: """Walk Claude Code's ``enabledPlugins`` via the shared plugin walker. - Each enabled plugin's marketplace is resolved through - ``extraKnownMarketplaces`` to a directory; the rest of the work - (manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``. + Directory-type marketplaces resolve through ``extraKnownMarketplaces``; all + other source types (git, github, ...) resolve through the local plugin cache. + The rest of the work (manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``. """ enabled = settings.get('enabledPlugins') or {} marketplaces = settings.get('extraKnownMarketplaces') or {} - def _locate(_plugin_name: str, marketplace_name: str) -> Optional[Path]: + def _locate(plugin_name: str, marketplace_name: str) -> Optional[Path]: + # Directory-type marketplaces point straight at the plugin source; every other source + # type (git, github, ...) is cloned into the local plugin cache. marketplace = marketplaces.get(marketplace_name) - if not marketplace: - return None - return _resolve_marketplace_path(marketplace) + if marketplace: + marketplace_path = _resolve_marketplace_path(marketplace) + if marketplace_path is not None: + return marketplace_path + return resolve_cached_plugin_dir(_plugins_cache_dir(), marketplace_name, plugin_name) return walk_enabled_plugins( plugin_entries=enabled, diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py index 2bfd70dc..e8049621 100644 --- a/cycode/cli/apps/ai_guardrails/ides/codex.py +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -17,6 +17,7 @@ from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( build_global_config_file, load_plugin_json, + resolve_cached_plugin_dir, walk_enabled_plugins, ) from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision @@ -100,18 +101,8 @@ def _email_from_auth(auth_path: Optional[Path] = None) -> Optional[str]: def _resolve_codex_plugin_dir(plugin_name: str, marketplace: str) -> Optional[Path]: - """Find ``~/.codex/plugins/cache////``. - - The trailing segment is a content hash. If multiple are cached, pick the - most recently modified. - """ - base = _codex_home() / 'plugins' / 'cache' / marketplace / plugin_name - if not base.is_dir(): - return None - candidates = [d for d in base.iterdir() if d.is_dir()] - if not candidates: - return None - return max(candidates, key=lambda d: d.stat().st_mtime) + """Find ``~/.codex/plugins/cache////``.""" + return resolve_cached_plugin_dir(_codex_home() / 'plugins' / 'cache', marketplace, plugin_name) def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]: @@ -141,7 +132,7 @@ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]: 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) + entry['mcp_config_file'] = json.dumps({'mcpServers': servers}) return entry, servers diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index c7164c79..bebd421d 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -1,12 +1,15 @@ """Handle AI guardrails session start: auth, conversation creation, session context.""" +import hashlib +import json import sys +import time +from pathlib import Path from typing import TYPE_CHECKING, Annotated, Optional import typer -from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide -from cycode.cli.apps.ai_guardrails.ides.base import IDE +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, collect_all_session_contexts, get_ide from cycode.cli.apps.ai_guardrails.scan.utils import read_stdin_text, safe_json_parse from cycode.cli.apps.auth.auth_common import get_authorization_info from cycode.cli.apps.auth.auth_manager import AuthManager @@ -26,23 +29,76 @@ logger = get_logger('AI Guardrails') +_SESSION_CONTEXT_CACHE_FILE = '.session-context-cache' +_SESSION_CONTEXT_TTL_SECONDS = 7 * 24 * 60 * 60 -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.""" + +def _session_context_cache_path() -> Path: + return Path.home() / '.cycode' / _SESSION_CONTEXT_CACHE_FILE + + +def _session_context_digest(report: dict) -> str: + """Deterministic hash of the outgoing payload (not the raw config files, which churn).""" + canonical = json.dumps(report, sort_keys=True, separators=(',', ':'), default=str) + return hashlib.sha256(canonical.encode('utf-8')).hexdigest() + + +def _should_skip_report(digest: str, tenant_id: Optional[str]) -> bool: + """Skip when the same payload was already sent for this tenant and the TTL hasn't expired.""" try: - global_config_file, enabled_plugins = ide.get_session_context() - if not global_config_file and not enabled_plugins: - return - ai_client.report_session_context( - hostname=get_hostname(), - platform_name=get_platform_name(), - os_version=get_os_version(), - serial_number=get_serial_number(), - last_login_user=get_last_login_user(), - global_config_file=global_config_file, - enabled_plugins=enabled_plugins, - user_email=user_email, + cache = json.loads(_session_context_cache_path().read_text(encoding='utf-8')) + return ( + cache.get('hash') == digest + and cache.get('tenant_id') == tenant_id + and time.time() - float(cache.get('sent_at', 0)) < _SESSION_CONTEXT_TTL_SECONDS + ) + except Exception: + # Missing/corrupt cache reads as a miss - over-sending is harmless + return False + + +def _save_report_cache(digest: str, tenant_id: Optional[str]) -> None: + try: + cache_path = _session_context_cache_path() + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text( + json.dumps({'hash': digest, 'tenant_id': tenant_id, 'sent_at': time.time()}), encoding='utf-8' ) + except Exception as e: + logger.debug('Failed to write session context cache', exc_info=e) + + +def _report_session_context( + ai_client: 'AISecurityManagerClient', + user_email: Optional[str], + tenant_id: Optional[str], +) -> None: + """Report the device + cross-IDE session context to the AI security manager. Never raises. + + The device context is always reported. MCP configs are collected from every registered IDE, + not just the triggering one. Unchanged payloads are skipped via a hash cache until the TTL expires. + """ + try: + config_files_by_ide, enabled_plugins = collect_all_session_contexts() + report = { + 'hostname': get_hostname(), + 'platform_name': get_platform_name(), + 'os_version': get_os_version(), + 'serial_number': get_serial_number(), + 'last_login_user': get_last_login_user(), + # Sorted by path so the digest is stable regardless of IDE registry order. + 'config_files': sorted(config_files_by_ide.values(), key=lambda f: f['path']), + 'enabled_plugins': enabled_plugins, + 'user_email': user_email, + } + + digest = _session_context_digest(report) + if _should_skip_report(digest, tenant_id): + logger.debug('Session context unchanged; skipping report') + return + + if ai_client.report_session_context(**report): + _save_report_cache(digest, tenant_id) except Exception as e: logger.debug('Failed to report session context', exc_info=e) @@ -61,7 +117,7 @@ def session_start_command( """Handle session start: ensure auth, create conversation, report session context.""" ide_integration = get_ide(ide) - # Step 1: Ensure authentication + # Ensure authentication auth_info = get_authorization_info(ctx) if auth_info is None: logger.debug('Not authenticated, starting authentication') @@ -70,10 +126,11 @@ def session_start_command( except Exception as err: handle_auth_exception(ctx, err) return + auth_info = get_authorization_info(ctx) else: logger.debug('Already authenticated') - # Step 2: Read stdin payload (backward compat: old hooks pipe no stdin) + # Read stdin payload (backward compat: old hooks pipe no stdin) if sys.stdin.isatty(): logger.debug('No stdin payload (TTY), skipping session initialization') return @@ -84,7 +141,7 @@ def session_start_command( logger.debug('Empty or invalid stdin payload, skipping session initialization') return - # Step 3: Build session payload + initialize API client + # Build session payload + initialize API client session_payload = ide_integration.build_session_payload(payload) try: @@ -93,11 +150,11 @@ def session_start_command( logger.debug('Failed to initialize AI security client', exc_info=e) return - # Step 4: Create conversation + # Create conversation try: ai_client.create_conversation(session_payload) except Exception as e: logger.debug('Failed to create conversation during session start', exc_info=e) - # Step 5: Report session context (MCP servers, enabled plugins) - _report_session_context(ai_client, ide_integration, session_payload.ide_user_email) + # Report session context (device + cross-IDE MCP servers and plugins) + _report_session_context(ai_client, session_payload.ide_user_email, auth_info.tenant_id) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index a4f9bd76..5dee7f2c 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -98,11 +98,11 @@ def report_session_context( os_version: Optional[str] = None, serial_number: Optional[str] = None, last_login_user: Optional[str] = None, - global_config_file: Optional[dict] = None, + config_files: Optional[list[dict]] = None, enabled_plugins: Optional[dict] = None, user_email: Optional[str] = None, - ) -> None: - """Report session context to the backend.""" + ) -> bool: + """Report session context to the backend. Returns whether the report was accepted.""" body: dict = { 'hostname': hostname, 'platform_name': platform_name, @@ -110,12 +110,14 @@ def report_session_context( 'serial_number': serial_number, 'last_login_user': last_login_user, 'user_email': user_email, - 'global_config_file': global_config_file, + 'config_files': config_files, 'enabled_plugins': enabled_plugins, } try: self.client.post(self._build_endpoint_path(self._SESSION_CONTEXT_PATH), body=body) + return True except Exception as e: logger.debug('Failed to report session context', exc_info=e) # Don't fail the session if reporting fails + return False 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 d1e28c0b..dbaca44f 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -13,6 +13,7 @@ _email_from_config, _read_claude_plugin, load_claude_config, + resolve_plugins, ) from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -222,34 +223,68 @@ def test_email_none_when_no_oauth(mocker: MockerFixture) -> 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)) +def test_read_claude_plugin_includes_mcp_config_file(fs: FakeFilesystem) -> None: + plugin_dir = Path('/dummy/plugin') + mcp_content = {'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}} + fs.create_file(plugin_dir / '.mcp.json', contents=json.dumps(mcp_content)) - entry, servers = _read_claude_plugin(tmp_path) + entry, servers = _read_claude_plugin(plugin_dir) 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 entry['mcp_config_file_path'] == str(plugin_dir / '.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': {}})) +def test_read_claude_plugin_no_mcp_config_file_when_no_servers(fs: FakeFilesystem) -> None: + plugin_dir = Path('/dummy/plugin') + fs.create_file(plugin_dir / '.mcp.json', contents=json.dumps({'mcpServers': {}})) - entry, servers = _read_claude_plugin(tmp_path) + entry, servers = _read_claude_plugin(plugin_dir) 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) +def test_read_claude_plugin_no_mcp_config_file_when_missing(fs: FakeFilesystem) -> None: + plugin_dir = Path('/dummy/plugin') + fs.create_dir(plugin_dir) + + entry, servers = _read_claude_plugin(plugin_dir) assert 'mcp_config_file' not in entry assert servers == {} +# resolve_plugins + + +def test_resolve_plugins_git_marketplace_resolves_from_cache(fs: FakeFilesystem) -> None: + """Non-directory marketplaces (git/github) resolve through ~/.claude/plugins/cache.""" + plugin_dir = Path.home() / '.claude' / 'plugins' / 'cache' / 'dummy-marketplace' / 'dummy-plugin' / '1.0.1' + fs.create_file( + plugin_dir / '.claude-plugin' / 'plugin.json', + contents=json.dumps({'name': 'dummy-plugin', 'version': '1.0.1'}), + ) + fs.create_file( + plugin_dir / '.mcp.json', + contents=json.dumps({'mcpServers': {'dummy-server': {'command': 'dummy-command'}}}), + ) + + settings = { + 'enabledPlugins': {'dummy-plugin@dummy-marketplace': True}, + 'extraKnownMarketplaces': { + 'dummy-marketplace': {'source': {'source': 'git', 'url': 'git@example.com:dummy/dummy-marketplace.git'}} + }, + } + plugins = resolve_plugins(settings) + + entry = plugins['dummy-plugin@dummy-marketplace'] + assert entry['version'] == '1.0.1' + assert entry['mcp_server_names'] == ['dummy-server'] + assert entry['mcp_config_file_path'] == str(plugin_dir / '.mcp.json') + + # Session context diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py index 285b889c..682739e6 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_codex.py +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -358,13 +358,14 @@ def test_read_codex_plugin_includes_mcp_config_file(tmp_path: Path) -> None: 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. + # Codex MCP files may be a bare {name: cfg} map with no mcpServers wrapper; the serialized + # session-context content is normalized to the canonical wrapped shape. 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 json.loads(entry['mcp_config_file']) == {'mcpServers': mcp_content} assert servers == mcp_content 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 ed6708ce..6beaa615 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -9,7 +9,9 @@ import typer from cycode.cli.apps.ai_guardrails import session_start_command as _session_start_mod +from cycode.cli.apps.ai_guardrails.ides import IDES, collect_all_session_contexts from cycode.cli.apps.ai_guardrails.ides import claude_code as _claude_mod +from cycode.cli.apps.ai_guardrails.ides import codex as _codex_mod from cycode.cli.apps.ai_guardrails.ides import cursor as _cursor_mod from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command @@ -22,6 +24,12 @@ def mock_ctx() -> MagicMock: return ctx +@pytest.fixture(autouse=True) +def _isolated_session_context_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep the dedup cache away from the real ~/.cycode in every test.""" + monkeypatch.setattr(_session_start_mod, '_session_context_cache_path', lambda: tmp_path / '.session-context-cache') + + # Auth tests @@ -202,52 +210,83 @@ def test_conversation_creation_failure_non_blocking( # Should not raise -# MCP server reporting tests +# Session context reporting tests -@patch.object(_claude_mod, 'load_claude_settings') -@patch.object(_claude_mod, 'load_claude_config') +@patch.object(_claude_mod, 'load_claude_config', return_value={}) +@patch.object(_session_start_mod, 'collect_all_session_contexts') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_claude_code_reports_mcp_servers( +def test_reports_cross_ide_session_context( mock_get_auth: MagicMock, mock_get_client: MagicMock, + mock_collect: MagicMock, mock_load_config: MagicMock, - mock_load_settings: MagicMock, mock_ctx: MagicMock, ) -> None: - """Claude Code should report MCP servers from ~/.claude.json and enriched plugins.""" - mock_get_auth.return_value = MagicMock() + """All registered IDEs' configs go into config_files.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client - mcp_servers = { - 'gitlab': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-gitlab']}, - 'filesystem': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-filesystem']}, - } - mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'test@test.com'}, 'mcpServers': mcp_servers} - # Marketplace won't resolve (no extraKnownMarketplaces) so plugin gets {"enabled": True} only. - mock_load_settings.return_value = {'enabledPlugins': {'cycode-dev@cycode-marketplace': True}} + cursor_file = {'path': '/home/u/.cursor/mcp.json', 'content': '{"mcpServers": {}}'} + claude_file = {'path': '/home/u/.claude.json', 'content': '{"mcpServers": {}}'} + plugins = {'dummy-plugin@dummy-marketplace': {'enabled': True}} + mock_collect.return_value = ({'cursor': cursor_file, 'claude-code': claude_file}, plugins) payload = {'session_id': 'session-123'} with patch('sys.stdin', new=StringIO(json.dumps(payload))): session_start_command(mock_ctx, ide='claude-code') + # config_files is sorted by path for a stable digest. mock_ai_client.report_session_context.assert_called_once_with( hostname=ANY, platform_name=ANY, os_version=ANY, serial_number=ANY, last_login_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', + config_files=[claude_file, cursor_file], + enabled_plugins=plugins, + user_email=None, ) +@patch.object(_claude_mod, 'load_claude_config', return_value={}) +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_no_mcp_anywhere_still_reports_device( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_load_config: MagicMock, + mock_ctx: MagicMock, +) -> None: + """A machine with no MCP configs or plugins must still report its device context.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({}, {}) + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_ai_client.report_session_context.assert_called_once_with( + hostname=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, + config_files=[], + enabled_plugins={}, + user_email=None, + ) + + +@patch.object(_codex_mod, '_load_codex_config') +@patch.object(_cursor_mod, '_load_cursor_mcp_config') @patch.object(_claude_mod, 'load_claude_settings') @patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @@ -257,32 +296,36 @@ def test_claude_code_reports_global_file_and_plugin_metadata( mock_get_client: MagicMock, mock_load_config: MagicMock, mock_load_settings: MagicMock, + mock_load_cursor: MagicMock, + mock_load_codex: MagicMock, mock_ctx: MagicMock, tmp_path: Path, ) -> None: """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_get_auth.return_value = MagicMock(tenant_id='tenant-1') mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client + mock_load_cursor.return_value = None + mock_load_codex.return_value = None # Set up a fake plugin directory on disk. - plugin_dir = tmp_path / 'ai-prompts' + plugin_dir = tmp_path / 'dummy-plugin' plugin_dir.mkdir() (plugin_dir / '.mcp.json').write_text( - json.dumps({'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}}) + json.dumps({'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}}) ) claude_plugin_dir = plugin_dir / '.claude-plugin' claude_plugin_dir.mkdir() (claude_plugin_dir / 'plugin.json').write_text( - json.dumps({'name': 'cycode-dev', 'version': '1.0.28', 'description': 'Shared skills'}) + json.dumps({'name': 'dummy-plugin', 'version': '1.0.28', 'description': 'Dummy plugin'}) ) - user_mcp_servers = {'gitlab': {'command': 'npx'}} + user_mcp_servers = {'dummy-global': {'command': 'dummy-command'}} mock_load_config.return_value = {'mcpServers': user_mcp_servers} mock_load_settings.return_value = { - 'enabledPlugins': {'cycode-dev@cycode-marketplace': True}, - 'extraKnownMarketplaces': {'cycode-marketplace': {'source': {'source': 'directory', 'path': str(plugin_dir)}}}, + 'enabledPlugins': {'dummy-plugin@dummy-marketplace': True}, + 'extraKnownMarketplaces': {'dummy-marketplace': {'source': {'source': 'directory', 'path': str(plugin_dir)}}}, } payload = {'session_id': 'session-123'} @@ -290,24 +333,25 @@ def test_claude_code_reports_global_file_and_plugin_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']}}} + plugin_mcp = {'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}} + claude_file = { + 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), + 'content': json.dumps({'mcpServers': user_mcp_servers}), + } mock_ai_client.report_session_context.assert_called_once_with( hostname=ANY, platform_name=ANY, os_version=ANY, serial_number=ANY, last_login_user=ANY, - global_config_file={ - 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), - 'content': json.dumps({'mcpServers': user_mcp_servers}), - }, + config_files=[claude_file], enabled_plugins={ - 'cycode-dev@cycode-marketplace': { + 'dummy-plugin@dummy-marketplace': { 'enabled': True, - 'name': 'cycode-dev', + 'name': 'dummy-plugin', 'version': '1.0.28', - 'description': 'Shared skills', - 'mcp_server_names': ['aspire'], + 'description': 'Dummy plugin', + 'mcp_server_names': ['dummy-server'], 'mcp_config_file_path': str(plugin_dir / '.mcp.json'), 'mcp_config_file': json.dumps(plugin_mcp), } @@ -316,89 +360,199 @@ def test_claude_code_reports_global_file_and_plugin_metadata( ) +@patch.object(_codex_mod, '_load_codex_config') @patch.object(_claude_mod, 'load_claude_settings') @patch.object(_claude_mod, 'load_claude_config') +@patch.object(_cursor_mod, '_load_cursor_mcp_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_claude_code_no_mcp_servers_no_plugins_skips_report( +def test_cursor_trigger_sweeps_other_ides( mock_get_auth: MagicMock, mock_get_client: MagicMock, + mock_load_cursor: MagicMock, mock_load_config: MagicMock, mock_load_settings: MagicMock, + mock_load_codex: MagicMock, mock_ctx: MagicMock, ) -> None: - """When no mcpServers and no plugins, report_session_context should not be called.""" - mock_get_auth.return_value = MagicMock() + """A Cursor-triggered session start also reports Claude's config via config_files.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client - mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'test@test.com'}} + cursor_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} + claude_servers = {'gitlab': {'command': 'npx'}} + mock_load_cursor.return_value = {'mcpServers': cursor_servers} + mock_load_config.return_value = {'mcpServers': claude_servers} mock_load_settings.return_value = None + mock_load_codex.return_value = None - payload = {'session_id': 'session-123'} + payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='cursor') + cursor_file = { + 'path': str(Path.home() / '.cursor' / 'mcp.json'), + 'content': json.dumps({'mcpServers': cursor_servers}), + } + claude_file = { + 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), + 'content': json.dumps({'mcpServers': claude_servers}), + } + # config_files is sorted by path for a stable digest (~/.claude.json < ~/.cursor/mcp.json). + mock_ai_client.report_session_context.assert_called_once_with( + hostname=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, + config_files=[claude_file, cursor_file], + enabled_plugins={}, + user_email=None, + ) + + +# Dedup cache tests + + +def _run_session_start(mock_ctx: MagicMock, payload: dict) -> None: with patch('sys.stdin', new=StringIO(json.dumps(payload))): session_start_command(mock_ctx, ide='claude-code') - mock_ai_client.report_session_context.assert_not_called() +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_unchanged_context_skips_second_report( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_ctx: MagicMock, +) -> None: + """An identical payload within the TTL is sent once; the second session start skips it.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) + + _run_session_start(mock_ctx, {'session_id': 'session-1'}) + _run_session_start(mock_ctx, {'session_id': 'session-2'}) -@patch.object(_cursor_mod, '_load_cursor_mcp_config') + mock_ai_client.report_session_context.assert_called_once() + + +@patch.object(_session_start_mod, 'collect_all_session_contexts') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_cursor_reports_mcp_servers( +def test_changed_context_resends( mock_get_auth: MagicMock, mock_get_client: MagicMock, - mock_load_cursor: MagicMock, + mock_collect: MagicMock, mock_ctx: MagicMock, ) -> None: - """Cursor should report MCP servers from ~/.cursor/mcp.json.""" - mock_get_auth.return_value = MagicMock() + """A change in the collected inventory busts the cache immediately.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client - mcp_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} - mock_load_cursor.return_value = {'mcpServers': mcp_servers} - payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c1'}}, {}) + _run_session_start(mock_ctx, {'session_id': 'session-1'}) - with patch('sys.stdin', new=StringIO(json.dumps(payload))): - session_start_command(mock_ctx, ide='cursor') + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c2'}}, {}) + _run_session_start(mock_ctx, {'session_id': 'session-2'}) - mock_ai_client.report_session_context.assert_called_once_with( - hostname=ANY, - platform_name=ANY, - os_version=ANY, - serial_number=ANY, - last_login_user=ANY, - global_config_file={ - 'path': str(Path.home() / '.cursor' / 'mcp.json'), - 'content': json.dumps({'mcpServers': mcp_servers}), - }, - enabled_plugins={}, - user_email=None, - ) + assert mock_ai_client.report_session_context.call_count == 2 -@patch.object(_cursor_mod, '_load_cursor_mcp_config') +@patch.object(_session_start_mod, 'collect_all_session_contexts') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_cursor_no_mcp_servers_skips_report( +def test_tenant_change_resends( mock_get_auth: MagicMock, mock_get_client: MagicMock, - mock_load_cursor: MagicMock, + mock_collect: MagicMock, mock_ctx: MagicMock, ) -> None: - """Cursor with no MCP config file should skip report_session_context.""" - mock_get_auth.return_value = MagicMock() + """Re-authenticating against a different tenant must re-send the same inventory.""" mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client - mock_load_cursor.return_value = None + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) - payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + _run_session_start(mock_ctx, {'session_id': 'session-1'}) - with patch('sys.stdin', new=StringIO(json.dumps(payload))): - session_start_command(mock_ctx, ide='cursor') + mock_get_auth.return_value = MagicMock(tenant_id='tenant-2') + _run_session_start(mock_ctx, {'session_id': 'session-2'}) + + assert mock_ai_client.report_session_context.call_count == 2 + + +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_failed_report_is_not_cached( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_ctx: MagicMock, +) -> None: + """A failed send must not populate the cache - the next session retries.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_ai_client.report_session_context.return_value = False + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) + + _run_session_start(mock_ctx, {'session_id': 'session-1'}) + _run_session_start(mock_ctx, {'session_id': 'session-2'}) + + assert mock_ai_client.report_session_context.call_count == 2 + + +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_expired_ttl_resends( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_ctx: MagicMock, +) -> None: + """After the TTL, an unchanged payload is re-sent (self-healing / liveness heartbeat).""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) + + _run_session_start(mock_ctx, {'session_id': 'session-1'}) + + # Age the cache entry past the TTL. + cache_path = _session_start_mod._session_context_cache_path() + cache = json.loads(cache_path.read_text(encoding='utf-8')) + cache['sent_at'] = cache['sent_at'] - _session_start_mod._SESSION_CONTEXT_TTL_SECONDS - 1 + cache_path.write_text(json.dumps(cache), encoding='utf-8') + + _run_session_start(mock_ctx, {'session_id': 'session-2'}) + + assert mock_ai_client.report_session_context.call_count == 2 + + +# Cross-IDE sweep tests + + +def test_collect_all_session_contexts_merges_plugins_first_wins() -> None: + """A plugin key present in two IDEs keeps the first registered IDE's entry.""" + claude_plugin = {'enabled': True, 'version': '1.0.0'} + codex_plugin = {'enabled': True, 'version': '2.0.0'} + + with ( + patch.object(IDES['cursor'], 'get_session_context', return_value=(None, {})), + patch.object(IDES['claude-code'], 'get_session_context', return_value=(None, {'plug@m': claude_plugin})), + patch.object(IDES['codex'], 'get_session_context', return_value=(None, {'plug@m': codex_plugin})), + ): + _, plugins = collect_all_session_contexts() - mock_ai_client.report_session_context.assert_not_called() + assert plugins == {'plug@m': claude_plugin} @patch.object(_session_start_mod, 'handle_auth_exception')