Skip to content

Commit 055cf3a

Browse files
Altruistusclaude
andauthored
CM-71972: Collect Claude Code skills in the Guardrails session sweep (#538)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a16fa2c commit 055cf3a

16 files changed

Lines changed: 594 additions & 5 deletions

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,22 @@ def collect_all_session_contexts() -> tuple[dict[str, dict], dict]:
5454
return config_files_by_ide, plugins
5555

5656

57+
def collect_all_skills() -> list[dict]:
58+
"""Sweep every registered IDE's user-scope skills, regardless of which IDE triggered the hook.
59+
60+
Returns ``[{"path", "content"}]`` deduplicated by path and sorted, so two IDEs sharing a skills
61+
directory report it once and the session-context digest stays stable across registry order.
62+
Skills a plugin ships are not here - those ride on their plugin entry, which carries the
63+
marketplace provenance.
64+
"""
65+
skills_by_path: dict[str, dict] = {}
66+
for ide in IDES.values():
67+
for skill in ide.get_skills():
68+
skills_by_path.setdefault(skill['path'], skill)
69+
70+
return [skills_by_path[path] for path in sorted(skills_by_path)]
71+
72+
5773
def resolve_ides(name: str) -> list[IDE]:
5874
"""Resolve an ``--ide`` argument to one or all IDE instances.
5975
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Shared skill-collection helpers for IDE integrations.
2+
3+
A skill is a directory holding a ``SKILL.md``: ``<skills root>/<skill name>/SKILL.md``.
4+
The same layout is used for user-scope skills (``~/.claude/skills/``) and for the skills a
5+
plugin ships (``<plugin dir>/skills/``), so one walker serves both.
6+
7+
Unlike an MCP config - a small JSON file at a known path - a ``SKILL.md`` body is unbounded
8+
prose, and the number of installed skills is unbounded too. Both are capped here rather than
9+
downstream: the whole session-context report is one request, so an oversized skill would cost
10+
the device its MCP inventory as well.
11+
"""
12+
13+
from pathlib import Path
14+
from typing import Optional
15+
16+
from cycode.logger import get_logger
17+
18+
logger = get_logger('AI Guardrails Skills')
19+
20+
SKILL_FILE_NAME = 'SKILL.md'
21+
22+
# Where a plugin keeps its skills, relative to the plugin directory. A property of the plugin format
23+
# rather than of any one IDE, so Claude Code, Codex and Copilot plugins all use it.
24+
PLUGIN_SKILLS_SUBDIR = 'skills'
25+
26+
# A skill is instructions, not data. Anything larger is not a skill we can usefully inventory,
27+
# and sending it would push the one-request report toward the API's body limit.
28+
MAX_SKILL_FILE_BYTES = 256 * 1024
29+
30+
# Per skills root, not per device: a developer with more installed skills than this in one place
31+
# is an outlier we would rather truncate than let define the payload size.
32+
MAX_SKILLS_PER_ROOT = 200
33+
34+
35+
def _read_skill_file(skill_file: Path) -> Optional[dict]:
36+
"""Read one ``SKILL.md`` into the session-context file shape, or None if unusable."""
37+
try:
38+
size = skill_file.stat().st_size
39+
except OSError as e:
40+
logger.debug('Failed to stat skill file, %s', {'path': str(skill_file)}, exc_info=e)
41+
return None
42+
43+
if size > MAX_SKILL_FILE_BYTES:
44+
logger.debug(
45+
'Skill file exceeds the size cap; skipping, %s',
46+
{'path': str(skill_file), 'size': size, 'cap': MAX_SKILL_FILE_BYTES},
47+
)
48+
return None
49+
50+
try:
51+
content = skill_file.read_text(encoding='utf-8')
52+
except Exception as e:
53+
logger.debug('Failed to read skill file, %s', {'path': str(skill_file)}, exc_info=e)
54+
return None
55+
56+
if not content.strip():
57+
return None
58+
59+
return {'path': str(skill_file), 'content': content}
60+
61+
62+
def walk_skill_dirs(skills_root: Path) -> list[dict]:
63+
"""Collect every ``<skills_root>/<name>/SKILL.md`` as ``{"path", "content"}``.
64+
65+
Exactly one directory level is scanned. A skill directory may hold nested references and
66+
scripts, but its ``SKILL.md`` always sits at the top of it, so there is nothing to recurse
67+
into - which is also what keeps this bounded without a depth cap.
68+
69+
Results are sorted by path: the session-context report is deduplicated by hashing the whole
70+
payload, so an unstable order would re-send an unchanged inventory.
71+
"""
72+
if not skills_root.is_dir():
73+
return []
74+
75+
try:
76+
skill_dirs = sorted(d for d in skills_root.iterdir() if d.is_dir())
77+
except OSError as e:
78+
logger.debug('Failed to list skills root, %s', {'path': str(skills_root)}, exc_info=e)
79+
return []
80+
81+
skills: list[dict] = []
82+
for skill_dir in skill_dirs:
83+
if len(skills) >= MAX_SKILLS_PER_ROOT:
84+
logger.debug(
85+
'Skills root exceeds the count cap; truncating, %s',
86+
{'path': str(skills_root), 'cap': MAX_SKILLS_PER_ROOT},
87+
)
88+
break
89+
90+
skill = _read_skill_file(skill_dir / SKILL_FILE_NAME)
91+
if skill:
92+
skills.append(skill)
93+
94+
return skills
95+
96+
97+
def walk_plugin_skills(plugin_dir: Path) -> list[dict]:
98+
"""Collect the skills a plugin ships, from ``<plugin_dir>/skills/<name>/SKILL.md``.
99+
100+
Shared by every IDE with a plugin system: the layout belongs to the plugin format, so a plugin
101+
shipping skills is inventoried whichever IDE loaded it.
102+
"""
103+
return walk_skill_dirs(plugin_dir / PLUGIN_SKILLS_SUBDIR)

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,17 @@ def get_session_context(self) -> tuple[Optional[dict], dict]:
189189
Override to surface MCP/plugin inventory.
190190
"""
191191
return None, {}
192+
193+
def get_skills(self) -> list[dict]:
194+
"""Return the IDE's user-scope skills as ``[{"path", "content"}]``.
195+
196+
A skill is a ``SKILL.md`` under a per-skill directory. Raw content is returned rather
197+
than parsed frontmatter: the backend owns parsing, because the device connectors that
198+
read these files off endpoints can only ever return raw content.
199+
200+
Kept separate from ``get_session_context`` rather than folded into its
201+
``global_config_file`` slot, which is normalized to an MCP server map.
202+
203+
Default: ``[]`` (the IDE has no skill system). Override to surface skills.
204+
"""
205+
return []

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
resolve_cached_plugin_dir,
1414
walk_enabled_plugins,
1515
)
16+
from cycode.cli.apps.ai_guardrails.ides._skill_utils import walk_plugin_skills, walk_skill_dirs
1617
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
1718
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
1819
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -169,6 +170,15 @@ def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]
169170
return None
170171

171172

173+
def _claude_skills_dir() -> Path:
174+
"""Claude Code's user-scope skills: ``~/.claude/skills/<name>/SKILL.md``.
175+
176+
A function, not a module constant: resolving ``Path.home()`` at import time pins the directory to
177+
whatever home the process started with, which a test filesystem then cannot redirect.
178+
"""
179+
return Path.home() / '.claude' / 'skills'
180+
181+
172182
def _plugins_cache_dir() -> Path:
173183
"""Claude Code's local plugin content cache: ``~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/``."""
174184
return Path.home() / '.claude' / 'plugins' / 'cache'
@@ -187,17 +197,24 @@ def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]:
187197

