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
16 changes: 16 additions & 0 deletions cycode/cli/apps/ai_guardrails/ides/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ def collect_all_session_contexts() -> tuple[dict[str, dict], dict]:
return config_files_by_ide, plugins


def collect_all_skills() -> list[dict]:
"""Sweep every registered IDE's user-scope skills, regardless of which IDE triggered the hook.

Returns ``[{"path", "content"}]`` deduplicated by path and sorted, so two IDEs sharing a skills
directory report it once and the session-context digest stays stable across registry order.
Skills a plugin ships are not here - those ride on their plugin entry, which carries the
marketplace provenance.
"""
skills_by_path: dict[str, dict] = {}
for ide in IDES.values():
for skill in ide.get_skills():
skills_by_path.setdefault(skill['path'], skill)

return [skills_by_path[path] for path in sorted(skills_by_path)]


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

Expand Down
103 changes: 103 additions & 0 deletions cycode/cli/apps/ai_guardrails/ides/_skill_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Shared skill-collection helpers for IDE integrations.

A skill is a directory holding a ``SKILL.md``: ``<skills root>/<skill name>/SKILL.md``.
The same layout is used for user-scope skills (``~/.claude/skills/``) and for the skills a
plugin ships (``<plugin dir>/skills/``), so one walker serves both.

Unlike an MCP config - a small JSON file at a known path - a ``SKILL.md`` body is unbounded
prose, and the number of installed skills is unbounded too. Both are capped here rather than
downstream: the whole session-context report is one request, so an oversized skill would cost
the device its MCP inventory as well.
"""

from pathlib import Path
from typing import Optional

from cycode.logger import get_logger

logger = get_logger('AI Guardrails Skills')

SKILL_FILE_NAME = 'SKILL.md'

# Where a plugin keeps its skills, relative to the plugin directory. A property of the plugin format
# rather than of any one IDE, so Claude Code, Codex and Copilot plugins all use it.
PLUGIN_SKILLS_SUBDIR = 'skills'

# A skill is instructions, not data. Anything larger is not a skill we can usefully inventory,
# and sending it would push the one-request report toward the API's body limit.
MAX_SKILL_FILE_BYTES = 256 * 1024

# Per skills root, not per device: a developer with more installed skills than this in one place
# is an outlier we would rather truncate than let define the payload size.
MAX_SKILLS_PER_ROOT = 200


def _read_skill_file(skill_file: Path) -> Optional[dict]:
"""Read one ``SKILL.md`` into the session-context file shape, or None if unusable."""
try:
size = skill_file.stat().st_size
except OSError as e:
logger.debug('Failed to stat skill file, %s', {'path': str(skill_file)}, exc_info=e)
return None

if size > MAX_SKILL_FILE_BYTES:
logger.debug(
'Skill file exceeds the size cap; skipping, %s',
{'path': str(skill_file), 'size': size, 'cap': MAX_SKILL_FILE_BYTES},
)
return None

try:
content = skill_file.read_text(encoding='utf-8')
except Exception as e:
logger.debug('Failed to read skill file, %s', {'path': str(skill_file)}, exc_info=e)
return None

if not content.strip():
return None

return {'path': str(skill_file), 'content': content}


def walk_skill_dirs(skills_root: Path) -> list[dict]:
"""Collect every ``<skills_root>/<name>/SKILL.md`` as ``{"path", "content"}``.

Exactly one directory level is scanned. A skill directory may hold nested references and
scripts, but its ``SKILL.md`` always sits at the top of it, so there is nothing to recurse
into - which is also what keeps this bounded without a depth cap.

