diff --git a/cycode/cli/apps/ai_guardrails/__init__.py b/cycode/cli/apps/ai_guardrails/__init__.py index 11267624..1443008d 100644 --- a/cycode/cli/apps/ai_guardrails/__init__.py +++ b/cycode/cli/apps/ai_guardrails/__init__.py @@ -1,23 +1,24 @@ import typer -from cycode.cli.apps.ai_guardrails.ensure_auth_command import ensure_auth_command -from cycode.cli.apps.ai_guardrails.install_command import install_command -from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command -from cycode.cli.apps.ai_guardrails.status_command import status_command -from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command +from cycode.cli.apps.ai_guardrails.install_command import install_command as _install_command +from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command as _scan_command +from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command as _session_start_command +from cycode.cli.apps.ai_guardrails.status_command import status_command as _status_command +from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command as _uninstall_command app = typer.Typer(name='ai-guardrails', no_args_is_help=True, hidden=True) -app.command(hidden=True, name='install', short_help='Install AI guardrails hooks for supported IDEs.')(install_command) +app.command(hidden=True, name='install', short_help='Install AI guardrails hooks for supported IDEs.')(_install_command) app.command(hidden=True, name='uninstall', short_help='Remove AI guardrails hooks from supported IDEs.')( - uninstall_command + _uninstall_command ) -app.command(hidden=True, name='status', short_help='Show AI guardrails hook installation status.')(status_command) +app.command(hidden=True, name='status', short_help='Show AI guardrails hook installation status.')(_status_command) app.command( hidden=True, name='scan', short_help='Scan content from AI IDE hooks for secrets (reads JSON from stdin).', -)(scan_command) -app.command(hidden=True, name='ensure-auth', short_help='Ensure authentication, triggering auth if needed.')( - ensure_auth_command +)(_scan_command) +app.command(hidden=True, name='session-start', short_help='Handle session start: auth, conversation, session context.')( + _session_start_command ) +app.command(hidden=True, name='ensure-auth', short_help='[Deprecated] Alias for session-start.')(_session_start_command) diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index 81539b30..837096c8 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -84,7 +84,7 @@ def _get_claude_code_hooks_dir() -> Path: # Command used in hooks CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan' -CYCODE_ENSURE_AUTH_COMMAND = 'cycode ai-guardrails ensure-auth' +CYCODE_SESSION_START_COMMAND = 'cycode ai-guardrails session-start' def _get_cursor_hooks_config(async_mode: bool = False) -> dict: @@ -92,7 +92,7 @@ def _get_cursor_hooks_config(async_mode: bool = False) -> dict: config = IDE_CONFIGS[AIIDEType.CURSOR] command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND hooks = {event: [{'command': command}] for event in config.hook_events} - hooks['sessionStart'] = [{'command': CYCODE_ENSURE_AUTH_COMMAND}] + hooks['sessionStart'] = [{'command': f'{CYCODE_SESSION_START_COMMAND} --ide cursor'}] return { 'version': 1, @@ -119,7 +119,7 @@ def _get_claude_code_hooks_config(async_mode: bool = False) -> dict: 'SessionStart': [ { 'matcher': 'startup', - 'hooks': [{'type': 'command', 'command': CYCODE_ENSURE_AUTH_COMMAND}], + 'hooks': [{'type': 'command', 'command': f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'}], } ], 'UserPromptSubmit': [ diff --git a/cycode/cli/apps/ai_guardrails/ensure_auth_command.py b/cycode/cli/apps/ai_guardrails/ensure_auth_command.py deleted file mode 100644 index 78b8bf83..00000000 --- a/cycode/cli/apps/ai_guardrails/ensure_auth_command.py +++ /dev/null @@ -1,21 +0,0 @@ -import typer - -from cycode.cli.apps.auth.auth_common import get_authorization_info -from cycode.cli.apps.auth.auth_manager import AuthManager -from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception -from cycode.cli.logger import logger - - -def ensure_auth_command(ctx: typer.Context) -> None: - """Ensure the user is authenticated, triggering authentication if needed.""" - auth_info = get_authorization_info(ctx) - if auth_info is not None: - logger.debug('Already authenticated') - return - - logger.debug('Not authenticated, starting authentication') - try: - auth_manager = AuthManager() - auth_manager.authenticate() - except Exception as err: - handle_auth_exception(ctx, err) diff --git a/cycode/cli/apps/ai_guardrails/scan/claude_config.py b/cycode/cli/apps/ai_guardrails/scan/claude_config.py index cff0a5d7..4b547427 100644 --- a/cycode/cli/apps/ai_guardrails/scan/claude_config.py +++ b/cycode/cli/apps/ai_guardrails/scan/claude_config.py @@ -13,6 +13,7 @@ logger = get_logger('AI Guardrails Claude Config') _CLAUDE_CONFIG_PATH = Path.home() / '.claude.json' +_CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json' def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]: @@ -42,3 +43,117 @@ def get_user_email(config: dict) -> Optional[str]: Reads oauthAccount.emailAddress from the config dict. """ return config.get('oauthAccount', {}).get('emailAddress') + + +def get_mcp_servers(config: dict) -> Optional[dict]: + """Extract MCP servers from Claude config. + + Reads mcpServers from the config dict. + """ + return config.get('mcpServers') + + +def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse ~/.claude/settings.json. + + Args: + settings_path: Override path for testing. Defaults to ~/.claude/settings.json. + + Returns: + Parsed dict or None if file is missing or invalid. + """ + path = settings_path or _CLAUDE_SETTINGS_PATH + if not path.exists(): + logger.debug('Claude settings file not found', extra={'path': str(path)}) + return None + try: + content = path.read_text(encoding='utf-8') + return json.loads(content) + except Exception as e: + logger.debug('Failed to load Claude settings file', exc_info=e) + return None + + +def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]: + """ + Resolve filesystem path for a directory-type marketplace. + """ + source = marketplace.get('source', {}) + if source.get('source') != 'directory': + return None + raw = source.get('path') + if not raw: + return None + path = Path(raw) + return path if path.is_dir() else None + + +def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]: + """Load and parse a JSON file inside a plugin directory. + + Returns None if the file is missing, unreadable, or has invalid JSON. + """ + target = plugin_path / relative_path + if not target.exists(): + return None + try: + return json.loads(target.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e) + return None + + +def resolve_plugins(settings: dict) -> tuple[dict, dict]: + """Resolve enabled plugins to their MCP servers and metadata. + + Walks enabledPlugins from claude settings, resolves each plugin's 'marketplace' directory + via the 'extraKnownMarketplaces' field, and reads: + - /.mcp.json for MCP servers (merged into a flat dict) + - /.claude-plugin/plugin.json for metadata (name, version, description) + + Args: + settings: Parsed ~/.claude/settings.json dict. + + Returns: + Tuple of (merged_mcp_servers, enriched_plugins): + - merged_mcp_servers: {server_name: server_config, ...} + - enriched_plugins: {plugin_key: {"enabled": True, "name": ..., ...}, ...} + """ + enabled = settings.get('enabledPlugins') or {} + marketplaces = settings.get('extraKnownMarketplaces') or {} + merged_mcp: dict = {} + enriched: dict = {} + + for plugin_key, is_enabled in enabled.items(): + if not is_enabled: + continue + + entry: dict = {'enabled': True} + enriched[plugin_key] = entry + + if '@' not in plugin_key: + continue + + _plugin_name, marketplace_name = plugin_key.split('@', 1) + marketplace = marketplaces.get(marketplace_name) + if not marketplace: + continue + + plugin_path = _resolve_marketplace_path(marketplace) + if plugin_path is None: + continue + + metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {} + for field in ('name', 'version', 'description'): + if field in metadata: + entry[field] = metadata[field] + + mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {} + plugin_server_names = [] + for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items(): + merged_mcp[server_name] = server_cfg + plugin_server_names.append(server_name) + if plugin_server_names: + entry['mcp_server_names'] = plugin_server_names + + return merged_mcp, enriched diff --git a/cycode/cli/apps/ai_guardrails/scan/cursor_config.py b/cycode/cli/apps/ai_guardrails/scan/cursor_config.py new file mode 100644 index 00000000..9a174a7a --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/cursor_config.py @@ -0,0 +1,36 @@ +"""Reader for ~/.cursor/mcp.json configuration file. + +Extracts MCP server definitions from the Cursor global config file +for use in AI guardrails session-context reporting. +""" + +import json +from pathlib import Path +from typing import Optional + +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Cursor Config') + +_CURSOR_MCP_CONFIG_PATH = Path.home() / '.cursor' / 'mcp.json' + + +def load_cursor_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse ~/.cursor/mcp.json. + + Args: + config_path: Override path for testing. Defaults to ~/.cursor/mcp.json. + + Returns: + Parsed dict or None if file is missing or invalid. + """ + path = config_path or _CURSOR_MCP_CONFIG_PATH + if not path.exists(): + logger.debug('Cursor MCP config file not found', extra={'path': str(path)}) + return None + try: + content = path.read_text(encoding='utf-8') + return json.loads(content) + except Exception as e: + logger.debug('Failed to load Cursor MCP config file', exc_info=e) + return None diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 99fa29c8..fa0bddee 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -42,7 +42,6 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli response_builder = get_response_builder(ide) prompt_config = get_policy_value(policy, 'prompt', default={}) - ai_client.create_conversation(payload) if not get_policy_value(prompt_config, 'enabled', default=True): ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED) return response_builder.allow_prompt() @@ -100,7 +99,6 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: response_builder = get_response_builder(ide) file_read_config = get_policy_value(policy, 'file_read', default={}) - ai_client.create_conversation(payload) if not get_policy_value(file_read_config, 'enabled', default=True): ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED) return response_builder.allow_permission() @@ -203,7 +201,6 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli response_builder = get_response_builder(ide) mcp_config = get_policy_value(policy, 'mcp', default={}) - ai_client.create_conversation(payload) if not get_policy_value(mcp_config, 'enabled', default=True): ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED) return response_builder.allow_permission() diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index 9a19970c..ada40a3c 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -71,7 +71,7 @@ def _extract_generation_id(entry: dict) -> Optional[str]: return None -def _extract_from_claude_transcript( +def extract_from_claude_transcript( transcript_path: str, ) -> tuple[Optional[str], Optional[str], Optional[str]]: """Extract IDE version, model, and latest generation ID from Claude Code transcript file. @@ -123,7 +123,7 @@ class AIHookPayload: """Unified payload object that normalizes field names from different AI tools.""" # Event identification - event_name: str # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution') + event_name: Optional[str] = None # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution') conversation_id: Optional[str] = None generation_id: Optional[str] = None @@ -206,7 +206,7 @@ def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload': mcp_tool_name = parts[2] # Extract IDE version, model, and generation ID from transcript file - ide_version, model, generation_id = _extract_from_claude_transcript(payload.get('transcript_path')) + ide_version, model, generation_id = extract_from_claude_transcript(payload.get('transcript_path')) # Extract user email from ~/.claude.json claude_config = load_claude_config() diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py new file mode 100644 index 00000000..a33dc439 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -0,0 +1,150 @@ +import sys +from typing import TYPE_CHECKING, Annotated + +import typer + +from cycode.cli.apps.ai_guardrails.consts import AIIDEType +from cycode.cli.apps.ai_guardrails.scan.claude_config import ( + get_mcp_servers, + get_user_email, + load_claude_config, + load_claude_settings, + resolve_plugins, +) +from cycode.cli.apps.ai_guardrails.scan.cursor_config import load_cursor_config +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload, extract_from_claude_transcript +from cycode.cli.apps.ai_guardrails.scan.utils import safe_json_parse +from cycode.cli.apps.auth.auth_common import get_authorization_info +from cycode.cli.apps.auth.auth_manager import AuthManager +from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception +from cycode.cli.utils.get_api_client import get_ai_security_manager_client +from cycode.logger import get_logger + +if TYPE_CHECKING: + from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient + +logger = get_logger('AI Guardrails') + + +def _build_session_payload(payload: dict, ide: str) -> AIHookPayload: + """Build an AIHookPayload from a session-start stdin payload.""" + if ide == AIIDEType.CLAUDE_CODE: + claude_config = load_claude_config() + ide_user_email = get_user_email(claude_config) if claude_config else None + ide_version, _, _ = extract_from_claude_transcript(payload.get('transcript_path')) + + return AIHookPayload( + conversation_id=payload.get('session_id'), + ide_user_email=ide_user_email, + model=payload.get('model'), + ide_provider=AIIDEType.CLAUDE_CODE.value, + ide_version=ide_version, + ) + + # Cursor + return AIHookPayload( + conversation_id=payload.get('conversation_id'), + ide_user_email=payload.get('user_email'), + model=payload.get('model'), + ide_provider=AIIDEType.CURSOR.value, + ide_version=payload.get('cursor_version'), + ) + + +def _get_claude_code_session_context() -> tuple[dict, dict]: + """Return (mcp_servers, enabled_plugins) for Claude Code. + + Merges MCP servers from ~/.claude.json (user-configured) with those contributed + by enabled plugins. Plugin metadata (name, version, description) is included in + the enabled_plugins dict when resolvable. + """ + config = load_claude_config() + mcp_servers = dict(get_mcp_servers(config) or {}) if config else {} + + settings = load_claude_settings() + if settings: + plugin_mcp, enriched_plugins = resolve_plugins(settings) + mcp_servers.update(plugin_mcp) + else: + enriched_plugins = {} + + return mcp_servers, enriched_plugins + + +def _get_cursor_session_context() -> tuple[dict, dict]: + """Return (mcp_servers, enabled_plugins) for Cursor. Cursor has no plugin system.""" + config = load_cursor_config() + mcp_servers = dict(get_mcp_servers(config) or {}) if config else {} + return mcp_servers, {} + + +def _report_session_context(ai_client: 'AISecurityManagerClient', ide: str) -> None: + """Report IDE session context to the AI security manager. Never raises.""" + try: + if ide == AIIDEType.CLAUDE_CODE: + mcp_servers, enabled_plugins = _get_claude_code_session_context() + elif ide == AIIDEType.CURSOR: + mcp_servers, enabled_plugins = _get_cursor_session_context() + else: + return + + if not mcp_servers and not enabled_plugins: + return + ai_client.report_session_context(mcp_servers=mcp_servers, enabled_plugins=enabled_plugins) + except Exception as e: + logger.debug('Failed to report session context', exc_info=e) + + +def session_start_command( + ctx: typer.Context, + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE that triggered the session start.', + hidden=True, + ), + ] = AIIDEType.CURSOR.value, +) -> None: + """Handle session start: ensure auth, create conversation, report session context.""" + # Step 1: Ensure authentication + auth_info = get_authorization_info(ctx) + if auth_info is None: + logger.debug('Not authenticated, starting authentication') + try: + auth_manager = AuthManager() + auth_manager.authenticate() + except Exception as err: + handle_auth_exception(ctx, err) + return + else: + logger.debug('Already authenticated') + + # Step 2: Read stdin payload (backward compat: old hooks pipe no stdin) + if sys.stdin.isatty(): + logger.debug('No stdin payload (TTY), skipping session initialization') + return + + stdin_data = sys.stdin.read().strip() + payload = safe_json_parse(stdin_data) + if not payload: + logger.debug('Empty or invalid stdin payload, skipping session initialization') + return + + # Step 3: Build session payload and initialize API client + session_payload = _build_session_payload(payload, ide) + + try: + ai_client = get_ai_security_manager_client(ctx) + except Exception as e: + logger.debug('Failed to initialize AI security client', exc_info=e) + return + + # Step 4: 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) + _report_session_context(ai_client, ide) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 35c1d8c9..376b549e 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -17,6 +17,7 @@ class AISecurityManagerClient: _CONVERSATIONS_PATH = 'v4/ai-security/interactions/conversations' _EVENTS_PATH = 'v4/ai-security/interactions/events' + _SESSION_CONTEXT_PATH = 'v4/ai-security/interactions/session-context' def __init__(self, client: CycodeClientBase, service_config: 'AISecurityManagerServiceConfigBase') -> None: self.client = client @@ -88,3 +89,20 @@ def create_event( except Exception as e: logger.debug('Failed to create AI hook event', exc_info=e) # Don't fail the hook if tracking fails + + def report_session_context( + self, + mcp_servers: Optional[dict] = None, + enabled_plugins: Optional[dict] = None, + ) -> None: + """Report session context to the backend.""" + body: dict = { + 'mcp_servers': mcp_servers, + 'enabled_plugins': enabled_plugins, + } + + try: + self.client.post(self._build_endpoint_path(self._SESSION_CONTEXT_PATH), body=body) + except Exception as e: + logger.debug('Failed to report session context', exc_info=e) + # Don't fail the session if reporting fails diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 1ef1098c..57c25b92 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -67,6 +67,7 @@ def test_handle_before_submit_prompt_disabled( assert result == {'continue': True} mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called() @patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') @@ -80,6 +81,7 @@ def test_handle_before_submit_prompt_no_secrets( assert result == {'continue': True} mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called() call_args = mock_ctx.obj['ai_security_client'].create_event.call_args # outcome is arg[2], scan_id and block_reason are kwargs assert call_args.args[2] == AIHookOutcome.ALLOWED diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index ed1ada09..a5732bca 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -6,8 +6,8 @@ from pyfakefs.fake_filesystem import FakeFilesystem from cycode.cli.apps.ai_guardrails.consts import ( - CYCODE_ENSURE_AUTH_COMMAND, CYCODE_SCAN_PROMPT_COMMAND, + CYCODE_SESSION_START_COMMAND, AIIDEType, PolicyMode, get_hooks_config, @@ -88,12 +88,13 @@ def test_get_hooks_config_cursor_async() -> None: def test_get_hooks_config_cursor_session_start() -> None: - """Test Cursor hooks config includes sessionStart auth check.""" + """Test Cursor hooks config includes sessionStart with --ide flag.""" config = get_hooks_config(AIIDEType.CURSOR) assert 'sessionStart' in config['hooks'] entries = config['hooks']['sessionStart'] assert len(entries) == 1 - assert entries[0]['command'] == CYCODE_ENSURE_AUTH_COMMAND + assert CYCODE_SESSION_START_COMMAND in entries[0]['command'] + assert '--ide cursor' in entries[0]['command'] def test_get_hooks_config_claude_code_sync() -> None: @@ -118,12 +119,13 @@ def test_get_hooks_config_claude_code_async() -> None: def test_get_hooks_config_claude_code_session_start() -> None: - """Test Claude Code hooks config includes SessionStart auth check.""" + """Test Claude Code hooks config includes SessionStart with --ide flag.""" config = get_hooks_config(AIIDEType.CLAUDE_CODE) assert 'SessionStart' in config['hooks'] entries = config['hooks']['SessionStart'] assert len(entries) == 1 - assert entries[0]['hooks'][0]['command'] == CYCODE_ENSURE_AUTH_COMMAND + assert CYCODE_SESSION_START_COMMAND in entries[0]['hooks'][0]['command'] + assert '--ide claude-code' in entries[0]['hooks'][0]['command'] def test_create_policy_file_warn(fs: FakeFilesystem) -> None: diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py new file mode 100644 index 00000000..48e0ebe3 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -0,0 +1,392 @@ +"""Tests for session-start command.""" + +import json +from io import StringIO +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.apps.ai_guardrails import session_start_command as _session_start_mod +from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command + + +@pytest.fixture +def mock_ctx() -> MagicMock: + """Create a mock Typer context.""" + ctx = MagicMock(spec=typer.Context) + ctx.obj = {} + return ctx + + +# Auth tests + + +@patch.object(_session_start_mod, 'get_authorization_info') +def test_already_authenticated_skips_auth(mock_get_auth: MagicMock, mock_ctx: MagicMock) -> None: + """When already authenticated, AuthManager should not be called.""" + mock_get_auth.return_value = MagicMock() + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + +@patch.object(_session_start_mod, 'AuthManager') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_not_authenticated_triggers_auth( + mock_get_auth: MagicMock, mock_auth_manager_cls: MagicMock, mock_ctx: MagicMock +) -> None: + """When not authenticated, AuthManager.authenticate should be called.""" + mock_get_auth.return_value = None + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + mock_auth_manager_cls.return_value.authenticate.assert_called_once() + + +@patch.object(_session_start_mod, 'handle_auth_exception') +@patch.object(_session_start_mod, 'AuthManager') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_auth_failure_handled_gracefully( + mock_get_auth: MagicMock, + mock_auth_manager_cls: MagicMock, + mock_handle_err: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Auth failure should be handled gracefully, not crash.""" + mock_get_auth.return_value = None + mock_auth_manager_cls.return_value.authenticate.side_effect = RuntimeError('auth failed') + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + mock_handle_err.assert_called_once() + + +# Stdin / payload tests + + +@patch.object(_session_start_mod, 'get_authorization_info') +def test_tty_stdin_auth_only(mock_get_auth: MagicMock, mock_ctx: MagicMock) -> None: + """When stdin is a TTY (old hooks), only auth is performed.""" + mock_get_auth.return_value = MagicMock() + mock_stdin = MagicMock() + mock_stdin.isatty.return_value = True + + with patch('sys.stdin', new=mock_stdin): + session_start_command(mock_ctx) + + mock_stdin.read.assert_not_called() + + +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_empty_stdin_skips_session_init( + mock_get_auth: MagicMock, mock_get_client: MagicMock, mock_ctx: MagicMock +) -> None: + """Empty stdin should skip session initialization.""" + mock_get_auth.return_value = MagicMock() + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + mock_get_client.assert_not_called() + + +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_invalid_json_stdin_skips_session_init( + mock_get_auth: MagicMock, mock_get_client: MagicMock, mock_ctx: MagicMock +) -> None: + """Invalid JSON stdin should skip session initialization.""" + mock_get_auth.return_value = MagicMock() + + with patch('sys.stdin', new=StringIO('not valid json')): + session_start_command(mock_ctx) + + mock_get_client.assert_not_called() + + +# Conversation creation tests + + +@patch.object(_session_start_mod, 'extract_from_claude_transcript') +@patch.object(_session_start_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_creates_conversation( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_extract: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Claude Code payload should create a conversation with session_id, model, email, version.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'user@example.com'}} + mock_extract.return_value = ('2.1.20', 'claude-opus', 'gen-abc') + + transcript_path = '/fake/transcript.jsonl' + payload = {'session_id': 'session-123', 'model': 'claude-opus', 'transcript_path': transcript_path} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_extract.assert_called_once_with(transcript_path) + mock_ai_client.create_conversation.assert_called_once() + call_payload = mock_ai_client.create_conversation.call_args[0][0] + assert call_payload.conversation_id == 'session-123' + assert call_payload.model == 'claude-opus' + assert call_payload.ide_user_email == 'user@example.com' + assert call_payload.ide_provider == 'claude-code' + assert call_payload.ide_version == '2.1.20' + + +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_cursor_creates_conversation( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Cursor payload should create conversation with conversation_id and model.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + + payload = { + 'conversation_id': 'conv-456', + 'user_email': 'cursor-user@example.com', + 'model': 'gpt-4', + 'cursor_version': '0.42.0', + } + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='cursor') + + mock_ai_client.create_conversation.assert_called_once() + call_payload = mock_ai_client.create_conversation.call_args[0][0] + assert call_payload.conversation_id == 'conv-456' + assert call_payload.model == 'gpt-4' + assert call_payload.ide_user_email == 'cursor-user@example.com' + assert call_payload.ide_provider == 'cursor' + + +@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_conversation_creation_failure_non_blocking( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Conversation creation failure should not crash the command.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_ai_client.create_conversation.side_effect = RuntimeError('API down') + mock_get_client.return_value = mock_ai_client + mock_load_config.return_value = None + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + # Should not raise + + +# MCP server reporting tests + + +@patch.object(_session_start_mod, 'load_claude_settings') +@patch.object(_session_start_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_reports_mcp_servers( + mock_get_auth: MagicMock, + mock_get_client: 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() + 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': 'u@e.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}} + + 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( + mcp_servers=mcp_servers, + enabled_plugins={'cycode-dev@cycode-marketplace': {'enabled': True}}, + ) + + +@patch.object(_session_start_mod, 'load_claude_settings') +@patch.object(_session_start_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( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_load_settings: MagicMock, + 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.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + + # Set up a fake plugin directory on disk. + plugin_dir = tmp_path / 'ai-prompts' + plugin_dir.mkdir() + (plugin_dir / '.mcp.json').write_text( + json.dumps({'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}}) + ) + 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'}) + ) + + user_mcp_servers = {'gitlab': {'command': 'npx'}} + 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)}}}, + } + + 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( + mcp_servers={ + 'gitlab': {'command': 'npx'}, + 'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}, + }, + enabled_plugins={ + 'cycode-dev@cycode-marketplace': { + 'enabled': True, + 'name': 'cycode-dev', + 'version': '1.0.28', + 'description': 'Shared skills', + 'mcp_server_names': ['aspire'], + } + }, + ) + + +@patch.object(_session_start_mod, 'load_claude_settings') +@patch.object(_session_start_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_no_mcp_servers_no_plugins_skips_report( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_load_settings: MagicMock, + mock_ctx: MagicMock, +) -> None: + """When no mcpServers and no plugins, report_session_context should not be called.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'u@e.com'}} + mock_load_settings.return_value = None + + 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_not_called() + + +@patch.object(_session_start_mod, 'load_cursor_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_cursor_reports_mcp_servers( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_cursor: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Cursor should report MCP servers from ~/.cursor/mcp.json.""" + mock_get_auth.return_value = MagicMock() + 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'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='cursor') + + mock_ai_client.report_session_context.assert_called_once_with(mcp_servers=mcp_servers, enabled_plugins={}) + + +@patch.object(_session_start_mod, 'load_cursor_config') +@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( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_cursor: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Cursor with no MCP config file should skip report_session_context.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_load_cursor.return_value = None + + payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='cursor') + + mock_ai_client.report_session_context.assert_not_called() + + +@patch.object(_session_start_mod, 'handle_auth_exception') +@patch.object(_session_start_mod, 'AuthManager') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_unauthenticated_skips_session_init( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_auth_manager_cls: MagicMock, + mock_handle_err: MagicMock, + mock_ctx: MagicMock, +) -> None: + """When auth fails, session initialization should be skipped entirely.""" + mock_get_auth.return_value = None + mock_auth_manager_cls.return_value.authenticate.side_effect = RuntimeError('auth failed') + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_get_client.assert_not_called()