188198

189199
def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]:
190-
"""Read one Claude Code plugin's manifest + MCP servers.
200+
"""Read one Claude Code plugin's manifest, MCP servers and skills.
191201
192202
Claude hardcodes the MCP file at ``<plugin_dir>/.mcp.json`` and always
193-
wraps it as ``{"mcpServers": {...}}``.
203+
wraps it as ``{"mcpServers": {...}}``, and a plugin's skills at
204+
``<plugin_dir>/skills/<name>/SKILL.md``.
194205
"""
195206
manifest = load_plugin_json(plugin_dir / '.claude-plugin' / 'plugin.json') or {}
196207
entry: dict = {}
197208
for field in ('name', 'version', 'description'):
198209
if field in manifest:
199210
entry[field] = manifest[field]
200211

212+
# Attached to the plugin entry rather than the top-level skills list so the backend keeps the
213+
# plugin provenance (marketplace, plugin, version) that a marketplace-installed skill has.
214+
skill_files = walk_plugin_skills(plugin_dir)
215+
if skill_files:
216+
entry['skill_files'] = skill_files
217+
201218
mcp_config_path = plugin_dir / '.mcp.json'
202219
mcp_config = load_plugin_json(mcp_config_path) or {}
203220
servers: dict = mcp_config.get('mcpServers') or {}
@@ -387,3 +404,6 @@ def get_session_context(self) -> tuple[Optional[dict], dict]:
387404
enriched_plugins = resolve_plugins(settings) if settings else {}
388405

389406
return global_config_file, enriched_plugins
407+
408+
def get_skills(self) -> list[dict]:
409+
return walk_skill_dirs(_claude_skills_dir())

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
resolve_cached_plugin_dir,
2121
walk_enabled_plugins,
2222
)
23+
from cycode.cli.apps.ai_guardrails.ides._skill_utils import walk_plugin_skills, walk_skill_dirs
2324
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
2425
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
2526
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -53,6 +54,11 @@ def _codex_home() -> Path:
5354
return Path.home() / _CONFIG_DIR_NAME
5455

5556

57+
def _codex_skills_dir() -> Path:
58+
"""User-scope Codex skills directory (honors ``$CODEX_HOME``)."""
59+
return _codex_home() / 'skills'
60+
61+
5662
def _codex_config_toml_path(scope: str, repo_path: Optional[Path] = None) -> Path:
5763
"""Return the Codex ``config.toml`` path for the given scope."""
5864
if scope == 'repo' and repo_path:
@@ -121,6 +127,12 @@ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]:
121127
if field in manifest:
122128
entry[field] = manifest[field]
123129

130+
# Same plugin-format layout as every other IDE's plugins, so a plugin shipping skills is
131+
# inventoried whichever IDE loaded it.
132+
skill_files = walk_plugin_skills(plugin_dir)
133+
if skill_files:
134+
entry['skill_files'] = skill_files
135+
124136
mcp_ref = manifest.get('mcpServers')
125137
if not mcp_ref:
126138
return entry, {}
@@ -304,3 +316,6 @@ def get_session_context(self) -> tuple[Optional[dict], dict]:
304316
global_config_file = build_global_config_file(config_path, config.get('mcp_servers'))
305317
enriched_plugins = _resolve_codex_plugins(config)
306318
return global_config_file, enriched_plugins
319+
320+
def get_skills(self) -> list[dict]:
321+
return walk_skill_dirs(_codex_skills_dir())

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
load_plugin_json,
3838
walk_enabled_plugins,
3939
)
40+
from cycode.cli.apps.ai_guardrails.ides._skill_utils import walk_plugin_skills, walk_skill_dirs
4041
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
4142
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
4243
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -105,6 +106,11 @@ def _copilot_home() -> Path:
105106
return Path.home() / '.copilot'
106107

107108

109+
def _copilot_skills_dir() -> Path:
110+
"""User-scope Copilot skills directory (honors ``$COPILOT_HOME``)."""
111+
return _copilot_home() / 'skills'
112+
113+
108114
def _vscode_agent_plugins_dir() -> Path:
109115
# Resolved at call time (not a module-level Path constant): on py<=3.10 a Path
110116
# instance binds its filesystem accessor at creation, which breaks fake-fs tests
@@ -178,6 +184,12 @@ def _read_copilot_plugin(plugin_dir: Path) -> tuple[dict, dict]:
178184
if field in manifest:
179185
entry[field] = manifest[field]
180186

187+
# Same plugin-format layout as every other IDE's plugins, so a plugin shipping skills is
188+
# inventoried whichever IDE loaded it.
189+
skill_files = walk_plugin_skills(plugin_dir)
190+
if skill_files:
191+
entry['skill_files'] = skill_files
192+
181193
mcp_ref = manifest.get('mcpServers')
182194
mcp_config_path = plugin_dir / mcp_ref if isinstance(mcp_ref, str) else plugin_dir / '.mcp.json'
183195
mcp_doc = load_plugin_json(mcp_config_path) or {}
@@ -481,3 +493,6 @@ def get_session_context(self) -> tuple[Optional[dict], dict]:
481493
build_global_config_file(_vscode_mcp_config_path(), config.get('servers')) if config else None
482494
)
483495
return global_config_file, _collect_installed_plugins()
496+
497+
def get_skills(self) -> list[dict]:
498+
return walk_skill_dirs(_copilot_skills_dir())

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
99
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import build_global_config_file
10+
from cycode.cli.apps.ai_guardrails.ides._skill_utils import walk_skill_dirs
1011
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
1112
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
1213
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -45,6 +46,11 @@ def _cursor_mcp_config_path() -> Path:
4546
return Path.home() / '.cursor' / _MCP_CONFIG_FILENAME
4647

4748

49+
def _cursor_skills_dir() -> Path:
50+
"""User-scope Cursor skills directory (``~/.cursor/skills``, all platforms)."""
51+
return Path.home() / '.cursor' / 'skills'
52+
53+
4854
def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]:
4955
"""Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid."""
5056
path = config_path or _cursor_mcp_config_path()
@@ -125,3 +131,6 @@ def get_session_context(self) -> tuple[Optional[dict], dict]:
125131
config_path = _cursor_mcp_config_path()
126132
global_config_file = build_global_config_file(config_path, config.get('mcpServers'))
127133
return global_config_file, {}
134+
135+
def get_skills(self) -> list[dict]:
136+
return walk_skill_dirs(_cursor_skills_dir())

cycode/cli/apps/ai_guardrails/session_start_command.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99

1010
import typer
1111

12-
from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, collect_all_session_contexts, get_ide
12+
from cycode.cli.apps.ai_guardrails.ides import (
13+
DEFAULT_IDE_NAME,
14+
collect_all_session_contexts,
15+
collect_all_skills,
16+
get_ide,
17+
)
1318
from cycode.cli.apps.ai_guardrails.scan.utils import read_stdin_text, safe_json_parse
1419
from cycode.cli.apps.auth.auth_common import get_authorization_info
1520
from cycode.cli.apps.auth.auth_manager import AuthManager
@@ -75,8 +80,9 @@ def _report_session_context(
7580
) -> None:
7681
"""Report the device + cross-IDE session context to the AI security manager. Never raises.
7782
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.
83+
The device context is always reported. MCP configs and skills are collected from every
84+
registered IDE, not just the triggering one. Unchanged payloads are skipped via a hash cache
85+
until the TTL expires.
8086
"""
8187
try:
8288
config_files_by_ide, enabled_plugins = collect_all_session_contexts()
@@ -89,6 +95,9 @@ def _report_session_context(
8995
# Sorted by path so the digest is stable regardless of IDE registry order.
9096
'config_files': sorted(config_files_by_ide.values(), key=lambda f: f['path']),
9197
'enabled_plugins': enabled_plugins,
98+
# Already deduplicated and sorted by path, for the same digest-stability reason.
99+
# Editing a skill body changes the digest and so re-reports the device's inventory.
100+
'skill_files': collect_all_skills(),
92101
'user_email': user_email,
93102
}
94103

cycode/cyclient/ai_security_manager_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def report_session_context(
101101
last_login_user: Optional[str] = None,
102102
config_files: Optional[list[dict]] = None,
103103
enabled_plugins: Optional[dict] = None,
104+
skill_files: Optional[list[dict]] = None,
104105
user_email: Optional[str] = None,
105106
) -> bool:
106107
"""Report session context to the backend. Returns whether the report was accepted."""
@@ -113,6 +114,7 @@ def report_session_context(
113114
'user_email': user_email,
114115
'config_files': config_files,
115116
'enabled_plugins': enabled_plugins,
117+
'skill_files': skill_files,
116118
}
117119

118120
try:

0 commit comments

Comments
 (0)