Skip to content
Merged
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
19 changes: 19 additions & 0 deletions cycode/cli/apps/ai_guardrails/ides/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,25 @@ def get_ide(name: str) -> IDE:
return ide


def collect_all_session_contexts() -> tuple[dict[str, dict], dict]:
"""Sweep every registered IDE's session context, regardless of which IDE triggered the hook.

Returns ``(config_files_by_ide, plugins)``: the global MCP config file of each IDE that has
one (keyed by IDE name), and the enabled plugins merged across IDEs (first registered IDE
wins on a duplicate plugin key - plugins are IDE-agnostic marketplace artifacts).
"""
config_files_by_ide: dict[str, dict] = {}
plugins: dict = {}
for ide in IDES.values():
global_config_file, enabled_plugins = ide.get_session_context()
if global_config_file:
config_files_by_ide[ide.name] = global_config_file
for plugin_key, plugin in (enabled_plugins or {}).items():
plugins.setdefault(plugin_key, plugin)

return config_files_by_ide, plugins


def resolve_ides(name: str) -> list[IDE]:
"""Resolve an ``--ide`` argument to one or all IDE instances.

Expand Down
16 changes: 16 additions & 0 deletions cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@
logger = get_logger('AI Guardrails Plugins')


def resolve_cached_plugin_dir(cache_root: Path, marketplace: str, plugin_name: str) -> Optional[Path]:
"""Find ``<cache_root>/<marketplace>/<plugin>/<version-or-hash>/``.

Both Claude Code and Codex cache installed plugin content in this layout (the trailing
segment is a version for Claude, a content hash for Codex). If multiple are cached, pick
the most recently modified (name as a deterministic tie-breaker).
"""
base = cache_root / marketplace / plugin_name
if not base.is_dir():
return None
candidates = [d for d in base.iterdir() if d.is_dir()]
if not candidates:
return None
return max(candidates, key=lambda d: (d.stat().st_mtime, d.name))


def load_plugin_json(path: Path) -> Optional[dict]:
"""Load a JSON file inside a plugin directory; None if missing or invalid."""
if not path.exists():
Expand Down
26 changes: 18 additions & 8 deletions cycode/cli/apps/ai_guardrails/ides/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
build_global_config_file,
load_plugin_json,
resolve_cached_plugin_dir,
walk_enabled_plugins,
)
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
Expand Down Expand Up @@ -164,6 +165,11 @@ def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]
return None


def _plugins_cache_dir() -> Path:
"""Claude Code's local plugin content cache: ``~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/``."""
return Path.home() / '.claude' / 'plugins' / 'cache'


def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]:
"""Resolve filesystem path for a directory-type marketplace."""
source = marketplace.get('source', {})
Expand Down Expand Up @@ -194,25 +200,29 @@ def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]:
if servers:
entry['mcp_server_names'] = list(servers.keys())
entry['mcp_config_file_path'] = str(mcp_config_path)
entry['mcp_config_file'] = json.dumps(mcp_config)
entry['mcp_config_file'] = json.dumps({'mcpServers': servers})
return entry, servers


def resolve_plugins(settings: dict) -> dict:
"""Walk Claude Code's ``enabledPlugins`` via the shared plugin walker.

Each enabled plugin's marketplace is resolved through
``extraKnownMarketplaces`` to a directory; the rest of the work
(manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``.
Directory-type marketplaces resolve through ``extraKnownMarketplaces``; all
other source types (git, github, ...) resolve through the local plugin cache.
The rest of the work (manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``.
"""
enabled = settings.get('enabledPlugins') or {}
marketplaces = settings.get('extraKnownMarketplaces') or {}

def _locate(_plugin_name: str, marketplace_name: str) -> Optional[Path]:
def _locate(plugin_name: str, marketplace_name: str) -> Optional[Path]:
# Directory-type marketplaces point straight at the plugin source; every other source
# type (git, github, ...) is cloned into the local plugin cache.
marketplace = marketplaces.get(marketplace_name)
if not marketplace:
return None
return _resolve_marketplace_path(marketplace)
if marketplace:
marketplace_path = _resolve_marketplace_path(marketplace)
if marketplace_path is not None:
return marketplace_path
return resolve_cached_plugin_dir(_plugins_cache_dir(), marketplace_name, plugin_name)

