Skip to content

Commit 6396761

Browse files
committed
CM-62381-address-review
1 parent 83cf72c commit 6396761

File tree

5 files changed

+102
-34
lines changed

5 files changed

+102
-34
lines changed

cycode/cli/apps/ai_guardrails/scan/claude_config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,11 @@ def get_user_email(config: dict) -> Optional[str]:
4242
Reads oauthAccount.emailAddress from the config dict.
4343
"""
4444
return config.get('oauthAccount', {}).get('emailAddress')
45+
46+
47+
def get_mcp_servers(config: dict) -> Optional[dict]:
48+
"""Extract MCP servers from Claude config.
49+
50+
Reads mcpServers from the config dict.
51+
"""
52+
return config.get('mcpServers')
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Reader for ~/.cursor/mcp.json configuration file.
2+
3+
Extracts MCP server definitions from the Cursor global config file
4+
for use in AI guardrails data-flow reporting.
5+
"""
6+
7+
import json
8+
from pathlib import Path
9+
from typing import Optional
10+
11+
from cycode.logger import get_logger
12+
13+
logger = get_logger('AI Guardrails Cursor Config')
14+
15+
_CURSOR_MCP_CONFIG_PATH = Path.home() / '.cursor' / 'mcp.json'
16+
17+
18+
def load_cursor_config(config_path: Optional[Path] = None) -> Optional[dict]:
19+
"""Load and parse ~/.cursor/mcp.json.
20+
21+
Args:
22+
config_path: Override path for testing. Defaults to ~/.cursor/mcp.json.
23+
24+
Returns:
25+
Parsed dict or None if file is missing or invalid.
26+
"""
27+
path = config_path or _CURSOR_MCP_CONFIG_PATH
28+
if not path.exists():
29+
logger.debug('Cursor MCP config file not found', extra={'path': str(path)})
30+
return None
31+
try:
32+
content = path.read_text(encoding='utf-8')
33+
return json.loads(content)
34+
except Exception as e:
35+
logger.debug('Failed to load Cursor MCP config file', exc_info=e)
36+
return None

cycode/cli/apps/ai_guardrails/scan/payload.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ class AIHookPayload:
123123
"""Unified payload object that normalizes field names from different AI tools."""
124124

125125
# Event identification
126-
event_name: str # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution')
126+
event_name: Optional[str] = None # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution')
127127
conversation_id: Optional[str] = None
128128
generation_id: Optional[str] = None
129129

cycode/cli/apps/ai_guardrails/session_start_command.py

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
from cycode.cli.apps.ai_guardrails.consts import AIIDEType
77
from cycode.cli.apps.ai_guardrails.scan.claude_config import get_mcp_servers, get_user_email, load_claude_config
8-
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload, _extract_from_claude_transcript
8+
from cycode.cli.apps.ai_guardrails.scan.cursor_config import load_cursor_config
9+
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
910
from cycode.cli.apps.ai_guardrails.scan.utils import safe_json_parse
1011
from cycode.cli.apps.auth.auth_common import get_authorization_info
1112
from cycode.cli.apps.auth.auth_manager import AuthManager
@@ -19,22 +20,19 @@
1920
def _build_session_payload(payload: dict, ide: str) -> AIHookPayload:
2021
"""Build an AIHookPayload from a session-start stdin payload."""
2122
if ide == AIIDEType.CLAUDE_CODE:
22-
ide_version, model, _ = _extract_from_claude_transcript(payload.get('transcript_path'))
2323
claude_config = load_claude_config()
2424
ide_user_email = get_user_email(claude_config) if claude_config else None
2525

2626
return AIHookPayload(
27-
event_name='session_start',
2827
conversation_id=payload.get('session_id'),
2928
ide_user_email=ide_user_email,
30-
model=payload.get('model') or model,
29+
model=payload.get('model'),
3130
ide_provider=AIIDEType.CLAUDE_CODE.value,
32-
ide_version=ide_version,
31+
ide_version=None,
3332
)
3433

3534
# Cursor
3635
return AIHookPayload(
37-
event_name='session_start',
3836
conversation_id=payload.get('conversation_id'),
3937
ide_user_email=payload.get('user_email'),
4038
model=payload.get('model'),
@@ -43,6 +41,28 @@ def _build_session_payload(payload: dict, ide: str) -> AIHookPayload:
4341
)
4442

4543

