Skip to content
9 changes: 6 additions & 3 deletions cycode/cli/apps/ai_guardrails/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
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

Check failure on line 7 in cycode/cli/apps/ai_guardrails/__init__.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (I001)

cycode/cli/apps/ai_guardrails/__init__.py:1:1: I001 Import block is un-sorted or un-formatted

Check failure on line 7 in cycode/cli/apps/ai_guardrails/__init__.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (I001)

cycode/cli/apps/ai_guardrails/__init__.py:1:1: I001 Import block is un-sorted or un-formatted

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

Expand All @@ -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, session context.')(
session_start_command
)
app.command(hidden=True, name='ensure-auth', short_help='[Deprecated] Alias for session-start.')(
session_start_command
Comment thread
RoniCycode marked this conversation as resolved.
Outdated
)
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.

119 changes: 119 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,121 @@ 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 get_enabled_plugins(settings: dict) -> Optional[dict]:
Comment thread
RoniCycode marked this conversation as resolved.
Outdated
"""Extract enabled plugins from Claude settings.

Reads enabledPlugins from the settings dict.
"""
return settings.get('enabledPlugins')


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
2 changes: 1 addition & 1 deletion cycode/cli/apps/ai_guardrails/scan/payload.py
Original file line number Diff line number Diff line change
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
Loading
Loading