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
13 changes: 13 additions & 0 deletions cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ def load_plugin_json(path: Path) -> Optional[dict]:
return None


def build_global_config_file(path: Path, mcp_servers: Optional[dict]) -> Optional[dict]:
"""Wrap a global (non-plugin) MCP config into the session-context file shape.

Returns ``{"path": <full path>, "content": <{"mcpServers": ...} JSON>}`` when
there are servers, else ``None``. ``content`` is normalized to the canonical
``{"mcpServers": {...}}`` shape, dropping everything else in the source file.
"""
servers = mcp_servers or {}
if not servers:
return None
return {'path': str(path), 'content': json.dumps({'mcpServers': servers})}


def walk_enabled_plugins(
plugin_entries: dict[str, Any],
is_enabled: Callable[[Any], bool],
Expand Down
14 changes: 10 additions & 4 deletions cycode/cli/apps/ai_guardrails/ides/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,16 @@ def get_user_email(self) -> Optional[str]:
"""
return None

def get_session_context(self) -> tuple[dict, dict]:
"""Return ``(mcp_servers, enabled_plugins)`` for session-context reporting.
def get_session_context(self) -> tuple[Optional[dict], dict]:
"""Return ``(global_config_file, enabled_plugins)`` for session-context reporting.

Default: empty dicts (no plugin system, no discoverable MCP config).
``global_config_file`` is the IDE's global (non-plugin) MCP config as
``{"path": <full path>, "content": <normalized {"mcpServers": ...} JSON>}``,
or ``None`` when there is no global MCP config. ``enabled_plugins`` maps each
enabled plugin key to its metadata (including its own ``mcp_config_file``
content and ``mcp_config_file_path``).

Default: ``(None, {})`` (no plugin system, no discoverable MCP config).
Override to surface MCP/plugin inventory.
"""
return {}, {}
return None, {}
20 changes: 13 additions & 7 deletions cycode/cli/apps/ai_guardrails/ides/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
from typing import ClassVar, Optional

from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
build_global_config_file,
load_plugin_json,
walk_enabled_plugins,
)
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 @@ -184,10 +188,13 @@ def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]:
if field in manifest:
entry[field] = manifest[field]

mcp_config = load_plugin_json(plugin_dir / '.mcp.json') or {}
mcp_config_path = plugin_dir / '.mcp.json'
mcp_config = load_plugin_json(mcp_config_path) or {}
servers: dict = mcp_config.get('mcpServers') or {}
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)
return entry, servers


Expand Down Expand Up @@ -354,15 +361,14 @@ def get_user_email(self) -> Optional[str]:
config = load_claude_config()
return _email_from_config(config) if config else None

def get_session_context(self) -> tuple[dict, dict]:
def get_session_context(self) -> tuple[Optional[dict], dict]:
config = load_claude_config()
mcp_servers: dict = dict(get_mcp_servers(config) or {}) if config else {}
global_config_file = build_global_config_file(_CLAUDE_CONFIG_PATH, get_mcp_servers(config)) if config else None

settings = load_claude_settings()
if settings:
plugin_mcp, enriched_plugins = resolve_plugins(settings)
mcp_servers.update(plugin_mcp)
_, enriched_plugins = resolve_plugins(settings)
else:
enriched_plugins = {}

return mcp_servers, enriched_plugins
return global_config_file, enriched_plugins
28 changes: 18 additions & 10 deletions cycode/cli/apps/ai_guardrails/ides/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
import tomli as tomllib

from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import (
build_global_config_file,
load_plugin_json,
walk_enabled_plugins,
)
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 @@ -129,12 +133,15 @@ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]:
mcp_ref = manifest.get('mcpServers')
if not mcp_ref:
return entry, {}
mcp_doc = load_plugin_json(plugin_dir / mcp_ref) or {}
mcp_config_path = plugin_dir / mcp_ref
mcp_doc = load_plugin_json(mcp_config_path) or {}
servers = mcp_doc.get('mcpServers', mcp_doc)
if not isinstance(servers, dict):
servers = {}
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)
return entry, servers


Expand Down Expand Up @@ -297,13 +304,14 @@ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
def get_user_email(self) -> Optional[str]:
return _email_from_auth()

