Skip to content

Commit f720073

Browse files
Ilanlidoclaude
andcommitted
CM-68342: sweep all IDEs' session context and dedup reports
Session start now reports the device context unconditionally (previously a machine with no MCPs in the triggering IDE never created a device entity) and collects MCP configs from every registered IDE, not just the triggering one, sent as the config_files list. Unchanged payloads are skipped via a sha256 cache (~/.cycode/.session-context-cache, tenant-aware, 7-day TTL, written only after a successful send) so the common session start makes one API call instead of two. Also: resolve plugins of git/github-sourced marketplaces through ~/.claude/plugins/cache (previously only directory-type marketplaces resolved, so their MCPs were silently missing), normalize plugin MCP content to the canonical {"mcpServers": ...} shape, and drop the legacy global_config_file field (the backend keeps its fallback for older CLIs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 23158f5 commit f720073

9 files changed

Lines changed: 416 additions & 134 deletions

File tree

cycode/cli/apps/ai_guardrails/ides/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,25 @@ def get_ide(name: str) -> IDE:
3434
return ide
3535

3636

37+
def collect_all_session_contexts() -> tuple[dict[str, dict], dict]:
38+
"""Sweep every registered IDE's session context, regardless of which IDE triggered the hook.
39+
40+
Returns ``(config_files_by_ide, plugins)``: the global MCP config file of each IDE that has
41+
one (keyed by IDE name), and the enabled plugins merged across IDEs (first registered IDE
42+
wins on a duplicate plugin key - plugins are IDE-agnostic marketplace artifacts).
43+
"""
44+
config_files_by_ide: dict[str, dict] = {}
45+
plugins: dict = {}
46+
for ide in IDES.values():
47+
global_config_file, enabled_plugins = ide.get_session_context()
48+
if global_config_file:
49+
config_files_by_ide[ide.name] = global_config_file
50+
for plugin_key, plugin in (enabled_plugins or {}).items():
51+
plugins.setdefault(plugin_key, plugin)
52+
53+
return config_files_by_ide, plugins
54+
55+
3756
def resolve_ides(name: str) -> list[IDE]:
3857
"""Resolve an ``--ide`` argument to one or all IDE instances.
3958

cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@
1515
logger = get_logger('AI Guardrails Plugins')
1616

1717

18+
def resolve_cached_plugin_dir(cache_root: Path, marketplace: str, plugin_name: str) -> Optional[Path]:
19+
"""Find ``<cache_root>/<marketplace>/<plugin>/<version-or-hash>/``.
20+
21+
Both Claude Code and Codex cache installed plugin content in this layout (the trailing
22+
segment is a version for Claude, a content hash for Codex). If multiple are cached, pick
23+
the most recently modified.
24+
"""
25+
base = cache_root / marketplace / plugin_name
26+
if not base.is_dir():
27+
return None
28+
candidates = [d for d in base.iterdir() if d.is_dir()]
29+
if not candidates:
30+
return None
31+
return max(candidates, key=lambda d: d.stat().st_mtime)
32+
33+
1834
def load_plugin_json(path: Path) -> Optional[dict]:
1935
"""Load a JSON file inside a plugin directory; None if missing or invalid."""
2036
if not path.exists():

cycode/cli/apps/ai_guardrails/ides/claude_code.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
1111
build_global_config_file,
1212
load_plugin_json,
13+
resolve_cached_plugin_dir,
1314
walk_enabled_plugins,
1415
)
1516
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
@@ -164,6 +165,11 @@ def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]
164165
return None
165166

166167

168+
def _plugins_cache_dir() -> Path:
169+
"""Claude Code's local plugin content cache: ``~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/``."""
170+
return Path.home() / '.claude' / 'plugins' / 'cache'
171+
172+
167173
def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]:
168174
"""Resolve filesystem path for a directory-type marketplace."""
169175
source = marketplace.get('source', {})
@@ -194,25 +200,29 @@ def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]:
194200
if servers:
195201
entry['mcp_server_names'] = list(servers.keys())
196202
entry['mcp_config_file_path'] = str(mcp_config_path)
197-
entry['mcp_config_file'] = json.dumps(mcp_config)
203+
entry['mcp_config_file'] = json.dumps({'mcpServers': servers})
198204
return entry, servers
199205

200206

201207
def resolve_plugins(settings: dict) -> dict:
202208
"""Walk Claude Code's ``enabledPlugins`` via the shared plugin walker.
203209
204-
Each enabled plugin's marketplace is resolved through
205-
``extraKnownMarketplaces`` to a directory; the rest of the work
206-
(manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``.
210+
Directory-type marketplaces resolve through ``extraKnownMarketplaces``; all
211+
other source types (git, github, ...) resolve through the local plugin cache.
212+
The rest of the work (manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``.
207213
"""
208214
enabled = settings.get('enabledPlugins') or {}
209215
marketplaces = settings.get('extraKnownMarketplaces') or {}
210216