return walk_enabled_plugins(
plugin_entries=enabled,
Expand Down
17 changes: 4 additions & 13 deletions cycode/cli/apps/ai_guardrails/ides/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
build_global_config_file,
load_plugin_json,
resolve_cached_plugin_dir,
walk_enabled_plugins,
)
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
Expand Down Expand Up @@ -100,18 +101,8 @@ def _email_from_auth(auth_path: Optional[Path] = None) -> Optional[str]:


def _resolve_codex_plugin_dir(plugin_name: str, marketplace: str) -> Optional[Path]:
"""Find ``~/.codex/plugins/cache/<marketplace>/<plugin>/<hash>/``.

The trailing segment is a content hash. If multiple are cached, pick the
most recently modified.
"""
base = _codex_home() / 'plugins' / 'cache' / marketplace / plugin_name
if not base.is_dir():
return None
candidates = [d for d in base.iterdir() if d.is_dir()]
if not candidates:
return None
return max(candidates, key=lambda d: d.stat().st_mtime)
"""Find ``~/.codex/plugins/cache/<marketplace>/<plugin>/<hash>/``."""
return resolve_cached_plugin_dir(_codex_home() / 'plugins' / 'cache', marketplace, plugin_name)


def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]:
Expand Down Expand Up @@ -141,7 +132,7 @@ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]:
if servers:
entry['mcp_server_names'] = list(servers.keys())
entry['mcp_config_file_path'] = str(mcp_config_path)
entry['mcp_config_file'] = json.dumps(mcp_doc)
entry['mcp_config_file'] = json.dumps({'mcpServers': servers})
return entry, servers


Expand Down
101 changes: 79 additions & 22 deletions cycode/cli/apps/ai_guardrails/session_start_command.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
"""Handle AI guardrails session start: auth, conversation creation, session context."""

import hashlib
import json
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Optional

import typer

from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide
from cycode.cli.apps.ai_guardrails.ides.base import IDE
from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, collect_all_session_contexts, get_ide
from cycode.cli.apps.ai_guardrails.scan.utils import read_stdin_text, safe_json_parse
from cycode.cli.apps.auth.auth_common import get_authorization_info
from cycode.cli.apps.auth.auth_manager import AuthManager
Expand All @@ -26,23 +29,76 @@

logger = get_logger('AI Guardrails')

_SESSION_CONTEXT_CACHE_FILE = '.session-context-cache'
_SESSION_CONTEXT_TTL_SECONDS = 7 * 24 * 60 * 60

def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None:
"""Report IDE session context to the AI security manager. Never raises."""

def _session_context_cache_path() -> Path:
return Path.home() / '.cycode' / _SESSION_CONTEXT_CACHE_FILE


def _session_context_digest(report: dict) -> str:
"""Deterministic hash of the outgoing payload (not the raw config files, which churn)."""
canonical = json.dumps(report, sort_keys=True, separators=(',', ':'), default=str)
return hashlib.sha256(canonical.encode('utf-8')).hexdigest()


