diff --git a/cycode/cli/apps/ai_guardrails/__init__.py b/cycode/cli/apps/ai_guardrails/__init__.py index 11267624..a9a9691a 100644 --- a/cycode/cli/apps/ai_guardrails/__init__.py +++ b/cycode/cli/apps/ai_guardrails/__init__.py @@ -1,7 +1,7 @@ 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.session_start_command import session_start_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 @@ -18,6 +18,9 @@ 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 +app.command(hidden=True, name='session-start', short_help='Handle session start: auth, conversation, data flow.')( + 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/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 8c0a2ce7..d9671ccf 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() @@ -187,7 +185,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/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py new file mode 100644 index 00000000..d68dacfc --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -0,0 +1,106 @@ +import sys +from typing import 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 +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 + +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: + ide_version, model, _ = _extract_from_claude_transcript(payload.get('transcript_path')) + claude_config = load_claude_config() + ide_user_email = get_user_email(claude_config) if claude_config else None + + return AIHookPayload( + event_name='session_start', + conversation_id=payload.get('session_id'), + ide_user_email=ide_user_email, + model=payload.get('model') or model, + ide_provider=AIIDEType.CLAUDE_CODE.value, + ide_version=ide_version, + ) + + # Cursor + return AIHookPayload( + event_name='session_start', + 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 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 data flow.""" + # 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 data flow (MCP servers, Claude Code only) + if ide == AIIDEType.CLAUDE_CODE: + claude_config = load_claude_config() + if claude_config: + mcp_servers = get_mcp_servers(claude_config) + if mcp_servers: + try: + ai_client.report_data_flow(mcp_servers) + except Exception as e: + logger.debug('Failed to report MCP servers', exc_info=e) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 35c1d8c9..caf4a64d 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' + _DATA_FLOW_PATH = 'v4/ai-security/interactions/data-flow' def __init__(self, client: CycodeClientBase, service_config: 'AISecurityManagerServiceConfigBase') -> None: self.client = client @@ -88,3 +89,15 @@ 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_data_flow(self, mcp_servers: Optional[dict] = None) -> None: + """Report session data flow to the backend.""" + body: dict = { + 'mcp_servers': mcp_servers, + } + + try: + self.client.post(self._build_endpoint_path(self._DATA_FLOW_PATH), body=body) + except Exception as e: + logger.debug('Failed to report data flow', 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 1adfe25b..77698606 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..9da62ae7 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -0,0 +1,329 @@ +"""Tests for session-start command.""" + +import json +from io import StringIO +from unittest.mock import MagicMock, patch + +import pytest +import typer + +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('cycode.cli.apps.ai_guardrails.session_start_command.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('cycode.cli.apps.ai_guardrails.session_start_command.AuthManager') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.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('cycode.cli.apps.ai_guardrails.session_start_command.handle_auth_exception') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.AuthManager') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.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('cycode.cli.apps.ai_guardrails.session_start_command.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('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.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('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.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('cycode.cli.apps.ai_guardrails.session_start_command.load_claude_config') +@patch('cycode.cli.apps.ai_guardrails.session_start_command._extract_from_claude_transcript') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_authorization_info') +def test_claude_code_creates_conversation( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_transcript: MagicMock, + mock_load_config: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Claude Code payload should create conversation with session_id, model, email.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_transcript.return_value = ('1.0.0', 'claude-sonnet', None) + mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'user@example.com'}} + + payload = {'session_id': 'session-123', 'model': 'claude-opus', 'transcript_path': '/tmp/t.jsonl'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + 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' + + +@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.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('cycode.cli.apps.ai_guardrails.session_start_command.load_claude_config') +@patch('cycode.cli.apps.ai_guardrails.session_start_command._extract_from_claude_transcript') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_authorization_info') +def test_conversation_creation_failure_non_blocking( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_transcript: 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_transcript.return_value = (None, None, None) + 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('cycode.cli.apps.ai_guardrails.session_start_command.load_claude_config') +@patch('cycode.cli.apps.ai_guardrails.session_start_command._extract_from_claude_transcript') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_authorization_info') +def test_claude_code_reports_mcp_servers( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_transcript: MagicMock, + mock_load_config: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Claude Code should report MCP servers from ~/.claude.json.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_transcript.return_value = (None, None, None) + 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} + + 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_data_flow.assert_called_once_with(mcp_servers) + + +@patch('cycode.cli.apps.ai_guardrails.session_start_command.load_claude_config') +@patch('cycode.cli.apps.ai_guardrails.session_start_command._extract_from_claude_transcript') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_authorization_info') +def test_claude_code_no_mcp_servers_skips_report( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_transcript: MagicMock, + mock_load_config: MagicMock, + mock_ctx: MagicMock, +) -> None: + """When no mcpServers in config, report_data_flow should not be called.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_transcript.return_value = (None, None, None) + mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'u@e.com'}} + + 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_data_flow.assert_not_called() + + +@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_authorization_info') +def test_cursor_does_not_report_mcp_servers( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Cursor should not report MCP servers.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + + 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_data_flow.assert_not_called() + + +@patch('cycode.cli.apps.ai_guardrails.session_start_command.load_claude_config') +@patch('cycode.cli.apps.ai_guardrails.session_start_command._extract_from_claude_transcript') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_authorization_info') +def test_mcp_report_failure_non_blocking( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_transcript: MagicMock, + mock_load_config: MagicMock, + mock_ctx: MagicMock, +) -> None: + """MCP reporting failure should not crash the command.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_ai_client.report_data_flow.side_effect = RuntimeError('API down') + mock_get_client.return_value = mock_ai_client + mock_transcript.return_value = (None, None, None) + mock_load_config.return_value = { + 'mcpServers': {'gitlab': {'command': 'npx'}}, + } + + 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 + + +@patch('cycode.cli.apps.ai_guardrails.session_start_command.handle_auth_exception') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.AuthManager') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client') +@patch('cycode.cli.apps.ai_guardrails.session_start_command.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()