211-
def _locate(_plugin_name: str, marketplace_name: str) -> Optional[Path]:
217+
def _locate(plugin_name: str, marketplace_name: str) -> Optional[Path]:
218+
# Directory-type marketplaces point straight at the plugin source; every other source
219+
# type (git, github, ...) is cloned into the local plugin cache.
212220
marketplace = marketplaces.get(marketplace_name)
213-
if not marketplace:
214-
return None
215-
return _resolve_marketplace_path(marketplace)
221+
if marketplace:
222+
marketplace_path = _resolve_marketplace_path(marketplace)
223+
if marketplace_path is not None:
224+
return marketplace_path
225+
return resolve_cached_plugin_dir(_plugins_cache_dir(), marketplace_name, plugin_name)
216226

217227
return walk_enabled_plugins(
218228
plugin_entries=enabled,

cycode/cli/apps/ai_guardrails/ides/codex.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
1818
build_global_config_file,
1919
load_plugin_json,
20+
resolve_cached_plugin_dir,
2021
walk_enabled_plugins,
2122
)
2223
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
@@ -100,18 +101,8 @@ def _email_from_auth(auth_path: Optional[Path] = None) -> Optional[str]:
100101

101102

102103
def _resolve_codex_plugin_dir(plugin_name: str, marketplace: str) -> Optional[Path]:
103-
"""Find ``~/.codex/plugins/cache/<marketplace>/<plugin>/<hash>/``.
104-
105-
The trailing segment is a content hash. If multiple are cached, pick the
106-
most recently modified.
107-
"""
108-
base = _codex_home() / 'plugins' / 'cache' / marketplace / plugin_name
109-
if not base.is_dir():
110-
return None
111-
candidates = [d for d in base.iterdir() if d.is_dir()]
112-
if not candidates:
113-
return None
114-
return max(candidates, key=lambda d: d.stat().st_mtime)
104+
"""Find ``~/.codex/plugins/cache/<marketplace>/<plugin>/<hash>/``."""
105+
return resolve_cached_plugin_dir(_codex_home() / 'plugins' / 'cache', marketplace, plugin_name)
115106

116107

117108
def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]:
@@ -141,7 +132,7 @@ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]:
141132
if servers:
142133
entry['mcp_server_names'] = list(servers.keys())
143134
entry['mcp_config_file_path'] = str(mcp_config_path)
144-
entry['mcp_config_file'] = json.dumps(mcp_doc)
135+
entry['mcp_config_file'] = json.dumps({'mcpServers': servers})
145136
return entry, servers
146137

147138

cycode/cli/apps/ai_guardrails/session_start_command.py

Lines changed: 78 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
"""Handle AI guardrails session start: auth, conversation creation, session context."""
22

3+
import hashlib
4+
import json
35
import sys
6+
import time
7+
from pathlib import Path
48
from typing import TYPE_CHECKING, Annotated, Optional
59

610
import typer
711

8-
from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide
9-
from cycode.cli.apps.ai_guardrails.ides.base import IDE
12+
from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, collect_all_session_contexts, get_ide
1013
from cycode.cli.apps.ai_guardrails.scan.utils import read_stdin_text, safe_json_parse
1114
from cycode.cli.apps.auth.auth_common import get_authorization_info
1215
from cycode.cli.apps.auth.auth_manager import AuthManager
@@ -26,23 +29,75 @@
2629

2730
logger = get_logger('AI Guardrails')
2831

32+
_SESSION_CONTEXT_CACHE_FILE = '.session-context-cache'
33+
_SESSION_CONTEXT_TTL_SECONDS = 7 * 24 * 60 * 60
2934