def get_session_context(self) -> tuple[dict, dict]:
def get_session_context(self) -> tuple[Optional[dict], dict]:
config = _load_codex_config()
if not config:
return {}, {}
# Codex stores MCP servers under `[mcp_servers.<name>]`. Plugin-contributed
# servers (via `[plugins."<plugin>@<marketplace>"]`) merge on top.
mcp_servers: dict = dict(config.get('mcp_servers') or {})
plugin_mcp, enriched_plugins = _resolve_codex_plugins(config)
mcp_servers.update(plugin_mcp)
return mcp_servers, enriched_plugins
return None, {}
# Codex stores MCP servers under `[mcp_servers.<name>]`; the global config
# file becomes its own session-context file. Plugins (via
# `[plugins."<plugin>@<marketplace>"]`) carry their own config files.
config_path = _codex_home() / _CONFIG_TOML_NAME
global_config_file = build_global_config_file(config_path, config.get('mcp_servers'))
_, enriched_plugins = _resolve_codex_plugins(config)
return global_config_file, enriched_plugins
10 changes: 7 additions & 3 deletions cycode/cli/apps/ai_guardrails/ides/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import ClassVar, Optional

from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
from cycode.cli.apps.ai_guardrails.ides._plugin_utils import build_global_config_file
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 @@ -113,7 +114,10 @@ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
ide_version=raw_payload.get('cursor_version'),
)

def get_session_context(self) -> tuple[dict, dict]:
def get_session_context(self) -> tuple[Optional[dict], dict]:
config = _load_cursor_mcp_config()
mcp_servers = dict((config or {}).get('mcpServers') or {}) if config else {}
return mcp_servers, {}
if not config:
return None, {}
config_path = Path.home() / '.cursor' / _MCP_CONFIG_FILENAME
global_config_file = build_global_config_file(config_path, config.get('mcpServers'))
return global_config_file, {}
20 changes: 17 additions & 3 deletions cycode/cli/apps/ai_guardrails/session_start_command.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"""Handle AI guardrails session start: auth, conversation creation, session context."""

import getpass
import platform
import socket
import sys
from typing import TYPE_CHECKING, Annotated, Optional

Expand All @@ -20,14 +23,25 @@
logger = get_logger('AI Guardrails')