44+
def _get_mcp_servers_for_ide(ide: str) -> dict:
45+
"""Return configured MCP servers for the given IDE, or empty dict."""
46+
if ide == AIIDEType.CLAUDE_CODE:
47+
config = load_claude_config()
48+
elif ide == AIIDEType.CURSOR:
49+
config = load_cursor_config()
50+
else:
51+
return {}
52+
return get_mcp_servers(config) or {} if config else {}
53+
54+
55+
def _report_data_flow(ai_client, ide: str) -> None:
56+
"""Report IDE MCP servers to the AI security manager. Never raises."""
57+
mcp_servers = _get_mcp_servers_for_ide(ide)
58+
if not mcp_servers:
59+
return
60+
try:
61+
ai_client.report_data_flow(mcp_servers)
62+
except Exception as e:
63+
logger.debug('Failed to report MCP servers', exc_info=e)
64+
65+
4666
def session_start_command(
4767
ctx: typer.Context,
4868
ide: Annotated[
@@ -94,13 +114,5 @@ def session_start_command(
94114
except Exception as e:
95115
logger.debug('Failed to create conversation during session start', exc_info=e)
96116

97-
# Step 5: Report data flow (MCP servers, Claude Code only)
98-
if ide == AIIDEType.CLAUDE_CODE:
99-
claude_config = load_claude_config()
100-
if claude_config:
101-
mcp_servers = get_mcp_servers(claude_config)
102-
if mcp_servers:
103-
try:
104-
ai_client.report_data_flow(mcp_servers)
105-
except Exception as e:
106-
logger.debug('Failed to report MCP servers', exc_info=e)
117+
# Step 5: Report data flow (MCP servers)
118+
_report_data_flow(ai_client, ide)

tests/cli/commands/ai_guardrails/test_session_start_command.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -111,21 +111,18 @@ def test_invalid_json_stdin_skips_session_init(
111111

112112

113113
@patch('cycode.cli.apps.ai_guardrails.session_start_command.load_claude_config')
114-
@patch('cycode.cli.apps.ai_guardrails.session_start_command._extract_from_claude_transcript')
115114
@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client')
116115
@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_authorization_info')
117116
def test_claude_code_creates_conversation(
118117
mock_get_auth: MagicMock,
119118
mock_get_client: MagicMock,
120-
mock_transcript: MagicMock,
121119
mock_load_config: MagicMock,
122120
mock_ctx: MagicMock,
123121
) -> None:
124122
"""Claude Code payload should create conversation with session_id, model, email."""
125123
mock_get_auth.return_value = MagicMock()
126124
mock_ai_client = MagicMock()
127125
mock_get_client.return_value = mock_ai_client
128-
mock_transcript.return_value = ('1.0.0', 'claude-sonnet', None)
129126
mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'user@example.com'}}
130127

131128
payload = {'session_id': 'session-123', 'model': 'claude-opus', 'transcript_path': '/tmp/t.jsonl'}
@@ -172,13 +169,11 @@ def test_cursor_creates_conversation(
172169

173170

174171
@patch('cycode.cli.apps.ai_guardrails.session_start_command.load_claude_config')
175-
@patch('cycode.cli.apps.ai_guardrails.session_start_command._extract_from_claude_transcript')
176172
@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client')
177173
@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_authorization_info')
178174
def test_conversation_creation_failure_non_blocking(
179175
mock_get_auth: MagicMock,
180176
mock_get_client: MagicMock,
181-
mock_transcript: MagicMock,
182177
mock_load_config: MagicMock,
183178
mock_ctx: MagicMock,
184179
) -> None:
@@ -187,7 +182,6 @@ def test_conversation_creation_failure_non_blocking(
187182
mock_ai_client = MagicMock()
188183
mock_ai_client.create_conversation.side_effect = RuntimeError('API down')
189184
mock_get_client.return_value = mock_ai_client
190-
mock_transcript.return_value = (None, None, None)
191185
mock_load_config.return_value = None
192186

193187
payload = {'session_id': 'session-123'}
@@ -202,21 +196,18 @@ def test_conversation_creation_failure_non_blocking(
202196

203197

204198
@patch('cycode.cli.apps.ai_guardrails.session_start_command.load_claude_config')
205-
@patch('cycode.cli.apps.ai_guardrails.session_start_command._extract_from_claude_transcript')
206199
@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client')
207200
@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_authorization_info')
208201
def test_claude_code_reports_mcp_servers(
209202
mock_get_auth: MagicMock,
210203
mock_get_client: MagicMock,
211-
mock_transcript: MagicMock,
212204
mock_load_config: MagicMock,
213205
mock_ctx: MagicMock,
214206
) -> None:
215207
"""Claude Code should report MCP servers from ~/.claude.json."""
216208
mock_get_auth.return_value = MagicMock()
217209
mock_ai_client = MagicMock()
218210
mock_get_client.return_value = mock_ai_client
219-
mock_transcript.return_value = (None, None, None)
220211
mcp_servers = {
221212
'gitlab': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-gitlab']},
222213
'filesystem': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-filesystem']},
@@ -232,21 +223,18 @@ def test_claude_code_reports_mcp_servers(
232223

233224

234225
@patch('cycode.cli.apps.ai_guardrails.session_start_command.load_claude_config')
235-
@patch('cycode.cli.apps.ai_guardrails.session_start_command._extract_from_claude_transcript')
236226
@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client')
237227
@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_authorization_info')
238228
def test_claude_code_no_mcp_servers_skips_report(
239229
mock_get_auth: MagicMock,
240230
mock_get_client: MagicMock,
241-
mock_transcript: MagicMock,
242231
mock_load_config: MagicMock,
243232
mock_ctx: MagicMock,
244233
) -> None:
245234
"""When no mcpServers in config, report_data_flow should not be called."""
246235
mock_get_auth.return_value = MagicMock()
247236
mock_ai_client = MagicMock()
248237
mock_get_client.return_value = mock_ai_client
249-
mock_transcript.return_value = (None, None, None)
250238
mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'u@e.com'}}
251239

252240
payload = {'session_id': 'session-123'}
@@ -257,17 +245,44 @@ def test_claude_code_no_mcp_servers_skips_report(
257245
mock_ai_client.report_data_flow.assert_not_called()
258246

259247

248+
@patch('cycode.cli.apps.ai_guardrails.session_start_command.load_cursor_config')
260249
@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client')
261250
@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_authorization_info')
262-
def test_cursor_does_not_report_mcp_servers(
251+
def test_cursor_reports_mcp_servers(
263252
mock_get_auth: MagicMock,
264253
mock_get_client: MagicMock,
254+
mock_load_cursor: MagicMock,
265255
mock_ctx: MagicMock,
266256
) -> None:
267-
"""Cursor should not report MCP servers."""
257+
"""Cursor should report MCP servers from ~/.cursor/mcp.json."""
268258
mock_get_auth.return_value = MagicMock()
269259
mock_ai_client = MagicMock()
270260
mock_get_client.return_value = mock_ai_client
261+
mcp_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}}
262+
mock_load_cursor.return_value = {'mcpServers': mcp_servers}
263+
264+
payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'}
265+
266+
with patch('sys.stdin', new=StringIO(json.dumps(payload))):
267+
session_start_command(mock_ctx, ide='cursor')
268+
269+
mock_ai_client.report_data_flow.assert_called_once_with(mcp_servers)
270+
271+
272+
@patch('cycode.cli.apps.ai_guardrails.session_start_command.load_cursor_config')
273+
@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client')
274+
@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_authorization_info')
275+
def test_cursor_no_mcp_servers_skips_report(
276+
mock_get_auth: MagicMock,
277+
mock_get_client: MagicMock,
278+
mock_load_cursor: MagicMock,
279+
mock_ctx: MagicMock,
280+
) -> None:
281+
"""Cursor with no MCP config file should skip report_data_flow."""
282+
mock_get_auth.return_value = MagicMock()
283+
mock_ai_client = MagicMock()
284+
mock_get_client.return_value = mock_ai_client
285+
mock_load_cursor.return_value = None
271286

272287
payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'}
273288

@@ -278,13 +293,11 @@ def test_cursor_does_not_report_mcp_servers(
278293

279294

280295
@patch('cycode.cli.apps.ai_guardrails.session_start_command.load_claude_config')
281-
@patch('cycode.cli.apps.ai_guardrails.session_start_command._extract_from_claude_transcript')
282296
@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_ai_security_manager_client')
283297
@patch('cycode.cli.apps.ai_guardrails.session_start_command.get_authorization_info')
284298
def test_mcp_report_failure_non_blocking(
285299
mock_get_auth: MagicMock,
286300
mock_get_client: MagicMock,
287-
mock_transcript: MagicMock,
288301
mock_load_config: MagicMock,
289302
mock_ctx: MagicMock,
290303
) -> None:
@@ -293,7 +306,6 @@ def test_mcp_report_failure_non_blocking(
293306
mock_ai_client = MagicMock()
294307
mock_ai_client.report_data_flow.side_effect = RuntimeError('API down')
295308
mock_get_client.return_value = mock_ai_client
296-
mock_transcript.return_value = (None, None, None)
297309
mock_load_config.return_value = {
298310
'mcpServers': {'gitlab': {'command': 'npx'}},
299311
}

0 commit comments

Comments
 (0)