Results are sorted by path: the session-context report is deduplicated by hashing the whole
payload, so an unstable order would re-send an unchanged inventory.
"""
if not skills_root.is_dir():
return []

try:
skill_dirs = sorted(d for d in skills_root.iterdir() if d.is_dir())
except OSError as e:
logger.debug('Failed to list skills root, %s', {'path': str(skills_root)}, exc_info=e)
return []

skills: list[dict] = []
for skill_dir in skill_dirs:
if len(skills) >= MAX_SKILLS_PER_ROOT:
logger.debug(
'Skills root exceeds the count cap; truncating, %s',
{'path': str(skills_root), 'cap': MAX_SKILLS_PER_ROOT},
)
break

skill = _read_skill_file(skill_dir / SKILL_FILE_NAME)
if skill:
skills.append(skill)

return skills


def walk_plugin_skills(plugin_dir: Path) -> list[dict]:
"""Collect the skills a plugin ships, from ``<plugin_dir>/skills/<name>/SKILL.md``.

Shared by every IDE with a plugin system: the layout belongs to the plugin format, so a plugin
shipping skills is inventoried whichever IDE loaded it.
"""
return walk_skill_dirs(plugin_dir / PLUGIN_SKILLS_SUBDIR)
14 changes: 14 additions & 0 deletions cycode/cli/apps/ai_guardrails/ides/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,17 @@ def get_session_context(self) -> tuple[Optional[dict], dict]:
Override to surface MCP/plugin inventory.
"""
return None, {}

def get_skills(self) -> list[dict]:
"""Return the IDE's user-scope skills as ``[{"path", "content"}]``.

A skill is a ``SKILL.md`` under a per-skill directory. Raw content is returned rather
than parsed frontmatter: the backend owns parsing, because the device connectors that
read these files off endpoints can only ever return raw content.

Kept separate from ``get_session_context`` rather than folded into its
``global_config_file`` slot, which is normalized to an MCP server map.

Default: ``[]`` (the IDE has no skill system). Override to surface skills.
"""
return []
24 changes: 22 additions & 2 deletions cycode/cli/apps/ai_guardrails/ides/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
resolve_cached_plugin_dir,
walk_enabled_plugins,
)
from cycode.cli.apps.ai_guardrails.ides._skill_utils import walk_plugin_skills, walk_skill_dirs
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
Expand Down Expand Up @@ -169,6 +170,15 @@ def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]
return None


def _claude_skills_dir() -> Path:
"""Claude Code's user-scope skills: ``~/.claude/skills/<name>/SKILL.md``.

A function, not a module constant: resolving ``Path.home()`` at import time pins the directory to
whatever home the process started with, which a test filesystem then cannot redirect.
"""
return Path.home() / '.claude' / 'skills'


def _plugins_cache_dir() -> Path:
"""Claude Code's local plugin content cache: ``~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/``."""
return Path.home() / '.claude' / 'plugins' / 'cache'
Expand All @@ -187,17 +197,24 @@ def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]:


def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]:
"""Read one Claude Code plugin's manifest + MCP servers.
"""Read one Claude Code plugin's manifest, MCP servers and skills.

