Skip to content

Commit 0a5b30d

Browse files
authored
Merge branch 'main' into CM-62984-add-codex-cli-support
2 parents 220829c + 4213d72 commit 0a5b30d

13 files changed

Lines changed: 743 additions & 51 deletions

File tree

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
11
import typer
22

3-
from cycode.cli.apps.ai_guardrails.ensure_auth_command import ensure_auth_command
4-
from cycode.cli.apps.ai_guardrails.install_command import install_command
5-
from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command
6-
from cycode.cli.apps.ai_guardrails.status_command import status_command
7-
from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command
3+
from cycode.cli.apps.ai_guardrails.install_command import install_command as _install_command
4+
from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command as _scan_command
5+
from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command as _session_start_command
6+
from cycode.cli.apps.ai_guardrails.status_command import status_command as _status_command
7+
from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command as _uninstall_command
88

99
app = typer.Typer(name='ai-guardrails', no_args_is_help=True, hidden=True)
1010

11-
app.command(hidden=True, name='install', short_help='Install AI guardrails hooks for supported IDEs.')(install_command)
11+
app.command(hidden=True, name='install', short_help='Install AI guardrails hooks for supported IDEs.')(_install_command)
1212
app.command(hidden=True, name='uninstall', short_help='Remove AI guardrails hooks from supported IDEs.')(
13-
uninstall_command
13+
_uninstall_command
1414
)
15-
app.command(hidden=True, name='status', short_help='Show AI guardrails hook installation status.')(status_command)
15+
app.command(hidden=True, name='status', short_help='Show AI guardrails hook installation status.')(_status_command)
1616
app.command(
1717
hidden=True,
1818
name='scan',
1919
short_help='Scan content from AI IDE hooks for secrets (reads JSON from stdin).',
20-
)(scan_command)
21-
app.command(hidden=True, name='ensure-auth', short_help='Ensure authentication, triggering auth if needed.')(
22-
ensure_auth_command
20+
)(_scan_command)
21+
app.command(hidden=True, name='session-start', short_help='Handle session start: auth, conversation, session context.')(
22+
_session_start_command
2323
)
24+
app.command(hidden=True, name='ensure-auth', short_help='[Deprecated] Alias for session-start.')(_session_start_command)

cycode/cli/apps/ai_guardrails/consts.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,15 @@ def _get_codex_hooks_dir() -> Path:
101101

102102
# Command used in hooks
103103
CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan'
104-
CYCODE_ENSURE_AUTH_COMMAND = 'cycode ai-guardrails ensure-auth'
104+
CYCODE_SESSION_START_COMMAND = 'cycode ai-guardrails session-start'
105105

106106

107107
def _get_cursor_hooks_config(async_mode: bool = False) -> dict:
108108
"""Get Cursor-specific hooks configuration."""
109109
config = IDE_CONFIGS[AIIDEType.CURSOR]
110110
command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND
111111
hooks = {event: [{'command': command}] for event in config.hook_events}
112-
hooks['sessionStart'] = [{'command': CYCODE_ENSURE_AUTH_COMMAND}]
112+
hooks['sessionStart'] = [{'command': f'{CYCODE_SESSION_START_COMMAND} --ide cursor'}]
113113