def _get_logged_in_user() -> Optional[str]:
"""Best-effort OS account name (whoami). None if it can't be resolved."""
try:
return getpass.getuser()
except Exception:
return None


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."""
try:
mcp_servers, enabled_plugins = ide.get_session_context()
if not mcp_servers and not enabled_plugins:
global_config_file, enabled_plugins = ide.get_session_context()
if not global_config_file and not enabled_plugins:
return
ai_client.report_session_context(
mcp_servers=mcp_servers,
hostname=socket.gethostname(),
platform=platform.system(),
logged_in_user=_get_logged_in_user(),
global_config_file=global_config_file,
enabled_plugins=enabled_plugins,
user_email=user_email,
)
Expand Down
12 changes: 9 additions & 3 deletions cycode/cyclient/ai_security_manager_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,21 @@ def create_event(

def report_session_context(
self,
mcp_servers: Optional[dict] = None,
hostname: Optional[str] = None,
platform: Optional[str] = None,
logged_in_user: Optional[str] = None,
global_config_file: Optional[dict] = None,
enabled_plugins: Optional[dict] = None,
user_email: Optional[str] = None,
) -> None:
"""Report session context to the backend."""
body: dict = {
'mcp_servers': mcp_servers,
'enabled_plugins': enabled_plugins,
'hostname': hostname,
'platform': platform,
'logged_in_user': logged_in_user,
'user_email': user_email,
'global_config_file': global_config_file,
'enabled_plugins': enabled_plugins,
}

try:
Expand Down
42 changes: 39 additions & 3 deletions tests/cli/commands/ai_guardrails/ides/test_claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@
from pytest_mock import MockerFixture

from cycode.cli.apps.ai_guardrails.ides.base import HookDecision
from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode, _email_from_config, load_claude_config
from cycode.cli.apps.ai_guardrails.ides.claude_code import (
ClaudeCode,
_email_from_config,
_read_claude_plugin,
load_claude_config,
)
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType


Expand Down Expand Up @@ -214,6 +219,37 @@ def test_email_none_when_no_oauth(mocker: MockerFixture) -> None:
assert unified.ide_user_email is None


# _read_claude_plugin


def test_read_claude_plugin_includes_mcp_config_file(tmp_path: Path) -> None:
mcp_content = {'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}}
(tmp_path / '.mcp.json').write_text(json.dumps(mcp_content))

entry, servers = _read_claude_plugin(tmp_path)

assert 'mcp_config_file' in entry
assert json.loads(entry['mcp_config_file']) == mcp_content
assert entry['mcp_config_file_path'] == str(tmp_path / '.mcp.json')
assert servers == mcp_content['mcpServers']


def test_read_claude_plugin_no_mcp_config_file_when_no_servers(tmp_path: Path) -> None:
(tmp_path / '.mcp.json').write_text(json.dumps({'mcpServers': {}}))

entry, servers = _read_claude_plugin(tmp_path)

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


def test_read_claude_plugin_no_mcp_config_file_when_missing(tmp_path: Path) -> None:
entry, servers = _read_claude_plugin(tmp_path)

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


# Session context


Expand All @@ -222,8 +258,8 @@ def test_session_context_no_config() -> None:
patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', return_value=None),
patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_settings', return_value=None),
):
servers, plugins = ClaudeCode().get_session_context()
assert servers == {}
global_config_file, plugins = ClaudeCode().get_session_context()
assert global_config_file is None
assert plugins == {}


Expand Down
56 changes: 52 additions & 4 deletions tests/cli/commands/ai_guardrails/ides/test_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
_email_from_auth,
_enable_codex_hooks_feature,
_load_codex_config,
_read_codex_plugin,
)
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType

Expand Down Expand Up @@ -324,13 +325,60 @@ def test_session_context_reads_mcp_servers() -> None:
'cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config',
return_value={'mcp_servers': mcp},
):
servers, plugins = Codex().get_session_context()
assert servers == mcp
global_config_file, plugins = Codex().get_session_context()
assert global_config_file is not None
assert global_config_file['path'].endswith('config.toml')
assert global_config_file['content'] == json.dumps({'mcpServers': mcp})
assert plugins == {}


def test_session_context_no_config() -> None:
with patch('cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config', return_value=None):
servers, plugins = Codex().get_session_context()
assert servers == {}
global_config_file, plugins = Codex().get_session_context()
assert global_config_file is None
assert plugins == {}


def _write_codex_plugin(plugin_dir: Path, mcp_doc: dict) -> None:
"""Lay out a Codex plugin: manifest referencing .mcp.json + the MCP file itself."""
(plugin_dir / '.codex-plugin').mkdir(parents=True, exist_ok=True)
(plugin_dir / '.codex-plugin' / 'plugin.json').write_text(json.dumps({'name': 'demo', 'mcpServers': '.mcp.json'}))
(plugin_dir / '.mcp.json').write_text(json.dumps(mcp_doc))


def test_read_codex_plugin_includes_mcp_config_file(tmp_path: Path) -> None:
mcp_content = {'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}}
_write_codex_plugin(tmp_path, mcp_content)

entry, servers = _read_codex_plugin(tmp_path)

assert json.loads(entry['mcp_config_file']) == mcp_content
assert entry['mcp_config_file_path'] == str(tmp_path / '.mcp.json')
assert servers == mcp_content['mcpServers']


def test_read_codex_plugin_mcp_config_file_bare_map(tmp_path: Path) -> None:
# Codex MCP files may be a bare {name: cfg} map with no mcpServers wrapper.
mcp_content = {'dummy-server': {'command': 'dummy-command'}}
_write_codex_plugin(tmp_path, mcp_content)

entry, servers = _read_codex_plugin(tmp_path)

assert json.loads(entry['mcp_config_file']) == mcp_content
assert servers == mcp_content


def test_read_codex_plugin_no_mcp_config_file_when_no_servers(tmp_path: Path) -> None:
_write_codex_plugin(tmp_path, {'mcpServers': {}})

entry, servers = _read_codex_plugin(tmp_path)

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


def test_read_codex_plugin_no_mcp_config_file_when_no_manifest(tmp_path: Path) -> None:
entry, servers = _read_codex_plugin(tmp_path)

assert 'mcp_config_file' not in entry
assert servers == {}
9 changes: 6 additions & 3 deletions tests/cli/commands/ai_guardrails/ides/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,12 @@ def test_build_session_payload_tags_ide(ide: IDE) -> None:


def test_get_session_context_returns_pair(ide: IDE) -> None:
"""Session context must always be a ``(mcp_servers, plugins)`` 2-tuple of dicts."""
mcp_servers, plugins = ide.get_session_context()
assert isinstance(mcp_servers, dict)
"""Session context must be a ``(global_config_file, plugins)`` pair.

``global_config_file`` is ``None`` or a ``{"path", "content"}`` dict; ``plugins`` is a dict.
"""
global_config_file, plugins = ide.get_session_context()
assert global_config_file is None or isinstance(global_config_file, dict)
assert isinstance(plugins, dict)


Expand Down
Loading
Loading