Claude hardcodes the MCP file at ``<plugin_dir>/.mcp.json`` and always
wraps it as ``{"mcpServers": {...}}``.
wraps it as ``{"mcpServers": {...}}``, and a plugin's skills at
``<plugin_dir>/skills/<name>/SKILL.md``.
"""
manifest = load_plugin_json(plugin_dir / '.claude-plugin' / 'plugin.json') or {}
entry: dict = {}
for field in ('name', 'version', 'description'):
if field in manifest:
entry[field] = manifest[field]

# Attached to the plugin entry rather than the top-level skills list so the backend keeps the
# plugin provenance (marketplace, plugin, version) that a marketplace-installed skill has.
skill_files = walk_plugin_skills(plugin_dir)
if skill_files:
entry['skill_files'] = skill_files

mcp_config_path = plugin_dir / '.mcp.json'
mcp_config = load_plugin_json(mcp_config_path) or {}
servers: dict = mcp_config.get('mcpServers') or {}
Expand Down Expand Up @@ -387,3 +404,6 @@ def get_session_context(self) -> tuple[Optional[dict], dict]:
enriched_plugins = resolve_plugins(settings) if settings else {}

return global_config_file, enriched_plugins

def get_skills(self) -> list[dict]:
return walk_skill_dirs(_claude_skills_dir())
7 changes: 7 additions & 0 deletions cycode/cli/apps/ai_guardrails/ides/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
resolve_cached_plugin_dir,
walk_enabled_plugins,
)
from cycode.cli.apps.ai_guardrails.ides._skill_utils import walk_plugin_skills
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
Expand Down Expand Up @@ -121,6 +122,12 @@ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]:
if field in manifest:
entry[field] = manifest[field]

# Same plugin-format layout as every other IDE's plugins, so a plugin shipping skills is
# inventoried whichever IDE loaded it.
skill_files = walk_plugin_skills(plugin_dir)
if skill_files:
entry['skill_files'] = skill_files

mcp_ref = manifest.get('mcpServers')
if not mcp_ref:
return entry, {}
Expand Down
7 changes: 7 additions & 0 deletions cycode/cli/apps/ai_guardrails/ides/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
load_plugin_json,
walk_enabled_plugins,
)
from cycode.cli.apps.ai_guardrails.ides._skill_utils import walk_plugin_skills
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
Expand Down Expand Up @@ -178,6 +179,12 @@ def _read_copilot_plugin(plugin_dir: Path) -> tuple[dict, dict]:
if field in manifest:
entry[field] = manifest[field]

# Same plugin-format layout as every other IDE's plugins, so a plugin shipping skills is
# inventoried whichever IDE loaded it.
skill_files = walk_plugin_skills(plugin_dir)
if skill_files:
entry['skill_files'] = skill_files

mcp_ref = manifest.get('mcpServers')
mcp_config_path = plugin_dir / mcp_ref if isinstance(mcp_ref, str) else plugin_dir / '.mcp.json'
mcp_doc = load_plugin_json(mcp_config_path) or {}
Expand Down
15 changes: 12 additions & 3 deletions cycode/cli/apps/ai_guardrails/session_start_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@

import typer

from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, collect_all_session_contexts, get_ide
from cycode.cli.apps.ai_guardrails.ides import (
DEFAULT_IDE_NAME,
collect_all_session_contexts,
collect_all_skills,
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 Down Expand Up @@ -75,8 +80,9 @@ def _report_session_context(
) -> 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.
The device context is always reported. MCP configs and skills 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()
Expand All @@ -89,6 +95,9 @@ def _report_session_context(
# 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,
# Already deduplicated and sorted by path, for the same digest-stability reason.
# Editing a skill body changes the digest and so re-reports the device's inventory.
'skill_files': collect_all_skills(),
'user_email': user_email,
}

Expand Down
2 changes: 2 additions & 0 deletions cycode/cyclient/ai_security_manager_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def report_session_context(
last_login_user: Optional[str] = None,
config_files: Optional[list[dict]] = None,
enabled_plugins: Optional[dict] = None,
skill_files: Optional[list[dict]] = None,
user_email: Optional[str] = None,
) -> bool:
"""Report session context to the backend. Returns whether the report was accepted."""
Expand All @@ -113,6 +114,7 @@ def report_session_context(
'user_email': user_email,
'config_files': config_files,
'enabled_plugins': enabled_plugins,
'skill_files': skill_files,
}

try:
Expand Down
63 changes: 63 additions & 0 deletions tests/cli/commands/ai_guardrails/ides/test_claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,3 +386,66 @@ def test_email_from_config_missing_oauth_account() -> None:

def test_email_from_config_missing_email_address() -> None:
assert _email_from_config({'oauthAccount': {'someOtherField': 'value'}}) is None


# skills


def test_get_skills_reads_user_scope_skills(fs: FakeFilesystem) -> None:
body = '---\nname: dummy-skill\ndescription: Dummy.\n---\n\nDo it.\n'
skill_file = Path.home() / '.claude' / 'skills' / 'dummy-skill' / 'SKILL.md'
fs.create_file(skill_file, contents=body)

skills = ClaudeCode().get_skills()

assert skills == [{'path': str(skill_file), 'content': body}]


def test_get_skills_no_skills_dir_returns_empty(fs: FakeFilesystem) -> None:
fs.create_file(Path.home() / '.claude' / 'settings.json', contents='{}')

assert ClaudeCode().get_skills() == []


def test_read_claude_plugin_collects_plugin_skills(fs: FakeFilesystem) -> None:
"""Plugin skills ride on the plugin entry, which is what carries marketplace provenance."""
plugin_dir = Path('/dummy/marketplace/dummy-plugin')
fs.create_file(
plugin_dir / '.claude-plugin' / 'plugin.json',
contents=json.dumps({'name': 'dummy-plugin', 'version': '2.0.0'}),
)
skill_file = plugin_dir / 'skills' / 'plugin-skill' / 'SKILL.md'
fs.create_file(skill_file, contents='---\nname: plugin-skill\n---\n\nPlugin body.\n')

entry, servers = _read_claude_plugin(plugin_dir)

assert servers == {}
assert entry['version'] == '2.0.0'
assert [s['path'] for s in entry['skill_files']] == [str(skill_file)]


def test_read_claude_plugin_without_skills_omits_key(fs: FakeFilesystem) -> None:
plugin_dir = Path('/dummy/marketplace/dummy-plugin')
fs.create_file(
plugin_dir / '.claude-plugin' / 'plugin.json',
contents=json.dumps({'name': 'dummy-plugin'}),
)

entry, _ = _read_claude_plugin(plugin_dir)

assert 'skill_files' not in entry


def test_resolve_plugins_surfaces_plugin_skills(fs: FakeFilesystem) -> None:
plugin_dir = Path.home() / '.claude' / 'plugins' / 'cache' / 'dummy-marketplace' / 'dummy-plugin' / '1.0.0'
fs.create_file(
plugin_dir / '.claude-plugin' / 'plugin.json',
contents=json.dumps({'name': 'dummy-plugin', 'version': '1.0.0'}),
)
skill_file = plugin_dir / 'skills' / 'cached-skill' / 'SKILL.md'
fs.create_file(skill_file, contents='---\nname: cached-skill\n---\nBody.\n')

plugins = resolve_plugins({'enabledPlugins': {'dummy-plugin@dummy-marketplace': True}})

entry = plugins['dummy-plugin@dummy-marketplace']
assert [Path(s['path']).parent.name for s in entry['skill_files']] == ['cached-skill']
15 changes: 15 additions & 0 deletions tests/cli/commands/ai_guardrails/ides/test_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,3 +377,18 @@ def test_read_codex_plugin_no_mcp_config_file_when_no_manifest(tmp_path: Path) -

assert 'mcp_config_file' not in entry
assert servers == {}


def test_read_codex_plugin_collects_plugin_skills(fs: FakeFilesystem) -> None:
"""Plugin skills are a property of the plugin format, so Codex plugins carry them too."""
plugin_dir = Path('/dummy/codex-marketplace/dummy-plugin')
fs.create_file(
plugin_dir / '.codex-plugin' / 'plugin.json',
contents=json.dumps({'name': 'dummy-plugin', 'version': '3.0.0'}),
)
skill_file = plugin_dir / 'skills' / 'codex-skill' / 'SKILL.md'
fs.create_file(skill_file, contents='---\nname: codex-skill\n---\nBody.\n')

entry, _ = _read_codex_plugin(plugin_dir)

assert [s['path'] for s in entry['skill_files']] == [str(skill_file)]
10 changes: 10 additions & 0 deletions tests/cli/commands/ai_guardrails/ides/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,16 @@ def test_get_session_context_returns_pair(ide: IDE) -> None:
assert isinstance(plugins, dict)


def test_get_skills_returns_path_content_dicts(ide: IDE) -> None:
"""Skills must be a list of ``{"path", "content"}`` dicts - empty for IDEs without skills."""
skills = ide.get_skills()
assert isinstance(skills, list)
for skill in skills:
assert isinstance(skill, dict)
assert isinstance(skill.get('path'), str)
assert isinstance(skill.get('content'), str)


# HookDecision helpers


Expand Down
Loading
Loading