def _should_skip_report(digest: str, tenant_id: Optional[str]) -> bool:
"""Skip when the same payload was already sent for this tenant and the TTL hasn't expired."""
try:
global_config_file, enabled_plugins = ide.get_session_context()
if not global_config_file and not enabled_plugins:
return
ai_client.report_session_context(
hostname=get_hostname(),
platform_name=get_platform_name(),
os_version=get_os_version(),
serial_number=get_serial_number(),
last_login_user=get_last_login_user(),
global_config_file=global_config_file,
enabled_plugins=enabled_plugins,
user_email=user_email,
cache = json.loads(_session_context_cache_path().read_text(encoding='utf-8'))
return (
cache.get('hash') == digest
and cache.get('tenant_id') == tenant_id
and time.time() - float(cache.get('sent_at', 0)) < _SESSION_CONTEXT_TTL_SECONDS
)
except Exception:
# Missing/corrupt cache reads as a miss - over-sending is harmless
return False


def _save_report_cache(digest: str, tenant_id: Optional[str]) -> None:
try:
cache_path = _session_context_cache_path()
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(
json.dumps({'hash': digest, 'tenant_id': tenant_id, 'sent_at': time.time()}), encoding='utf-8'
)
except Exception as e:
logger.debug('Failed to write session context cache', exc_info=e)


def _report_session_context(
ai_client: 'AISecurityManagerClient',
user_email: Optional[str],
tenant_id: Optional[str],
) -> None:
"""Report the device + cross-IDE session context to the AI security manager. Never raises.

The device context is always reported. MCP configs are collected from every registered IDE,
not just the triggering one. Unchanged payloads are skipped via a hash cache until the TTL expires.
"""
try:
config_files_by_ide, enabled_plugins = collect_all_session_contexts()
report = {
'hostname': get_hostname(),
'platform_name': get_platform_name(),
'os_version': get_os_version(),
'serial_number': get_serial_number(),
'last_login_user': get_last_login_user(),
# Sorted by path so the digest is stable regardless of IDE registry order.
'config_files': sorted(config_files_by_ide.values(), key=lambda f: f['path']),
'enabled_plugins': enabled_plugins,
'user_email': user_email,
}

digest = _session_context_digest(report)
if _should_skip_report(digest, tenant_id):
logger.debug('Session context unchanged; skipping report')
return

if ai_client.report_session_context(**report):
_save_report_cache(digest, tenant_id)
except Exception as e:
logger.debug('Failed to report session context', exc_info=e)

Expand All @@ -61,7 +117,7 @@ def session_start_command(
"""Handle session start: ensure auth, create conversation, report session context."""
ide_integration = get_ide(ide)

# Step 1: Ensure authentication
# Ensure authentication
auth_info = get_authorization_info(ctx)
if auth_info is None:
logger.debug('Not authenticated, starting authentication')
Expand All @@ -70,10 +126,11 @@ def session_start_command(
except Exception as err:
handle_auth_exception(ctx, err)
return
auth_info = get_authorization_info(ctx)
else:
logger.debug('Already authenticated')

# Step 2: Read stdin payload (backward compat: old hooks pipe no stdin)
# Read stdin payload (backward compat: old hooks pipe no stdin)
if sys.stdin.isatty():
logger.debug('No stdin payload (TTY), skipping session initialization')
return
Expand All @@ -84,7 +141,7 @@ def session_start_command(
logger.debug('Empty or invalid stdin payload, skipping session initialization')
return

# Step 3: Build session payload + initialize API client
# Build session payload + initialize API client
session_payload = ide_integration.build_session_payload(payload)

try:
Expand All @@ -93,11 +150,11 @@ def session_start_command(
logger.debug('Failed to initialize AI security client', exc_info=e)
return

# Step 4: Create conversation
# 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 session context (MCP servers, enabled plugins)
_report_session_context(ai_client, ide_integration, session_payload.ide_user_email)
# Report session context (device + cross-IDE MCP servers and plugins)
_report_session_context(ai_client, session_payload.ide_user_email, auth_info.tenant_id)
10 changes: 6 additions & 4 deletions cycode/cyclient/ai_security_manager_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,24 +98,26 @@ def report_session_context(
os_version: Optional[str] = None,
serial_number: Optional[str] = None,
last_login_user: Optional[str] = None,
global_config_file: Optional[dict] = None,
config_files: Optional[list[dict]] = None,
enabled_plugins: Optional[dict] = None,
user_email: Optional[str] = None,
) -> None:
"""Report session context to the backend."""
) -> bool:
"""Report session context to the backend. Returns whether the report was accepted."""
body: dict = {
'hostname': hostname,
'platform_name': platform_name,
'os_version': os_version,
'serial_number': serial_number,
'last_login_user': last_login_user,
'user_email': user_email,
'global_config_file': global_config_file,
'config_files': config_files,
'enabled_plugins': enabled_plugins,
}

try:
self.client.post(self._build_endpoint_path(self._SESSION_CONTEXT_PATH), body=body)
return True
except Exception as e:
logger.debug('Failed to report session context', exc_info=e)
# Don't fail the session if reporting fails
return False
Loading
Loading