114114
return {
115115
'version': 1,
@@ -136,7 +136,7 @@ def _get_claude_code_hooks_config(async_mode: bool = False) -> dict:
136136
'SessionStart': [
137137
{
138138
'matcher': 'startup',
139-
'hooks': [{'type': 'command', 'command': CYCODE_ENSURE_AUTH_COMMAND}],
139+
'hooks': [{'type': 'command', 'command': f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'}],
140140
}
141141
],
142142
'UserPromptSubmit': [

cycode/cli/apps/ai_guardrails/ensure_auth_command.py

Lines changed: 0 additions & 21 deletions
This file was deleted.

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

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
logger = get_logger('AI Guardrails Claude Config')
1414

1515
_CLAUDE_CONFIG_PATH = Path.home() / '.claude.json'
16+
_CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json'
1617

1718

1819
def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]:
@@ -42,3 +43,117 @@ def get_user_email(config: dict) -> Optional[str]:
4243
Reads oauthAccount.emailAddress from the config dict.
4344
"""
4445
return config.get('oauthAccount', {}).get('emailAddress')
46+
47+
48+
def get_mcp_servers(config: dict) -> Optional[dict]:
49+
"""Extract MCP servers from Claude config.
50+
51+
Reads mcpServers from the config dict.
52+
"""
53+
return config.get('mcpServers')
54+
55+
56+
def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]:
57+
"""Load and parse ~/.claude/settings.json.
58+
59+
Args:
60+
settings_path: Override path for testing. Defaults to ~/.claude/settings.json.
61+
62+
Returns:
63+
Parsed dict or None if file is missing or invalid.
64+
"""
65+
path = settings_path or _CLAUDE_SETTINGS_PATH
66+
if not path.exists():
67+
logger.debug('Claude settings file not found', extra={'path': str(path)})
68+
return None
69+
try:
70+
content = path.read_text(encoding='utf-8')
71+
return json.loads(content)
72+
except Exception as e:
73+
logger.debug('Failed to load Claude settings file', exc_info=e)
74+
return None
75+
76+
77+
def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]:
78+
"""
79+
Resolve filesystem path for a directory-type marketplace.
80+
"""
81+
source = marketplace.get('source', {})
82+
if source.get('source') != 'directory':
83+
return None
84+
raw = source.get('path')
85+
if not raw:
86+
return None
87+
path = Path(raw)
88+
return path if path.is_dir() else None
89+
90+
91+
def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]:
92+
"""Load and parse a JSON file inside a plugin directory.
93+
94+
Returns None if the file is missing, unreadable, or has invalid JSON.
95+
"""
96+
target = plugin_path / relative_path
97+
if not target.exists():
98+
return None
99+
try:
100+
return json.loads(target.read_text(encoding='utf-8'))
101+
except Exception as e:
102+
logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e)
103+
return None
104+
105+
106+
def resolve_plugins(settings: dict) -> tuple[dict, dict]:
107+
"""Resolve enabled plugins to their MCP servers and metadata.
108+
109+
Walks enabledPlugins from claude settings, resolves each plugin's 'marketplace' directory
110+
via the 'extraKnownMarketplaces' field, and reads:
111+
- <path>/.mcp.json for MCP servers (merged into a flat dict)
112+
- <path>/.claude-plugin/plugin.json for metadata (name, version, description)
113+
114+
Args:
115+
settings: Parsed ~/.claude/settings.json dict.
116+
117+
Returns:
118+
Tuple of (merged_mcp_servers, enriched_plugins):
119+
- merged_mcp_servers: {server_name: server_config, ...}
120+
- enriched_plugins: {plugin_key: {"enabled": True, "name": ..., ...}, ...}
121+
"""
122+
enabled = settings.get('enabledPlugins') or {}
123+
marketplaces = settings.get('extraKnownMarketplaces') or {}
124+
merged_mcp: dict = {}
125+
enriched: dict = {}
126+
127+
for plugin_key, is_enabled in enabled.items():
128+
if not is_enabled:
129+
continue
130+
131+
entry: dict = {'enabled': True}
132+
enriched[plugin_key] = entry
133+
134+
if '@' not in plugin_key:
135+
continue
136+
137+
_plugin_name, marketplace_name = plugin_key.split('@', 1)
138+
marketplace = marketplaces.get(marketplace_name)
139+
if not marketplace:
140+
continue
141+
142+
plugin_path = _resolve_marketplace_path(marketplace)
143+
if plugin_path is None:
144+
continue
145+
146+
metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {}
147+
for field in ('name', 'version', 'description'):
148+
if field in metadata:
149+
entry[field] = metadata[field]
150+
151+
mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {}
152+
plugin_server_names = []
153+
for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items():
154+
merged_mcp[server_name] = server_cfg
155+
plugin_server_names.append(server_name)
156+
if plugin_server_names:
157+
entry['mcp_server_names'] = plugin_server_names
158+
159+
return merged_mcp, enriched
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 session-context 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/handlers.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
4242
response_builder = get_response_builder(ide)
4343

4444
prompt_config = get_policy_value(policy, 'prompt', default={})
45-
ai_client.create_conversation(payload)
4645
if not get_policy_value(prompt_config, 'enabled', default=True):
4746
ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED)
4847
return response_builder.allow_prompt()
@@ -100,7 +99,6 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
10099
response_builder = get_response_builder(ide)
101100

102101
file_read_config = get_policy_value(policy, 'file_read', default={})
103-
ai_client.create_conversation(payload)
104102
if not get_policy_value(file_read_config, 'enabled', default=True):
105103
ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED)
106104
return response_builder.allow_permission()
@@ -203,7 +201,6 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
203201
response_builder = get_response_builder(ide)
204202

205203
mcp_config = get_policy_value(policy, 'mcp', default={})
206-
ai_client.create_conversation(payload)
207204
if not get_policy_value(mcp_config, 'enabled', default=True):
208205
ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED)
209206
return response_builder.allow_permission()

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def _extract_generation_id(entry: dict) -> Optional[str]:
7373
return None
7474

7575

76-
def _extract_from_claude_transcript(
76+
def extract_from_claude_transcript(
7777
transcript_path: str,
7878
) -> tuple[Optional[str], Optional[str], Optional[str]]:
7979
"""Extract IDE version, model, and latest generation ID from Claude Code transcript file.
@@ -125,7 +125,7 @@ class AIHookPayload:
125125
"""Unified payload object that normalizes field names from different AI tools."""
126126

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

@@ -209,7 +209,7 @@ def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload':
209209
mcp_tool_name = parts[2]
210210

211211
# Extract IDE version, model, and generation ID from transcript file
212-
ide_version, model, generation_id = _extract_from_claude_transcript(payload.get('transcript_path'))
212+
ide_version, model, generation_id = extract_from_claude_transcript(payload.get('transcript_path'))
213213

214214
# Extract user email from ~/.claude.json
215215
claude_config = load_claude_config()

0 commit comments

Comments
 (0)