30-
def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None:
31-
"""Report IDE session context to the AI security manager. Never raises."""
35+
36+
def _session_context_cache_path() -> Path:
37+
return Path.home() / '.cycode' / _SESSION_CONTEXT_CACHE_FILE
38+
39+
40+
def _session_context_digest(report: dict) -> str:
41+
"""Deterministic hash of the outgoing payload (not the raw config files, which churn)."""
42+
canonical = json.dumps(report, sort_keys=True, separators=(',', ':'), default=str)
43+
return hashlib.sha256(canonical.encode('utf-8')).hexdigest()
44+
45+
46+
def _should_skip_report(digest: str, tenant_id: Optional[str]) -> bool:
47+
"""Skip when the same payload was already sent for this tenant and the TTL hasn't expired."""
3248
try:
33-
global_config_file, enabled_plugins = ide.get_session_context()
34-
if not global_config_file and not enabled_plugins:
35-
return
36-
ai_client.report_session_context(
37-
hostname=get_hostname(),
38-
platform_name=get_platform_name(),
39-
os_version=get_os_version(),
40-
serial_number=get_serial_number(),
41-
last_login_user=get_last_login_user(),
42-
global_config_file=global_config_file,
43-
enabled_plugins=enabled_plugins,
44-
user_email=user_email,
49+
cache = json.loads(_session_context_cache_path().read_text(encoding='utf-8'))
50+
return (
51+
cache.get('hash') == digest
52+
and cache.get('tenant_id') == tenant_id
53+
and time.time() - float(cache.get('sent_at', 0)) < _SESSION_CONTEXT_TTL_SECONDS
54+
)
55+
except Exception:
56+
# Missing/corrupt cache reads as a miss - over-sending is harmless
57+
return False
58+
59+
60+
def _save_report_cache(digest: str, tenant_id: Optional[str]) -> None:
61+
try:
62+
cache_path = _session_context_cache_path()
63+
cache_path.parent.mkdir(parents=True, exist_ok=True)
64+
cache_path.write_text(
65+
json.dumps({'hash': digest, 'tenant_id': tenant_id, 'sent_at': time.time()}), encoding='utf-8'
4566
)
67+
except Exception as e:
68+
logger.debug('Failed to write session context cache', exc_info=e)
69+
70+
71+
def _report_session_context(
72+
ai_client: 'AISecurityManagerClient',
73+
user_email: Optional[str],
74+
tenant_id: Optional[str],
75+
) -> None:
76+
"""Report the device + cross-IDE session context to the AI security manager. Never raises.
77+
78+
The device context is always reported. MCP configs are collected from every registered IDE,
79+
not just the triggering one. Unchanged payloads are skipped via a hash cache until the TTL expires.
80+
"""
81+
try:
82+
config_files_by_ide, enabled_plugins = collect_all_session_contexts()
83+
report = {
84+
'hostname': get_hostname(),
85+
'platform_name': get_platform_name(),
86+
'os_version': get_os_version(),
87+
'serial_number': get_serial_number(),
88+
'last_login_user': get_last_login_user(),
89+
'config_files': list(config_files_by_ide.values()),
90+
'enabled_plugins': enabled_plugins,
91+
'user_email': user_email,
92+
}
93+
94+
digest = _session_context_digest(report)
95+
if _should_skip_report(digest, tenant_id):
96+
logger.debug('Session context unchanged; skipping report')
97+
return
98+
99+
if ai_client.report_session_context(**report):
100+
_save_report_cache(digest, tenant_id)
46101
except Exception as e:
47102
logger.debug('Failed to report session context', exc_info=e)
48103

@@ -61,7 +116,7 @@ def session_start_command(
61116
"""Handle session start: ensure auth, create conversation, report session context."""
62117
ide_integration = get_ide(ide)
63118

64-
# Step 1: Ensure authentication
119+
# Ensure authentication
65120
auth_info = get_authorization_info(ctx)
66121
if auth_info is None:
67122
logger.debug('Not authenticated, starting authentication')
@@ -70,10 +125,11 @@ def session_start_command(
70125
except Exception as err:
71126
handle_auth_exception(ctx, err)
72127
return
128+
auth_info = get_authorization_info(ctx)
73129
else:
74130
logger.debug('Already authenticated')
75131

76-
# Step 2: Read stdin payload (backward compat: old hooks pipe no stdin)
132+
# Read stdin payload (backward compat: old hooks pipe no stdin)
77133
if sys.stdin.isatty():
78134
logger.debug('No stdin payload (TTY), skipping session initialization')
79135
return
@@ -84,7 +140,7 @@ def session_start_command(
84140
logger.debug('Empty or invalid stdin payload, skipping session initialization')
85141
return
86142

87-
# Step 3: Build session payload + initialize API client
143+
# Build session payload + initialize API client
88144
session_payload = ide_integration.build_session_payload(payload)
89145

90146
try:
@@ -93,11 +149,11 @@ def session_start_command(
93149
logger.debug('Failed to initialize AI security client', exc_info=e)
94150
return
95151

96-
# Step 4: Create conversation
152+
# Create conversation
97153
try:
98154
ai_client.create_conversation(session_payload)
99155
except Exception as e:
100156
logger.debug('Failed to create conversation during session start', exc_info=e)
101157

102-
# Step 5: Report session context (MCP servers, enabled plugins)
103-
_report_session_context(ai_client, ide_integration, session_payload.ide_user_email)
158+
# Report session context (device + cross-IDE MCP servers and plugins)
159+
_report_session_context(ai_client, session_payload.ide_user_email, auth_info.tenant_id)

cycode/cyclient/ai_security_manager_client.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,24 +98,26 @@ def report_session_context(
9898
os_version: Optional[str] = None,
9999
serial_number: Optional[str] = None,
100100
last_login_user: Optional[str] = None,
101-
global_config_file: Optional[dict] = None,
101+
config_files: Optional[list[dict]] = None,
102102
enabled_plugins: Optional[dict] = None,
103103
user_email: Optional[str] = None,
104-
) -> None:
105-
"""Report session context to the backend."""
104+
) -> bool:
105+
"""Report session context to the backend. Returns whether the report was accepted."""
106106
body: dict = {
107107
'hostname': hostname,
108108
'platform_name': platform_name,
109109
'os_version': os_version,
110110
'serial_number': serial_number,
111111
'last_login_user': last_login_user,
112112
'user_email': user_email,
113-
'global_config_file': global_config_file,
113+
'config_files': config_files,
114114
'enabled_plugins': enabled_plugins,
115115
}
116116

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

0 commit comments

Comments
 (0)