Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions cycode/cli/apps/ai_guardrails/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
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.session_start_command import session_start_command
from cycode.cli.apps.ai_guardrails.status_command import status_command
from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command

Expand All @@ -18,6 +18,7 @@
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, session context.')(
session_start_command
)
app.command(hidden=True, name='ensure-auth', short_help='[Deprecated] Alias for session-start.')(session_start_command)
6 changes: 3 additions & 3 deletions cycode/cli/apps/ai_guardrails/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,15 @@ 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:
"""Get Cursor-specific hooks configuration."""
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,
Expand All @@ -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': [
Expand Down
21 changes: 0 additions & 21 deletions cycode/cli/apps/ai_guardrails/ensure_auth_command.py

This file was deleted.

111 changes: 111 additions & 0 deletions cycode/cli/apps/ai_guardrails/scan/claude_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -42,3 +43,113 @@ 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:
- <path>/.mcp.json for MCP servers (merged into a flat dict)
- <path>/.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 {}
for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items():
merged_mcp[server_name] = server_cfg

return merged_mcp, enriched
36 changes: 36 additions & 0 deletions cycode/cli/apps/ai_guardrails/scan/cursor_config.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 0 additions & 3 deletions cycode/cli/apps/ai_guardrails/scan/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions cycode/cli/apps/ai_guardrails/scan/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading