diff --git a/src/agentsec/adapters/__init__.py b/src/agentsec/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agentsec/adapters/base.py b/src/agentsec/adapters/base.py new file mode 100644 index 0000000..04e7a24 --- /dev/null +++ b/src/agentsec/adapters/base.py @@ -0,0 +1,157 @@ +"""Framework adapter interface and normalized config models. + +Adapters translate framework-specific configuration files (Claude Code, +Cursor, Windsurf, etc.) into a common FrameworkConfig structure that +scanners can reason about uniformly. +""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Sub-config dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class McpServerConfig: + """Normalized representation of a single MCP server declaration.""" + + name: str + command: str + args: list[str] = field(default_factory=list) + env: dict[str, str] = field(default_factory=dict) + transport: str = "stdio" + requires_auth: bool = False + + +@dataclass +class PermissionsConfig: + """Normalized tool/resource permission rules.""" + + allow_rules: list[str] = field(default_factory=list) + deny_rules: list[str] = field(default_factory=list) + default_mode: str = "ask" + auto_approve_tools: list[str] = field(default_factory=list) + auto_approve_mcp: list[str] = field(default_factory=list) + + +@dataclass +class HookConfig: + """Normalized lifecycle hook (pre/post tool use, etc.).""" + + event: str + hook_type: str + command: str + prompt: str = "" + timeout: int = 30 + source_file: str = "" + + +@dataclass +class RuleConfig: + """Normalized instruction rule (CLAUDE.md, .cursorrules, etc.).""" + + name: str + content: str + source_file: str = "" + activation_mode: str = "always" + glob_pattern: str = "" + + +@dataclass +class SandboxConfig: + """Normalized sandbox / network isolation settings.""" + + enabled: bool = False + network_allowed_domains: list[str] = field(default_factory=list) + filesystem_deny_read: list[str] = field(default_factory=list) + filesystem_allow_write: list[str] = field(default_factory=list) + + +@dataclass +class PluginConfig: + """Normalized plugin / extension declaration.""" + + name: str + source: str = "" + enabled: bool = True + marketplace: str = "" + + +# --------------------------------------------------------------------------- +# Top-level normalized config +# --------------------------------------------------------------------------- + + +@dataclass +class FrameworkConfig: + """Unified configuration extracted from any agent framework. + + Scanners operate on this structure instead of parsing raw JSON/YAML + from each framework individually. + """ + + framework: str + config_paths: list[Path] = field(default_factory=list) + mcp_servers: list[McpServerConfig] = field(default_factory=list) + permissions: PermissionsConfig = field(default_factory=PermissionsConfig) + hooks: list[HookConfig] = field(default_factory=list) + rules: list[RuleConfig] = field(default_factory=list) + sandbox: SandboxConfig | None = None + plugins: list[PluginConfig] = field(default_factory=list) + env_vars: dict[str, str] = field(default_factory=dict) + raw_configs: dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Abstract adapter +# --------------------------------------------------------------------------- + + +class FrameworkAdapter(ABC): + """Abstract base for framework-specific config adapters. + + Each supported agent framework (Claude Code, Cursor, Windsurf, etc.) + gets a concrete subclass that knows how to locate, read, and normalize + that framework's configuration into a FrameworkConfig. + """ + + @property + @abstractmethod + def name(self) -> str: + """Machine-readable adapter identifier (e.g. 'claude_code').""" + + @property + @abstractmethod + def display_name(self) -> str: + """Human-readable name shown in reports (e.g. 'Claude Code').""" + + @abstractmethod + def detect(self, target: Path) -> bool: + """Return True if the target directory contains this framework's artifacts.""" + + @abstractmethod + def discover_configs(self, target: Path) -> list[Path]: + """Return paths to all config files found under *target* for this framework.""" + + @abstractmethod + def parse(self, target: Path) -> FrameworkConfig: + """Parse all discovered configs into a normalized FrameworkConfig.""" + + @property + @abstractmethod + def known_config_paths(self) -> list[str]: + """Relative paths this framework is known to use for configuration. + + Used for quick existence checks before full parsing. Paths may + contain ``~`` for user-home expansion. + """ diff --git a/src/agentsec/adapters/claude_code.py b/src/agentsec/adapters/claude_code.py new file mode 100644 index 0000000..c29e557 --- /dev/null +++ b/src/agentsec/adapters/claude_code.py @@ -0,0 +1,482 @@ +"""Claude Code adapter — parses Claude Code configuration into FrameworkConfig. + +Reads Claude Code project and user-level settings, MCP server declarations, +rules (CLAUDE.md, .claude/rules/, .claude/agents/, .claude/commands/), +hooks, permissions, sandbox config, and environment variables. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +from agentsec.adapters.base import ( + FrameworkAdapter, + FrameworkConfig, + HookConfig, + McpServerConfig, + PluginConfig, + RuleConfig, + SandboxConfig, +) + +logger = logging.getLogger(__name__) + +# Claude Code settings files (project-level, relative to target) +_PROJECT_SETTINGS = [ + ".claude/settings.json", + ".claude/settings.local.json", +] + +# Claude Code MCP config files (project-level) +_PROJECT_MCP_CONFIGS = [ + ".mcp.json", +] + +# Rule sources (project-level) +_PROJECT_RULE_SOURCES = [ + "CLAUDE.md", + ".claude/CLAUDE.md", +] + +# Rule directories (project-level) +_PROJECT_RULE_DIRS = [ + ".claude/rules", + ".claude/agents", + ".claude/commands", +] + +# User-level config paths (relative to home) +_USER_SETTINGS = [ + ".claude/settings.json", +] + +_USER_MCP_CONFIGS = [ + ".claude.json", +] + +_USER_RULE_SOURCES = [ + ".claude/CLAUDE.md", +] + + +def _read_json(path: Path) -> dict[str, Any] | None: + """Read and parse a JSON file, returning None on any error.""" + try: + data: dict[str, Any] = json.loads(path.read_text()) + return data + except json.JSONDecodeError: + logger.debug("Malformed JSON in %s", path) + return None + except OSError as e: + logger.debug("Could not read %s: %s", path, e) + return None + except PermissionError: + logger.debug("Permission denied reading %s", path) + return None + + +def _read_text(path: Path) -> str | None: + """Read a text file, returning None on any error.""" + try: + return path.read_text(errors="replace") + except OSError as e: + logger.debug("Could not read %s: %s", path, e) + return None + except PermissionError: + logger.debug("Permission denied reading %s", path) + return None + + +class ClaudeCodeAdapter(FrameworkAdapter): + """Adapter for Claude Code agent framework configuration.""" + + @property + def name(self) -> str: + return "claude_code" + + @property + def display_name(self) -> str: + return "Claude Code" + + @property + def known_config_paths(self) -> list[str]: + return [ + ".claude/settings.json", + ".claude/settings.local.json", + ".mcp.json", + "CLAUDE.md", + ".claude/CLAUDE.md", + "~/.claude/settings.json", + "~/.claude.json", + "~/.claude/CLAUDE.md", + ] + + def detect(self, target: Path) -> bool: + """Check for Claude Code markers at the target or in the user home.""" + # Project-level markers + if (target / ".claude").is_dir(): + return True + if (target / "CLAUDE.md").is_file(): + return True + + # User-global installation + home = Path.home() + return bool((home / ".claude").is_dir()) + + def discover_configs(self, target: Path) -> list[Path]: + """Return all Claude Code config files found at project and user level.""" + found: list[Path] = [] + home = Path.home() + + # Project-level settings + for rel in _PROJECT_SETTINGS: + path = target / rel + if path.is_file(): + found.append(path) + + # Project-level MCP + for rel in _PROJECT_MCP_CONFIGS: + path = target / rel + if path.is_file(): + found.append(path) + + # Project-level rule files + for rel in _PROJECT_RULE_SOURCES: + path = target / rel + if path.is_file(): + found.append(path) + + # Project-level rule directories + for rel in _PROJECT_RULE_DIRS: + dir_path = target / rel + if dir_path.is_dir(): + for child in dir_path.iterdir(): + if child.is_file() and child.suffix == ".md": + found.append(child) + + # User-level settings + for rel in _USER_SETTINGS: + path = home / rel + if path.is_file(): + found.append(path) + + # User-level MCP + for rel in _USER_MCP_CONFIGS: + path = home / rel + if path.is_file(): + found.append(path) + + # User-level rule files + for rel in _USER_RULE_SOURCES: + path = home / rel + if path.is_file(): + found.append(path) + + return found + + def parse(self, target: Path) -> FrameworkConfig: + """Parse all Claude Code configs into a normalized FrameworkConfig.""" + home = Path.home() + config = FrameworkConfig(framework=self.name) + raw: dict[str, Any] = {} + + # Parse project-level settings files + for rel in _PROJECT_SETTINGS: + path = target / rel + if not path.is_file(): + continue + data = _read_json(path) + if data is None: + continue + config.config_paths.append(path) + raw[rel] = data + self._extract_settings(data, config, str(path)) + + # Parse user-level settings + for rel in _USER_SETTINGS: + path = home / rel + if not path.is_file(): + continue + data = _read_json(path) + if data is None: + continue + config.config_paths.append(path) + raw[f"~/{rel}"] = data + self._extract_settings(data, config, str(path)) + + # Parse project-level MCP configs + for rel in _PROJECT_MCP_CONFIGS: + path = target / rel + if not path.is_file(): + continue + data = _read_json(path) + if data is None: + continue + config.config_paths.append(path) + raw[rel] = data + self._extract_mcp_servers(data, config) + + # Parse user-level MCP configs + for rel in _USER_MCP_CONFIGS: + path = home / rel + if not path.is_file(): + continue + data = _read_json(path) + if data is None: + continue + config.config_paths.append(path) + raw[f"~/{rel}"] = data + self._extract_mcp_servers(data, config) + + # Parse rule files + self._extract_rules(target, home, config) + + config.raw_configs = raw + return config + + # ------------------------------------------------------------------ + # Settings extraction + # ------------------------------------------------------------------ + + def _extract_settings( + self, data: dict[str, Any], config: FrameworkConfig, source_file: str + ) -> None: + """Extract hooks, permissions, env, sandbox, and plugins from a settings dict.""" + self._extract_hooks(data.get("hooks", {}), config, source_file) + self._extract_permissions(data.get("permissions", {}), config) + self._extract_env(data.get("env", {}), config) + self._extract_sandbox(data, config) + self._extract_plugins(data.get("enabledPlugins", []), config) + + # Track auto-approve MCP flag + if data.get("enableAllProjectMcpServers"): + config.permissions.auto_approve_mcp.append("*") + + def _extract_hooks( + self, hooks: dict[str, Any], config: FrameworkConfig, source_file: str + ) -> None: + """Parse Claude Code hooks structure into HookConfig list. + + The hooks structure is: + { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + {"type": "command", "command": "...", "timeout": 30}, + {"type": "prompt", "prompt": "..."} + ] + } + ] + } + """ + if not isinstance(hooks, dict): + return + + for event_name, hook_groups in hooks.items(): + if not isinstance(hook_groups, list): + continue + for group in hook_groups: + if not isinstance(group, dict): + continue + for hook in group.get("hooks", []): + if not isinstance(hook, dict): + continue + hook_type = hook.get("type", "") + command = hook.get("command", "") + prompt = hook.get("prompt", "") + timeout = hook.get("timeout", 30) + if not isinstance(timeout, int): + timeout = 30 + + config.hooks.append( + HookConfig( + event=event_name, + hook_type=hook_type, + command=command, + prompt=prompt, + timeout=timeout, + source_file=source_file, + ) + ) + + def _extract_permissions(self, permissions: dict[str, Any], config: FrameworkConfig) -> None: + """Parse Claude Code permissions into PermissionsConfig.""" + if not isinstance(permissions, dict): + return + + allow = permissions.get("allow", []) + deny = permissions.get("deny", []) + default_mode = permissions.get("defaultMode", "ask") + auto_approve = permissions.get("autoApprove", []) + + if isinstance(allow, list): + config.permissions.allow_rules.extend(str(r) for r in allow) + if isinstance(deny, list): + config.permissions.deny_rules.extend(str(r) for r in deny) + if isinstance(default_mode, str): + config.permissions.default_mode = default_mode + if isinstance(auto_approve, list): + config.permissions.auto_approve_tools.extend(str(r) for r in auto_approve) + + def _extract_env(self, env: dict[str, Any], config: FrameworkConfig) -> None: + """Extract environment variable declarations.""" + if not isinstance(env, dict): + return + for key, value in env.items(): + if isinstance(value, str): + config.env_vars[key] = value + + def _extract_sandbox(self, data: dict[str, Any], config: FrameworkConfig) -> None: + """Extract sandbox/network isolation settings.""" + sandbox_data = data.get("sandbox", {}) + if not isinstance(sandbox_data, dict): + return + + enabled = sandbox_data.get("enabled", False) + network_domains = sandbox_data.get("networkAllowedDomains", []) + fs_deny_read = sandbox_data.get("filesystemDenyRead", []) + fs_allow_write = sandbox_data.get("filesystemAllowWrite", []) + + if not isinstance(network_domains, list): + network_domains = [] + if not isinstance(fs_deny_read, list): + fs_deny_read = [] + if not isinstance(fs_allow_write, list): + fs_allow_write = [] + + config.sandbox = SandboxConfig( + enabled=bool(enabled), + network_allowed_domains=[str(d) for d in network_domains], + filesystem_deny_read=[str(p) for p in fs_deny_read], + filesystem_allow_write=[str(p) for p in fs_allow_write], + ) + + def _extract_plugins(self, plugins: list[Any], config: FrameworkConfig) -> None: + """Extract enabled plugins/extensions.""" + if not isinstance(plugins, list): + return + for plugin in plugins: + if isinstance(plugin, str): + config.plugins.append(PluginConfig(name=plugin, enabled=True)) + elif isinstance(plugin, dict): + config.plugins.append( + PluginConfig( + name=plugin.get("name", "unknown"), + source=plugin.get("source", ""), + enabled=plugin.get("enabled", True), + marketplace=plugin.get("marketplace", ""), + ) + ) + + # ------------------------------------------------------------------ + # MCP server extraction + # ------------------------------------------------------------------ + + def _extract_mcp_servers(self, data: dict[str, Any], config: FrameworkConfig) -> None: + """Parse MCP server declarations from .mcp.json or ~/.claude.json.""" + servers = data.get("mcpServers", {}) + if not isinstance(servers, dict): + return + + for server_name, server_data in servers.items(): + if not isinstance(server_data, dict): + continue + + command = server_data.get("command", "") + args = server_data.get("args", []) + env = server_data.get("env", {}) + transport = server_data.get("transport", "stdio") + + if not isinstance(command, str): + command = str(command) + if not isinstance(args, list): + args = [] + if not isinstance(env, dict): + env = {} + if not isinstance(transport, str): + transport = "stdio" + + requires_auth = bool(server_data.get("auth") or server_data.get("headers")) + + config.mcp_servers.append( + McpServerConfig( + name=server_name, + command=command, + args=[str(a) for a in args], + env={k: str(v) for k, v in env.items() if isinstance(v, str)}, + transport=transport, + requires_auth=requires_auth, + ) + ) + + # ------------------------------------------------------------------ + # Rule extraction + # ------------------------------------------------------------------ + + def _extract_rules(self, target: Path, home: Path, config: FrameworkConfig) -> None: + """Collect rules from CLAUDE.md files, .claude/rules/, agents/, commands/.""" + # Project-level rule files + for rel in _PROJECT_RULE_SOURCES: + path = target / rel + content = _read_text(path) if path.is_file() else None + if content is not None: + config.config_paths.append(path) + config.rules.append( + RuleConfig( + name=path.name, + content=content, + source_file=str(path), + activation_mode="always", + ) + ) + + # Project-level rule directories + for rel in _PROJECT_RULE_DIRS: + dir_path = target / rel + if not dir_path.is_dir(): + continue + for child in sorted(dir_path.iterdir()): + if not child.is_file() or child.suffix != ".md": + continue + content = _read_text(child) + if content is None: + continue + + # Determine activation mode from directory name + parent_name = dir_path.name + if parent_name == "rules": + activation = "always" + elif parent_name == "agents": + activation = "agent" + elif parent_name == "commands": + activation = "command" + else: + activation = "always" + + config.rules.append( + RuleConfig( + name=child.stem, + content=content, + source_file=str(child), + activation_mode=activation, + ) + ) + + # User-level rule files + for rel in _USER_RULE_SOURCES: + path = home / rel + content = _read_text(path) if path.is_file() else None + if content is not None: + config.config_paths.append(path) + config.rules.append( + RuleConfig( + name=f"user:{path.name}", + content=content, + source_file=str(path), + activation_mode="always", + ) + ) diff --git a/src/agentsec/adapters/cursor.py b/src/agentsec/adapters/cursor.py new file mode 100644 index 0000000..52f4137 --- /dev/null +++ b/src/agentsec/adapters/cursor.py @@ -0,0 +1,389 @@ +"""Cursor adapter — parses Cursor configuration into FrameworkConfig. + +Reads Cursor MCP server declarations, .cursorrules, .cursor/rules/*.mdc +(MDC format with YAML frontmatter), and VS Code tasks with runOn triggers. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +from agentsec.adapters.base import ( + FrameworkAdapter, + FrameworkConfig, + HookConfig, + McpServerConfig, + RuleConfig, +) + +logger = logging.getLogger(__name__) + +# MCP config files (project-level, relative to target) +_PROJECT_MCP_CONFIGS = [ + ".cursor/mcp.json", +] + +# MCP config files (user-level, relative to home) +_USER_MCP_CONFIGS = [ + ".cursor/mcp.json", +] + +# Rule sources (project-level) +_PROJECT_RULE_FILES = [ + ".cursorrules", +] + +# Rule directories with MDC files (project-level) +_PROJECT_RULE_DIRS = [ + ".cursor/rules", +] + +# VS Code tasks file (project-level, Cursor inherits VS Code task runner) +_PROJECT_TASKS = [ + ".vscode/tasks.json", +] + + +def _read_json(path: Path) -> dict[str, Any] | None: + """Read and parse a JSON file, returning None on any error.""" + try: + data: dict[str, Any] = json.loads(path.read_text()) + return data + except json.JSONDecodeError: + logger.debug("Malformed JSON in %s", path) + return None + except OSError as e: + logger.debug("Could not read %s: %s", path, e) + return None + except PermissionError: + logger.debug("Permission denied reading %s", path) + return None + + +def _read_text(path: Path) -> str | None: + """Read a text file, returning None on any error.""" + try: + return path.read_text(errors="replace") + except OSError as e: + logger.debug("Could not read %s: %s", path, e) + return None + except PermissionError: + logger.debug("Permission denied reading %s", path) + return None + + +def _parse_mdc(content: str, source_file: str) -> RuleConfig | None: + """Parse an MDC file (YAML frontmatter between --- markers + markdown body). + + MDC format: + --- + description: Some description + globs: "*.py" + alwaysApply: false + --- + Body content here... + + Returns a RuleConfig with activation_mode and glob_pattern extracted + from the frontmatter, and the markdown body as content. + """ + stripped = content.strip() + if not stripped.startswith("---"): + # No frontmatter, treat entire file as content + return RuleConfig( + name=Path(source_file).stem, + content=stripped, + source_file=source_file, + activation_mode="always", + ) + + # Find the closing --- delimiter + rest = stripped[3:] + end_idx = rest.find("---") + if end_idx == -1: + # Malformed frontmatter, treat entire file as content + return RuleConfig( + name=Path(source_file).stem, + content=stripped, + source_file=source_file, + activation_mode="always", + ) + + frontmatter_text = rest[:end_idx].strip() + body = rest[end_idx + 3 :].strip() + + # Parse frontmatter as simple key: value pairs (avoid yaml dependency) + frontmatter: dict[str, str] = {} + for line in frontmatter_text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + colon_idx = line.find(":") + if colon_idx == -1: + continue + key = line[:colon_idx].strip() + value = line[colon_idx + 1 :].strip().strip('"').strip("'") + frontmatter[key] = value + + # Determine activation mode + always_apply = frontmatter.get("alwaysApply", "false").lower() == "true" + glob_pattern = frontmatter.get("globs", "") + + if always_apply: + activation = "always" + elif glob_pattern: + activation = "glob" + else: + activation = "manual" + + return RuleConfig( + name=Path(source_file).stem, + content=body, + source_file=source_file, + activation_mode=activation, + glob_pattern=glob_pattern, + ) + + +class CursorAdapter(FrameworkAdapter): + """Adapter for Cursor editor configuration.""" + + @property + def name(self) -> str: + return "cursor" + + @property + def display_name(self) -> str: + return "Cursor" + + @property + def known_config_paths(self) -> list[str]: + return [ + ".cursor/mcp.json", + "~/.cursor/mcp.json", + ".cursorrules", + ".cursor/rules/", + ".vscode/tasks.json", + ] + + def detect(self, target: Path) -> bool: + """Check for Cursor markers at the target or in the user home.""" + if (target / ".cursor").is_dir(): + return True + if (target / ".cursorrules").is_file(): + return True + + home = Path.home() + return bool((home / ".cursor").is_dir()) + + def discover_configs(self, target: Path) -> list[Path]: + """Return all Cursor config files found at project and user level.""" + found: list[Path] = [] + home = Path.home() + + # Project-level MCP configs + for rel in _PROJECT_MCP_CONFIGS: + path = target / rel + if path.is_file(): + found.append(path) + + # User-level MCP configs + for rel in _USER_MCP_CONFIGS: + path = home / rel + if path.is_file(): + found.append(path) + + # Project-level rule files + for rel in _PROJECT_RULE_FILES: + path = target / rel + if path.is_file(): + found.append(path) + + # Project-level MDC rule directories + for rel in _PROJECT_RULE_DIRS: + dir_path = target / rel + if dir_path.is_dir(): + for child in dir_path.iterdir(): + if child.is_file() and child.suffix == ".mdc": + found.append(child) + + # VS Code tasks + for rel in _PROJECT_TASKS: + path = target / rel + if path.is_file(): + found.append(path) + + return found + + def parse(self, target: Path) -> FrameworkConfig: + """Parse all Cursor configs into a normalized FrameworkConfig.""" + home = Path.home() + config = FrameworkConfig(framework=self.name) + raw: dict[str, Any] = {} + + # Parse project-level MCP configs + for rel in _PROJECT_MCP_CONFIGS: + path = target / rel + if not path.is_file(): + continue + data = _read_json(path) + if data is None: + continue + config.config_paths.append(path) + raw[rel] = data + self._extract_mcp_servers(data, config) + + # Parse user-level MCP configs + for rel in _USER_MCP_CONFIGS: + path = home / rel + if not path.is_file(): + continue + data = _read_json(path) + if data is None: + continue + config.config_paths.append(path) + raw[f"~/{rel}"] = data + self._extract_mcp_servers(data, config) + + # Parse rule files + self._extract_rules(target, config) + + # Parse VS Code tasks for hooks + self._extract_tasks(target, config) + + config.raw_configs = raw + return config + + # ------------------------------------------------------------------ + # MCP server extraction + # ------------------------------------------------------------------ + + def _extract_mcp_servers(self, data: dict[str, Any], config: FrameworkConfig) -> None: + """Parse MCP server declarations from .cursor/mcp.json. + + Cursor uses the same mcpServers key format as Claude Code. + """ + servers = data.get("mcpServers", {}) + if not isinstance(servers, dict): + return + + for server_name, server_data in servers.items(): + if not isinstance(server_data, dict): + continue + + command = server_data.get("command", "") + args = server_data.get("args", []) + env = server_data.get("env", {}) + transport = server_data.get("transport", "stdio") + + if not isinstance(command, str): + command = str(command) + if not isinstance(args, list): + args = [] + if not isinstance(env, dict): + env = {} + if not isinstance(transport, str): + transport = "stdio" + + requires_auth = bool(server_data.get("auth") or server_data.get("headers")) + + config.mcp_servers.append( + McpServerConfig( + name=server_name, + command=command, + args=[str(a) for a in args], + env={k: str(v) for k, v in env.items() if isinstance(v, str)}, + transport=transport, + requires_auth=requires_auth, + ) + ) + + # ------------------------------------------------------------------ + # Rule extraction + # ------------------------------------------------------------------ + + def _extract_rules(self, target: Path, config: FrameworkConfig) -> None: + """Collect rules from .cursorrules and .cursor/rules/*.mdc.""" + # .cursorrules file + for rel in _PROJECT_RULE_FILES: + path = target / rel + content = _read_text(path) if path.is_file() else None + if content is not None: + config.config_paths.append(path) + config.rules.append( + RuleConfig( + name=path.name, + content=content, + source_file=str(path), + activation_mode="always", + ) + ) + + # .cursor/rules/*.mdc files + for rel in _PROJECT_RULE_DIRS: + dir_path = target / rel + if not dir_path.is_dir(): + continue + for child in sorted(dir_path.iterdir()): + if not child.is_file() or child.suffix != ".mdc": + continue + content = _read_text(child) + if content is None: + continue + + rule = _parse_mdc(content, str(child)) + if rule is not None: + config.rules.append(rule) + + # ------------------------------------------------------------------ + # Task extraction (hooks) + # ------------------------------------------------------------------ + + def _extract_tasks(self, target: Path, config: FrameworkConfig) -> None: + """Extract VS Code tasks with runOn: folderOpen as hooks. + + Cursor inherits the VS Code task runner. Tasks with + "runOptions": {"runOn": "folderOpen"} execute automatically + when the workspace opens. + """ + for rel in _PROJECT_TASKS: + path = target / rel + if not path.is_file(): + continue + data = _read_json(path) + if data is None: + continue + + tasks = data.get("tasks", []) + if not isinstance(tasks, list): + continue + + for task in tasks: + if not isinstance(task, dict): + continue + + run_options = task.get("runOptions", {}) + if not isinstance(run_options, dict): + continue + + run_on = run_options.get("runOn", "") + if run_on != "folderOpen": + continue + + task.get("label", "unnamed_task") + command = task.get("command", "") + task_type = task.get("type", "shell") + + if not command: + continue + + config.hooks.append( + HookConfig( + event="folderOpen", + hook_type=task_type, + command=command, + source_file=str(path), + ) + ) diff --git a/src/agentsec/adapters/vscode_copilot.py b/src/agentsec/adapters/vscode_copilot.py new file mode 100644 index 0000000..a7a4983 --- /dev/null +++ b/src/agentsec/adapters/vscode_copilot.py @@ -0,0 +1,307 @@ +"""VS Code Copilot adapter — parses GitHub Copilot configuration into FrameworkConfig. + +Reads VS Code MCP server declarations (note: uses "servers" key, not "mcpServers"), +settings.json for auto-approve config, tasks.json for runOn hooks, +copilot-instructions.md, and AGENTS.md rule files. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +from agentsec.adapters.base import ( + FrameworkAdapter, + FrameworkConfig, + HookConfig, + McpServerConfig, + RuleConfig, +) + +logger = logging.getLogger(__name__) + +# MCP config files (project-level, relative to target) +_PROJECT_MCP_CONFIGS = [ + ".vscode/mcp.json", +] + +# VS Code settings (project-level) +_PROJECT_SETTINGS = [ + ".vscode/settings.json", +] + +# VS Code tasks (project-level) +_PROJECT_TASKS = [ + ".vscode/tasks.json", +] + +# Copilot instruction files (project-level) +_PROJECT_RULE_FILES = [ + ".github/copilot-instructions.md", + "AGENTS.md", +] + + +def _read_json(path: Path) -> dict[str, Any] | None: + """Read and parse a JSON file, returning None on any error.""" + try: + data: dict[str, Any] = json.loads(path.read_text()) + return data + except json.JSONDecodeError: + logger.debug("Malformed JSON in %s", path) + return None + except OSError as e: + logger.debug("Could not read %s: %s", path, e) + return None + except PermissionError: + logger.debug("Permission denied reading %s", path) + return None + + +def _read_text(path: Path) -> str | None: + """Read a text file, returning None on any error.""" + try: + return path.read_text(errors="replace") + except OSError as e: + logger.debug("Could not read %s: %s", path, e) + return None + except PermissionError: + logger.debug("Permission denied reading %s", path) + return None + + +class VSCodeCopilotAdapter(FrameworkAdapter): + """Adapter for VS Code with GitHub Copilot configuration.""" + + @property + def name(self) -> str: + return "vscode_copilot" + + @property + def display_name(self) -> str: + return "VS Code Copilot" + + @property + def known_config_paths(self) -> list[str]: + return [ + ".vscode/mcp.json", + ".vscode/settings.json", + ".vscode/tasks.json", + ".github/copilot-instructions.md", + "AGENTS.md", + ] + + def detect(self, target: Path) -> bool: + """Check for VS Code Copilot markers at the target.""" + if (target / ".vscode" / "mcp.json").is_file(): + return True + if (target / ".github" / "copilot-instructions.md").is_file(): + return True + return bool((target / "AGENTS.md").is_file()) + + def discover_configs(self, target: Path) -> list[Path]: + """Return all VS Code Copilot config files found at project level.""" + found: list[Path] = [] + + # MCP configs + for rel in _PROJECT_MCP_CONFIGS: + path = target / rel + if path.is_file(): + found.append(path) + + # VS Code settings + for rel in _PROJECT_SETTINGS: + path = target / rel + if path.is_file(): + found.append(path) + + # VS Code tasks + for rel in _PROJECT_TASKS: + path = target / rel + if path.is_file(): + found.append(path) + + # Rule files + for rel in _PROJECT_RULE_FILES: + path = target / rel + if path.is_file(): + found.append(path) + + return found + + def parse(self, target: Path) -> FrameworkConfig: + """Parse all VS Code Copilot configs into a normalized FrameworkConfig.""" + config = FrameworkConfig(framework=self.name) + raw: dict[str, Any] = {} + + # Parse MCP configs (uses "servers" key, not "mcpServers") + for rel in _PROJECT_MCP_CONFIGS: + path = target / rel + if not path.is_file(): + continue + data = _read_json(path) + if data is None: + continue + config.config_paths.append(path) + raw[rel] = data + self._extract_mcp_servers(data, config) + + # Parse VS Code settings for auto-approve config + for rel in _PROJECT_SETTINGS: + path = target / rel + if not path.is_file(): + continue + data = _read_json(path) + if data is None: + continue + config.config_paths.append(path) + raw[rel] = data + self._extract_settings(data, config) + + # Parse VS Code tasks for hooks + for rel in _PROJECT_TASKS: + path = target / rel + if not path.is_file(): + continue + data = _read_json(path) + if data is None: + continue + config.config_paths.append(path) + raw[rel] = data + self._extract_tasks(data, config, str(path)) + + # Parse rule files + self._extract_rules(target, config) + + config.raw_configs = raw + return config + + # ------------------------------------------------------------------ + # MCP server extraction + # ------------------------------------------------------------------ + + def _extract_mcp_servers(self, data: dict[str, Any], config: FrameworkConfig) -> None: + """Parse MCP server declarations from .vscode/mcp.json. + + VS Code uses the "servers" key, NOT "mcpServers" like Claude Code + and Cursor. Each server entry has the same structure (command, args, env). + """ + servers = data.get("servers", {}) + if not isinstance(servers, dict): + return + + for server_name, server_data in servers.items(): + if not isinstance(server_data, dict): + continue + + command = server_data.get("command", "") + args = server_data.get("args", []) + env = server_data.get("env", {}) + transport = server_data.get("transport", "stdio") + + if not isinstance(command, str): + command = str(command) + if not isinstance(args, list): + args = [] + if not isinstance(env, dict): + env = {} + if not isinstance(transport, str): + transport = "stdio" + + requires_auth = bool(server_data.get("auth") or server_data.get("headers")) + + config.mcp_servers.append( + McpServerConfig( + name=server_name, + command=command, + args=[str(a) for a in args], + env={k: str(v) for k, v in env.items() if isinstance(v, str)}, + transport=transport, + requires_auth=requires_auth, + ) + ) + + # ------------------------------------------------------------------ + # Settings extraction + # ------------------------------------------------------------------ + + def _extract_settings(self, data: dict[str, Any], config: FrameworkConfig) -> None: + """Extract Copilot auto-approve settings from .vscode/settings.json. + + Checks github.copilot.chat.agent.autoApprove which controls whether + Copilot agent tool calls are auto-approved without user confirmation. + """ + auto_approve = data.get("github.copilot.chat.agent.autoApprove") + if auto_approve is None: + return + + if isinstance(auto_approve, bool) and auto_approve: + config.permissions.auto_approve_tools.append("*") + elif isinstance(auto_approve, list): + config.permissions.auto_approve_tools.extend(str(t) for t in auto_approve) + + # ------------------------------------------------------------------ + # Task extraction (hooks) + # ------------------------------------------------------------------ + + def _extract_tasks( + self, data: dict[str, Any], config: FrameworkConfig, source_file: str + ) -> None: + """Extract VS Code tasks with runOn: folderOpen as hooks. + + Tasks with "runOptions": {"runOn": "folderOpen"} execute automatically + when the workspace opens, which is a potential attack vector. + """ + tasks = data.get("tasks", []) + if not isinstance(tasks, list): + return + + for task in tasks: + if not isinstance(task, dict): + continue + + run_options = task.get("runOptions", {}) + if not isinstance(run_options, dict): + continue + + run_on = run_options.get("runOn", "") + if run_on != "folderOpen": + continue + + task.get("label", "unnamed_task") + command = task.get("command", "") + task_type = task.get("type", "shell") + + if not command: + continue + + config.hooks.append( + HookConfig( + event="folderOpen", + hook_type=task_type, + command=command, + source_file=source_file, + ) + ) + + # ------------------------------------------------------------------ + # Rule extraction + # ------------------------------------------------------------------ + + def _extract_rules(self, target: Path, config: FrameworkConfig) -> None: + """Collect rules from copilot-instructions.md and AGENTS.md.""" + for rel in _PROJECT_RULE_FILES: + path = target / rel + content = _read_text(path) if path.is_file() else None + if content is not None: + config.config_paths.append(path) + config.rules.append( + RuleConfig( + name=path.name, + content=content, + source_file=str(path), + activation_mode="always", + ) + ) diff --git a/src/agentsec/adapters/windsurf.py b/src/agentsec/adapters/windsurf.py new file mode 100644 index 0000000..63774a2 --- /dev/null +++ b/src/agentsec/adapters/windsurf.py @@ -0,0 +1,259 @@ +"""Windsurf adapter — parses Windsurf configuration into FrameworkConfig. + +Reads Windsurf MCP server declarations from project and user-level configs, +.windsurfrules, and .windsurf/rules/*.md rule files. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +from agentsec.adapters.base import ( + FrameworkAdapter, + FrameworkConfig, + McpServerConfig, + RuleConfig, +) + +logger = logging.getLogger(__name__) + +# MCP config files (project-level, relative to target) +_PROJECT_MCP_CONFIGS = [ + ".windsurf/mcp.json", +] + +# MCP config files (user-level, relative to home) +_USER_MCP_CONFIGS = [ + ".windsurf/mcp.json", + ".codeium/windsurf/mcp_config.json", +] + +# Rule sources (project-level) +_PROJECT_RULE_FILES = [ + ".windsurfrules", +] + +# Rule directories (project-level) +_PROJECT_RULE_DIRS = [ + ".windsurf/rules", +] + + +def _read_json(path: Path) -> dict[str, Any] | None: + """Read and parse a JSON file, returning None on any error.""" + try: + data: dict[str, Any] = json.loads(path.read_text()) + return data + except json.JSONDecodeError: + logger.debug("Malformed JSON in %s", path) + return None + except OSError as e: + logger.debug("Could not read %s: %s", path, e) + return None + except PermissionError: + logger.debug("Permission denied reading %s", path) + return None + + +def _read_text(path: Path) -> str | None: + """Read a text file, returning None on any error.""" + try: + return path.read_text(errors="replace") + except OSError as e: + logger.debug("Could not read %s: %s", path, e) + return None + except PermissionError: + logger.debug("Permission denied reading %s", path) + return None + + +class WindsurfAdapter(FrameworkAdapter): + """Adapter for Windsurf editor configuration.""" + + @property + def name(self) -> str: + return "windsurf" + + @property + def display_name(self) -> str: + return "Windsurf" + + @property + def known_config_paths(self) -> list[str]: + return [ + ".windsurf/mcp.json", + "~/.windsurf/mcp.json", + "~/.codeium/windsurf/mcp_config.json", + ".windsurfrules", + ".windsurf/rules/", + ] + + def detect(self, target: Path) -> bool: + """Check for Windsurf markers at the target or in the user home.""" + if (target / ".windsurf").is_dir(): + return True + if (target / ".windsurfrules").is_file(): + return True + + home = Path.home() + if (home / ".codeium" / "windsurf").is_dir(): + return True + return bool((home / ".windsurf").is_dir()) + + def discover_configs(self, target: Path) -> list[Path]: + """Return all Windsurf config files found at project and user level.""" + found: list[Path] = [] + home = Path.home() + + # Project-level MCP configs + for rel in _PROJECT_MCP_CONFIGS: + path = target / rel + if path.is_file(): + found.append(path) + + # User-level MCP configs + for rel in _USER_MCP_CONFIGS: + path = home / rel + if path.is_file(): + found.append(path) + + # Project-level rule files + for rel in _PROJECT_RULE_FILES: + path = target / rel + if path.is_file(): + found.append(path) + + # Project-level rule directories + for rel in _PROJECT_RULE_DIRS: + dir_path = target / rel + if dir_path.is_dir(): + for child in dir_path.iterdir(): + if child.is_file() and child.suffix == ".md": + found.append(child) + + return found + + def parse(self, target: Path) -> FrameworkConfig: + """Parse all Windsurf configs into a normalized FrameworkConfig.""" + home = Path.home() + config = FrameworkConfig(framework=self.name) + raw: dict[str, Any] = {} + + # Parse project-level MCP configs + for rel in _PROJECT_MCP_CONFIGS: + path = target / rel + if not path.is_file(): + continue + data = _read_json(path) + if data is None: + continue + config.config_paths.append(path) + raw[rel] = data + self._extract_mcp_servers(data, config) + + # Parse user-level MCP configs + for rel in _USER_MCP_CONFIGS: + path = home / rel + if not path.is_file(): + continue + data = _read_json(path) + if data is None: + continue + config.config_paths.append(path) + raw[f"~/{rel}"] = data + self._extract_mcp_servers(data, config) + + # Parse rule files + self._extract_rules(target, config) + + config.raw_configs = raw + return config + + # ------------------------------------------------------------------ + # MCP server extraction + # ------------------------------------------------------------------ + + def _extract_mcp_servers(self, data: dict[str, Any], config: FrameworkConfig) -> None: + """Parse MCP server declarations from Windsurf MCP config files. + + Windsurf uses the mcpServers key, same structure as Claude Code and Cursor. + """ + servers = data.get("mcpServers", {}) + if not isinstance(servers, dict): + return + + for server_name, server_data in servers.items(): + if not isinstance(server_data, dict): + continue + + command = server_data.get("command", "") + args = server_data.get("args", []) + env = server_data.get("env", {}) + transport = server_data.get("transport", "stdio") + + if not isinstance(command, str): + command = str(command) + if not isinstance(args, list): + args = [] + if not isinstance(env, dict): + env = {} + if not isinstance(transport, str): + transport = "stdio" + + requires_auth = bool(server_data.get("auth") or server_data.get("headers")) + + config.mcp_servers.append( + McpServerConfig( + name=server_name, + command=command, + args=[str(a) for a in args], + env={k: str(v) for k, v in env.items() if isinstance(v, str)}, + transport=transport, + requires_auth=requires_auth, + ) + ) + + # ------------------------------------------------------------------ + # Rule extraction + # ------------------------------------------------------------------ + + def _extract_rules(self, target: Path, config: FrameworkConfig) -> None: + """Collect rules from .windsurfrules and .windsurf/rules/*.md.""" + # .windsurfrules file + for rel in _PROJECT_RULE_FILES: + path = target / rel + content = _read_text(path) if path.is_file() else None + if content is not None: + config.config_paths.append(path) + config.rules.append( + RuleConfig( + name=path.name, + content=content, + source_file=str(path), + activation_mode="always", + ) + ) + + # .windsurf/rules/*.md files + for rel in _PROJECT_RULE_DIRS: + dir_path = target / rel + if not dir_path.is_dir(): + continue + for child in sorted(dir_path.iterdir()): + if not child.is_file() or child.suffix != ".md": + continue + content = _read_text(child) + if content is None: + continue + + config.rules.append( + RuleConfig( + name=child.stem, + content=content, + source_file=str(child), + activation_mode="always", + ) + ) diff --git a/src/agentsec/cli.py b/src/agentsec/cli.py index 1487ae3..263e13b 100644 --- a/src/agentsec/cli.py +++ b/src/agentsec/cli.py @@ -56,6 +56,7 @@ class _WorkflowGroup(click.Group): """Click group that lists commands in workflow order instead of alphabetical.""" _COMMAND_ORDER = [ + "discover", "scan", "harden", "gate", @@ -182,6 +183,13 @@ def main() -> None: default=False, help="Actively verify discovered credentials via safe, read-only API probes", ) +@click.option( + "--all", + "scan_all", + is_flag=True, + default=False, + help="Auto-discover all installed agents and scan each one", +) def scan( target: str, output: str, @@ -197,6 +205,7 @@ def scan( scan_history: bool, history_depth: int, verify: bool, + scan_all: bool, ) -> None: """Scan an agent installation for security vulnerabilities. @@ -220,6 +229,53 @@ def scan( """ _configure_logging(verbose) + # When --all is passed, discover agents and scan each install path + if scan_all: + from agentsec.discovery import discover_agents + + agents = discover_agents(target=Path(target).expanduser().resolve()) + if not agents: + console.print("[yellow]No installed agents found.[/yellow]") + sys.exit(0) + + if not quiet: + console.print(f"[bold]Discovered {len(agents)} agent(s).[/bold] Scanning each...\n") + + combined_exit = 0 + for agent in agents: + scan_path = agent.install_path or agent.config_dir + if not scan_path or not scan_path.exists(): + continue + if not quiet: + console.print(f"[cyan]{agent.display_name}[/cyan]: {scan_path}") + # Re-invoke the scan command context without --all to avoid recursion + ctx = click.get_current_context() + try: + ctx.invoke( + scan, + target=str(scan_path), + output=output, + output_file=output_file, + scanners=scanners, + fail_on=fail_on, + policy=policy, + verbose=verbose, + quiet=quiet, + baseline=baseline, + create_baseline=create_baseline, + show_baseline=show_baseline, + scan_history=scan_history, + history_depth=history_depth, + verify=verify, + scan_all=False, + ) + except SystemExit as e: + if e.code and int(e.code) > combined_exit: + combined_exit = int(e.code) + if not quiet: + console.print() + sys.exit(combined_exit) + target_path = Path(target).expanduser().resolve() # Build config @@ -1075,5 +1131,104 @@ def pin_tools(target: str, verbose: bool) -> None: ) +@main.command() +@click.argument("target", default=".", required=False, type=click.Path()) +@click.option( + "--detect-versions", + is_flag=True, + default=False, + help="Try to detect installed agent versions via their CLI binaries", +) +@click.option( + "--json", + "output_json", + is_flag=True, + default=False, + help="Output results as JSON instead of a Rich table", +) +def discover(target: str, detect_versions: bool, output_json: bool) -> None: + """Discover AI agents installed on this system. + + Checks for known agent installations (Claude Code, Cursor, Windsurf, + VS Code Copilot, etc.) by looking for marker files, config directories, + and MCP configuration files. + + If TARGET is provided, also checks for project-level agent configs + under that directory. + + \b + Examples: + agentsec discover # find all installed agents + agentsec discover --detect-versions # also detect version strings + agentsec discover --json # machine-readable output + agentsec discover ~/my-project # include project-level configs + """ + import json as json_mod + + from agentsec.discovery import discover_agents + + target_path = Path(target).expanduser().resolve() if target != "." else None + + agents = discover_agents(target=target_path, detect_versions=detect_versions) + + if output_json: + records = [] + for agent in agents: + records.append( + { + "name": agent.name, + "display_name": agent.display_name, + "agent_type": agent.agent_type, + "install_path": str(agent.install_path) if agent.install_path else None, + "config_dir": str(agent.config_dir) if agent.config_dir else None, + "version": agent.version, + "mcp_config_paths": [str(p) for p in agent.mcp_config_paths], + "config_files_found": [str(p) for p in agent.config_files_found], + "scope": agent.scope, + "supported": agent.supported, + } + ) + click.echo(json_mod.dumps(records, indent=2)) + return + + if not agents: + console.print("[yellow]No AI agents found on this system.[/yellow]") + return + + table = Table(title=f"Discovered Agents ({len(agents)} found)") + table.add_column("Agent", style="bold cyan") + table.add_column("Scope", width=8) + table.add_column("Config Files", min_width=20) + table.add_column("MCP Configs", min_width=20) + if detect_versions: + table.add_column("Version") + + for agent in agents: + config_str = "\n".join(str(p) for p in agent.config_files_found) or "[dim]none[/dim]" + mcp_str = "\n".join(str(p) for p in agent.mcp_config_paths) or "[dim]none[/dim]" + supported_marker = "" if agent.supported else " [dim](unsupported)[/dim]" + + row = [ + f"{agent.display_name}{supported_marker}", + agent.scope, + config_str, + mcp_str, + ] + if detect_versions: + row.append(agent.version or "[dim]unknown[/dim]") + + table.add_row(*row) + + console.print() + console.print(table) + + supported_count = sum(1 for a in agents if a.supported) + unsupported_count = len(agents) - supported_count + console.print(f"\n[dim]{supported_count} supported, {unsupported_count} unsupported[/dim]") + console.print( + "[dim]Use [cyan]agentsec scan --all[/cyan] to scan all discovered agents.[/dim]\n" + ) + + if __name__ == "__main__": main() diff --git a/src/agentsec/discovery.py b/src/agentsec/discovery.py new file mode 100644 index 0000000..37bcaf6 --- /dev/null +++ b/src/agentsec/discovery.py @@ -0,0 +1,206 @@ +"""Auto-discovery of installed AI agents. + +Scans the local filesystem for known AI agent installations by checking +marker paths, config files, and MCP configuration locations from the +agent registry. Works across macOS, Linux, and Windows. +""" + +from __future__ import annotations + +import glob +import logging +import os +import platform +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from agentsec.models.agents import AGENT_REGISTRY + +logger = logging.getLogger(__name__) + + +@dataclass +class DiscoveredAgent: + """Result of discovering an installed AI agent.""" + + name: str + display_name: str + agent_type: str + install_path: Path | None + config_dir: Path | None + version: str | None + mcp_config_paths: list[Path] + config_files_found: list[Path] + scope: str # "global" or "project" + supported: bool + + +def _expand_path(raw: str) -> str: + """Expand ~ and environment variables in a path string.""" + return os.path.expandvars(os.path.expanduser(raw)) + + +def _resolve_paths(raw_paths: list[str]) -> list[Path]: + """Expand and resolve a list of path strings, returning those that exist. + + Handles glob patterns (e.g. paths containing *) by expanding them. + """ + found: list[Path] = [] + for raw in raw_paths: + expanded = _expand_path(raw) + if "*" in expanded or "?" in expanded: + matches = glob.glob(expanded) + for m in matches: + p = Path(m) + if p.exists(): + found.append(p) + else: + p = Path(expanded) + if p.exists(): + found.append(p) + return found + + +def _resolve_project_paths(raw_paths: list[str], target: Path) -> list[Path]: + """Resolve paths relative to a project target directory. + + Only considers paths that look project-relative (no ~ or env vars at start). + """ + found: list[Path] = [] + for raw in raw_paths: + if raw.startswith("~") or raw.startswith("%") or raw.startswith("$"): + continue + candidate = target / raw + if "*" in str(candidate) or "?" in str(candidate): + matches = glob.glob(str(candidate)) + for m in matches: + p = Path(m) + if p.exists(): + found.append(p) + elif candidate.exists(): + found.append(candidate) + return found + + +def _detect_version(binary_names: list[str]) -> str | None: + """Try to detect an agent's version by running its binary with --version.""" + for binary in binary_names: + which_result = shutil.which(binary) + if not which_result: + continue + try: + result = subprocess.run( + [which_result, "--version"], + capture_output=True, + text=True, + timeout=5, + ) + output = (result.stdout or "").strip() + if not output: + output = (result.stderr or "").strip() + if output: + # Take the first line, strip common prefixes + first_line = output.splitlines()[0].strip() + return first_line + except (subprocess.TimeoutExpired, OSError, subprocess.SubprocessError): + logger.debug("Failed to get version for %s", binary) + continue + return None + + +def discover_agents( + target: Path | None = None, + detect_versions: bool = False, +) -> list[DiscoveredAgent]: + """Discover installed AI agents on this system. + + Checks each agent in AGENT_REGISTRY for the presence of marker paths, + config files, and MCP configurations on the current OS. + + Args: + target: Optional project directory to also check for project-level + agent configs (e.g. .cursor/mcp.json, .vscode/mcp.json). + detect_versions: If True, attempt to detect agent versions by + running their binaries with --version. + + Returns: + List of DiscoveredAgent sorted by display_name. + """ + current_os = platform.system().lower() + # Normalize platform.system() output to match registry keys + os_key_map = { + "darwin": "darwin", + "linux": "linux", + "windows": "windows", + } + os_key = os_key_map.get(current_os) + if os_key is None: + logger.warning("Unsupported platform: %s", current_os) + return [] + + discovered: list[DiscoveredAgent] = [] + + for agent_def in AGENT_REGISTRY.values(): + marker_paths_raw = agent_def.marker_paths.get(os_key, []) + config_paths_raw = agent_def.config_paths.get(os_key, []) + mcp_paths_raw = agent_def.mcp_config_paths.get(os_key, []) + + # Check global marker paths + global_markers = _resolve_paths(marker_paths_raw) + + # Check project-level paths + project_configs: list[Path] = [] + project_mcp: list[Path] = [] + if target is not None: + project_configs = _resolve_project_paths(config_paths_raw, target) + project_mcp = _resolve_project_paths(mcp_paths_raw, target) + + has_global = len(global_markers) > 0 + has_project = len(project_configs) > 0 or len(project_mcp) > 0 + + if not has_global and not has_project: + continue + + # Collect all config files that exist (global + project) + global_configs = _resolve_paths(config_paths_raw) + global_mcp = _resolve_paths(mcp_paths_raw) + + all_configs = list(dict.fromkeys(global_configs + project_configs)) + all_mcp = list(dict.fromkeys(global_mcp + project_mcp)) + + # Determine install path from first marker + install_path = global_markers[0] if global_markers else None + + # Determine config dir from first config found + config_dir = None + if all_configs: + config_dir = all_configs[0].parent + elif install_path: + config_dir = install_path if install_path.is_dir() else install_path.parent + + # Version detection + version = None + if detect_versions and agent_def.binary_names: + version = _detect_version(agent_def.binary_names) + + scope = "project" if (has_project and not has_global) else "global" + + discovered.append( + DiscoveredAgent( + name=agent_def.name, + display_name=agent_def.display_name, + agent_type=agent_def.agent_type, + install_path=install_path, + config_dir=config_dir, + version=version, + mcp_config_paths=all_mcp, + config_files_found=all_configs, + scope=scope, + supported=agent_def.supported, + ) + ) + + discovered.sort(key=lambda a: a.display_name) + return discovered diff --git a/src/agentsec/models/agents.py b/src/agentsec/models/agents.py new file mode 100644 index 0000000..24f3ead --- /dev/null +++ b/src/agentsec/models/agents.py @@ -0,0 +1,401 @@ +"""Agent definitions for auto-discovery and multi-framework scanning. + +Single source of truth for AI agent installation paths, marker files, +and config locations across macOS, Linux, and Windows. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class AgentDefinition: + """Definition of a known AI agent for discovery and scanning.""" + + name: str + display_name: str + agent_type: str + marker_paths: dict[str, list[str]] = field(default_factory=dict) + config_paths: dict[str, list[str]] = field(default_factory=dict) + mcp_config_paths: dict[str, list[str]] = field(default_factory=dict) + binary_names: list[str] = field(default_factory=list) + env_overrides: list[str] = field(default_factory=list) + supported: bool = False + + +AGENT_REGISTRY: dict[str, AgentDefinition] = { + "claude_code": AgentDefinition( + name="claude_code", + display_name="Claude Code", + agent_type="claude-code", + marker_paths={ + "darwin": ["~/.claude/"], + "linux": ["~/.claude/"], + "windows": ["%USERPROFILE%\\.claude\\"], + }, + config_paths={ + "darwin": [ + "~/.claude/settings.json", + ".claude/settings.json", + ".claude/settings.local.json", + ], + "linux": [ + "~/.claude/settings.json", + ".claude/settings.json", + ".claude/settings.local.json", + ], + "windows": [ + "%USERPROFILE%\\.claude\\settings.json", + ".claude\\settings.json", + ".claude\\settings.local.json", + ], + }, + mcp_config_paths={ + "darwin": ["~/.claude.json", ".mcp.json"], + "linux": ["~/.claude.json", ".mcp.json"], + "windows": ["%USERPROFILE%\\.claude.json", ".mcp.json"], + }, + binary_names=["claude"], + supported=True, + ), + "claude_desktop": AgentDefinition( + name="claude_desktop", + display_name="Claude Desktop", + agent_type="claude-desktop", + marker_paths={ + "darwin": ["~/Library/Application Support/Claude/"], + "linux": ["~/.config/claude-desktop/"], + "windows": ["%APPDATA%\\Claude\\"], + }, + config_paths={ + "darwin": [ + "~/Library/Application Support/Claude/claude_desktop_config.json", + ], + "linux": ["~/.config/claude-desktop/claude_desktop_config.json"], + "windows": ["%APPDATA%\\Claude\\claude_desktop_config.json"], + }, + mcp_config_paths={ + "darwin": [ + "~/Library/Application Support/Claude/claude_desktop_config.json", + ], + "linux": ["~/.config/claude-desktop/claude_desktop_config.json"], + "windows": ["%APPDATA%\\Claude\\claude_desktop_config.json"], + }, + supported=False, + ), + "cursor": AgentDefinition( + name="cursor", + display_name="Cursor", + agent_type="cursor", + marker_paths={ + "darwin": ["~/.cursor/"], + "linux": ["~/.cursor/"], + "windows": ["%USERPROFILE%\\.cursor\\"], + }, + config_paths={ + "darwin": [ + "~/.cursor/mcp.json", + ".cursor/mcp.json", + ".cursorrules", + ], + "linux": [ + "~/.cursor/mcp.json", + ".cursor/mcp.json", + ".cursorrules", + ], + "windows": [ + "%USERPROFILE%\\.cursor\\mcp.json", + ".cursor\\mcp.json", + ".cursorrules", + ], + }, + mcp_config_paths={ + "darwin": ["~/.cursor/mcp.json", ".cursor/mcp.json"], + "linux": ["~/.cursor/mcp.json", ".cursor/mcp.json"], + "windows": [ + "%USERPROFILE%\\.cursor\\mcp.json", + ".cursor\\mcp.json", + ], + }, + binary_names=["cursor"], + supported=True, + ), + "windsurf": AgentDefinition( + name="windsurf", + display_name="Windsurf", + agent_type="windsurf", + marker_paths={ + "darwin": ["~/.codeium/windsurf/", "~/.windsurf/"], + "linux": ["~/.codeium/windsurf/", "~/.windsurf/"], + "windows": [ + "%USERPROFILE%\\.codeium\\windsurf\\", + "%USERPROFILE%\\.windsurf\\", + ], + }, + config_paths={ + "darwin": [ + "~/.codeium/windsurf/mcp_config.json", + ".windsurf/mcp.json", + ".windsurfrules", + ], + "linux": [ + "~/.codeium/windsurf/mcp_config.json", + ".windsurf/mcp.json", + ".windsurfrules", + ], + "windows": [ + "%USERPROFILE%\\.codeium\\windsurf\\mcp_config.json", + ".windsurf\\mcp.json", + ".windsurfrules", + ], + }, + mcp_config_paths={ + "darwin": [ + "~/.codeium/windsurf/mcp_config.json", + ".windsurf/mcp.json", + ], + "linux": [ + "~/.codeium/windsurf/mcp_config.json", + ".windsurf/mcp.json", + ], + "windows": [ + "%USERPROFILE%\\.codeium\\windsurf\\mcp_config.json", + ".windsurf\\mcp.json", + ], + }, + supported=True, + ), + "vscode_copilot": AgentDefinition( + name="vscode_copilot", + display_name="VS Code Copilot", + agent_type="vscode-copilot", + marker_paths={ + "darwin": ["~/.vscode/extensions/github.copilot-*"], + "linux": ["~/.vscode/extensions/github.copilot-*"], + "windows": ["%USERPROFILE%\\.vscode\\extensions\\github.copilot-*"], + }, + config_paths={ + "darwin": [ + ".vscode/mcp.json", + ".vscode/settings.json", + ".vscode/tasks.json", + ".github/copilot-instructions.md", + ], + "linux": [ + ".vscode/mcp.json", + ".vscode/settings.json", + ".vscode/tasks.json", + ".github/copilot-instructions.md", + ], + "windows": [ + ".vscode\\mcp.json", + ".vscode\\settings.json", + ".vscode\\tasks.json", + ".github\\copilot-instructions.md", + ], + }, + mcp_config_paths={ + "darwin": [".vscode/mcp.json"], + "linux": [".vscode/mcp.json"], + "windows": [".vscode\\mcp.json"], + }, + binary_names=["code"], + supported=True, + ), + "gemini_cli": AgentDefinition( + name="gemini_cli", + display_name="Gemini CLI", + agent_type="gemini-cli", + marker_paths={ + "darwin": ["~/.gemini/"], + "linux": ["~/.gemini/"], + "windows": ["%USERPROFILE%\\.gemini\\"], + }, + config_paths={ + "darwin": ["~/.gemini/settings.json", ".gemini/settings.json"], + "linux": ["~/.gemini/settings.json", ".gemini/settings.json"], + "windows": [ + "%USERPROFILE%\\.gemini\\settings.json", + ".gemini\\settings.json", + ], + }, + mcp_config_paths={ + "darwin": ["~/.gemini/settings.json"], + "linux": ["~/.gemini/settings.json"], + "windows": ["%USERPROFILE%\\.gemini\\settings.json"], + }, + binary_names=["gemini"], + env_overrides=["GEMINI_CLI_HOME"], + supported=False, + ), + "codex": AgentDefinition( + name="codex", + display_name="OpenAI Codex CLI", + agent_type="codex", + marker_paths={ + "darwin": ["~/.codex/"], + "linux": ["~/.codex/"], + "windows": ["%USERPROFILE%\\.codex\\"], + }, + config_paths={ + "darwin": ["~/.codex/config.toml", ".codex/config.toml"], + "linux": ["~/.codex/config.toml", ".codex/config.toml"], + "windows": [ + "%USERPROFILE%\\.codex\\config.toml", + ".codex\\config.toml", + ], + }, + mcp_config_paths={ + "darwin": ["~/.codex/config.toml"], + "linux": ["~/.codex/config.toml"], + "windows": ["%USERPROFILE%\\.codex\\config.toml"], + }, + binary_names=["codex"], + env_overrides=["CODEX_HOME"], + supported=False, + ), + "openclaw": AgentDefinition( + name="openclaw", + display_name="OpenClaw", + agent_type="openclaw", + marker_paths={ + "darwin": ["~/.openclaw/", "~/.clawdbot/"], + "linux": ["~/.openclaw/", "~/.clawdbot/"], + "windows": [ + "%USERPROFILE%\\.openclaw\\", + "%USERPROFILE%\\.clawdbot\\", + ], + }, + config_paths={ + "darwin": ["~/.openclaw/openclaw.json"], + "linux": ["~/.openclaw/openclaw.json"], + "windows": ["%USERPROFILE%\\.openclaw\\openclaw.json"], + }, + mcp_config_paths={ + "darwin": ["~/.openclaw/openclaw.json"], + "linux": ["~/.openclaw/openclaw.json"], + "windows": ["%USERPROFILE%\\.openclaw\\openclaw.json"], + }, + env_overrides=["OPENCLAW_HOME"], + supported=True, + ), + "amazon_q": AgentDefinition( + name="amazon_q", + display_name="Amazon Q Developer", + agent_type="amazon-q", + marker_paths={ + "darwin": ["~/.aws/amazonq/"], + "linux": ["~/.aws/amazonq/"], + "windows": ["%USERPROFILE%\\.aws\\amazonq\\"], + }, + config_paths={ + "darwin": ["~/.aws/amazonq/mcp.json", ".amazonq/default.json"], + "linux": ["~/.aws/amazonq/mcp.json", ".amazonq/default.json"], + "windows": [ + "%USERPROFILE%\\.aws\\amazonq\\mcp.json", + ".amazonq\\default.json", + ], + }, + mcp_config_paths={ + "darwin": ["~/.aws/amazonq/mcp.json", ".amazonq/default.json"], + "linux": ["~/.aws/amazonq/mcp.json", ".amazonq/default.json"], + "windows": [ + "%USERPROFILE%\\.aws\\amazonq\\mcp.json", + ".amazonq\\default.json", + ], + }, + binary_names=["q"], + supported=False, + ), + "kiro": AgentDefinition( + name="kiro", + display_name="Kiro", + agent_type="kiro", + marker_paths={ + "darwin": ["~/.kiro/"], + "linux": ["~/.kiro/"], + "windows": ["%USERPROFILE%\\.kiro\\"], + }, + config_paths={ + "darwin": ["~/.kiro/settings/mcp.json", ".kiro/mcp.json"], + "linux": ["~/.kiro/settings/mcp.json", ".kiro/mcp.json"], + "windows": [ + "%USERPROFILE%\\.kiro\\settings\\mcp.json", + ".kiro\\mcp.json", + ], + }, + mcp_config_paths={ + "darwin": ["~/.kiro/settings/mcp.json", ".kiro/mcp.json"], + "linux": ["~/.kiro/settings/mcp.json", ".kiro/mcp.json"], + "windows": [ + "%USERPROFILE%\\.kiro\\settings\\mcp.json", + ".kiro\\mcp.json", + ], + }, + supported=False, + ), + "continue_dev": AgentDefinition( + name="continue_dev", + display_name="Continue", + agent_type="continue", + marker_paths={ + "darwin": ["~/.continue/"], + "linux": ["~/.continue/"], + "windows": ["%USERPROFILE%\\.continue\\"], + }, + config_paths={ + "darwin": ["~/.continue/config.yaml", "~/.continue/config.json"], + "linux": ["~/.continue/config.yaml", "~/.continue/config.json"], + "windows": [ + "%USERPROFILE%\\.continue\\config.yaml", + "%USERPROFILE%\\.continue\\config.json", + ], + }, + mcp_config_paths={ + "darwin": [".continue/mcpServers/mcp.json"], + "linux": [".continue/mcpServers/mcp.json"], + "windows": [".continue\\mcpServers\\mcp.json"], + }, + supported=False, + ), + "roo_code": AgentDefinition( + name="roo_code", + display_name="Roo Code", + agent_type="roo-code", + marker_paths={ + "darwin": [ + "~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/", + ], + "linux": [ + "~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/", + ], + "windows": [ + "%APPDATA%\\Code\\User\\globalStorage\\rooveterinaryinc.roo-cline\\", + ], + }, + config_paths={ + "darwin": [".roo/mcp.json"], + "linux": [".roo/mcp.json"], + "windows": [".roo\\mcp.json"], + }, + mcp_config_paths={ + "darwin": [".roo/mcp.json"], + "linux": [".roo/mcp.json"], + "windows": [".roo\\mcp.json"], + }, + supported=False, + ), +} + + +def get_agent(name: str) -> AgentDefinition | None: + return AGENT_REGISTRY.get(name) + + +def get_supported_agents() -> list[AgentDefinition]: + return [a for a in AGENT_REGISTRY.values() if a.supported] + + +def get_all_agents() -> list[AgentDefinition]: + return list(AGENT_REGISTRY.values()) diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py new file mode 100644 index 0000000..df7dba7 --- /dev/null +++ b/tests/unit/test_adapters.py @@ -0,0 +1,1038 @@ +"""Tests for framework adapters (Claude Code, Cursor, Windsurf, VS Code Copilot).""" + +import json + +import pytest + +from agentsec.adapters.base import ( + FrameworkConfig, + PermissionsConfig, +) +from agentsec.adapters.claude_code import ClaudeCodeAdapter +from agentsec.adapters.cursor import CursorAdapter, _parse_mdc +from agentsec.adapters.vscode_copilot import VSCodeCopilotAdapter +from agentsec.adapters.windsurf import WindsurfAdapter + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def claude_adapter(): + return ClaudeCodeAdapter() + + +@pytest.fixture +def cursor_adapter(): + return CursorAdapter() + + +@pytest.fixture +def windsurf_adapter(): + return WindsurfAdapter() + + +@pytest.fixture +def vscode_adapter(): + return VSCodeCopilotAdapter() + + +# --------------------------------------------------------------------------- +# Claude Code: detect() +# --------------------------------------------------------------------------- + + +def test_claude_detect_with_dot_claude_dir(claude_adapter, tmp_path): + (tmp_path / ".claude").mkdir() + assert claude_adapter.detect(tmp_path) is True + + +def test_claude_detect_with_claude_md(claude_adapter, tmp_path): + (tmp_path / "CLAUDE.md").write_text("# Instructions") + assert claude_adapter.detect(tmp_path) is True + + +def test_claude_detect_empty_dir(claude_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.claude_code.Path.home", lambda: fake_home) + assert claude_adapter.detect(tmp_path) is False + + +# --------------------------------------------------------------------------- +# Claude Code: discover_configs() +# --------------------------------------------------------------------------- + + +def test_claude_discover_settings(claude_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.claude_code.Path.home", lambda: fake_home) + + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + settings = claude_dir / "settings.json" + settings.write_text("{}") + local_settings = claude_dir / "settings.local.json" + local_settings.write_text("{}") + + found = claude_adapter.discover_configs(tmp_path) + assert settings in found + assert local_settings in found + + +def test_claude_discover_mcp_json(claude_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.claude_code.Path.home", lambda: fake_home) + + mcp = tmp_path / ".mcp.json" + mcp.write_text("{}") + + found = claude_adapter.discover_configs(tmp_path) + assert mcp in found + + +def test_claude_discover_rule_files(claude_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.claude_code.Path.home", lambda: fake_home) + + (tmp_path / "CLAUDE.md").write_text("# Rules") + rules_dir = tmp_path / ".claude" / "rules" + rules_dir.mkdir(parents=True) + (rules_dir / "style.md").write_text("# Style") + (rules_dir / "not_md.txt").write_text("ignored") + + found = claude_adapter.discover_configs(tmp_path) + assert tmp_path / "CLAUDE.md" in found + assert rules_dir / "style.md" in found + assert rules_dir / "not_md.txt" not in found + + +def test_claude_discover_empty_project(claude_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.claude_code.Path.home", lambda: fake_home) + + found = claude_adapter.discover_configs(tmp_path) + assert found == [] + + +# --------------------------------------------------------------------------- +# Claude Code: parse() - hooks +# --------------------------------------------------------------------------- + + +def test_claude_parse_hooks(claude_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.claude_code.Path.home", lambda: fake_home) + + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + settings = { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + {"type": "command", "command": "echo safety check", "timeout": 15}, + {"type": "prompt", "prompt": "Review this tool call"}, + ], + } + ] + } + } + (claude_dir / "settings.json").write_text(json.dumps(settings)) + + config = claude_adapter.parse(tmp_path) + assert len(config.hooks) == 2 + assert config.hooks[0].event == "PreToolUse" + assert config.hooks[0].hook_type == "command" + assert config.hooks[0].command == "echo safety check" + assert config.hooks[0].timeout == 15 + assert config.hooks[1].hook_type == "prompt" + assert config.hooks[1].prompt == "Review this tool call" + + +# --------------------------------------------------------------------------- +# Claude Code: parse() - permissions +# --------------------------------------------------------------------------- + + +def test_claude_parse_permissions(claude_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.claude_code.Path.home", lambda: fake_home) + + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + settings = { + "permissions": { + "allow": ["Bash(git *)", "Read"], + "deny": ["Write(/etc/*)"], + "defaultMode": "allow", + "autoApprove": ["Read", "Glob"], + }, + "enableAllProjectMcpServers": True, + } + (claude_dir / "settings.json").write_text(json.dumps(settings)) + + config = claude_adapter.parse(tmp_path) + assert "Bash(git *)" in config.permissions.allow_rules + assert "Read" in config.permissions.allow_rules + assert "Write(/etc/*)" in config.permissions.deny_rules + assert config.permissions.default_mode == "allow" + assert "Read" in config.permissions.auto_approve_tools + assert "*" in config.permissions.auto_approve_mcp + + +# --------------------------------------------------------------------------- +# Claude Code: parse() - MCP servers +# --------------------------------------------------------------------------- + + +def test_claude_parse_mcp_servers(claude_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.claude_code.Path.home", lambda: fake_home) + + mcp_data = { + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "env": {"NODE_ENV": "production"}, + }, + "remote-server": { + "command": "mcp-client", + "transport": "sse", + "auth": True, + }, + } + } + (tmp_path / ".mcp.json").write_text(json.dumps(mcp_data)) + + config = claude_adapter.parse(tmp_path) + assert len(config.mcp_servers) == 2 + + fs_server = next(s for s in config.mcp_servers if s.name == "filesystem") + assert fs_server.command == "npx" + assert fs_server.args == ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + assert fs_server.env == {"NODE_ENV": "production"} + assert fs_server.transport == "stdio" + assert fs_server.requires_auth is False + + remote = next(s for s in config.mcp_servers if s.name == "remote-server") + assert remote.transport == "sse" + assert remote.requires_auth is True + + +# --------------------------------------------------------------------------- +# Claude Code: parse() - rules +# --------------------------------------------------------------------------- + + +def test_claude_parse_rules(claude_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.claude_code.Path.home", lambda: fake_home) + + (tmp_path / "CLAUDE.md").write_text("Top-level rules") + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + (claude_dir / "CLAUDE.md").write_text("Nested rules") + + rules_dir = claude_dir / "rules" + rules_dir.mkdir() + (rules_dir / "testing.md").write_text("Test rules") + + agents_dir = claude_dir / "agents" + agents_dir.mkdir() + (agents_dir / "reviewer.md").write_text("Agent config") + + commands_dir = claude_dir / "commands" + commands_dir.mkdir() + (commands_dir / "deploy.md").write_text("Deploy command") + + config = claude_adapter.parse(tmp_path) + + rule_names = [r.name for r in config.rules] + assert "CLAUDE.md" in rule_names + assert "testing" in rule_names + assert "reviewer" in rule_names + assert "deploy" in rule_names + + testing_rule = next(r for r in config.rules if r.name == "testing") + assert testing_rule.activation_mode == "always" + assert testing_rule.content == "Test rules" + + agent_rule = next(r for r in config.rules if r.name == "reviewer") + assert agent_rule.activation_mode == "agent" + + cmd_rule = next(r for r in config.rules if r.name == "deploy") + assert cmd_rule.activation_mode == "command" + + +# --------------------------------------------------------------------------- +# Claude Code: parse() - sandbox +# --------------------------------------------------------------------------- + + +def test_claude_parse_sandbox(claude_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.claude_code.Path.home", lambda: fake_home) + + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + settings = { + "sandbox": { + "enabled": True, + "networkAllowedDomains": ["api.example.com"], + "filesystemDenyRead": ["/etc/passwd"], + "filesystemAllowWrite": ["/tmp"], + } + } + (claude_dir / "settings.json").write_text(json.dumps(settings)) + + config = claude_adapter.parse(tmp_path) + assert config.sandbox is not None + assert config.sandbox.enabled is True + assert "api.example.com" in config.sandbox.network_allowed_domains + assert "/etc/passwd" in config.sandbox.filesystem_deny_read + assert "/tmp" in config.sandbox.filesystem_allow_write + + +# --------------------------------------------------------------------------- +# Claude Code: parse() - env vars and plugins +# --------------------------------------------------------------------------- + + +def test_claude_parse_env_and_plugins(claude_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.claude_code.Path.home", lambda: fake_home) + + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + settings = { + "env": {"OPENAI_API_KEY": "sk-test123"}, + "enabledPlugins": ["todo-manager", {"name": "custom-tool", "source": "github"}], + } + (claude_dir / "settings.json").write_text(json.dumps(settings)) + + config = claude_adapter.parse(tmp_path) + assert config.env_vars["OPENAI_API_KEY"] == "sk-test123" + assert len(config.plugins) == 2 + assert config.plugins[0].name == "todo-manager" + assert config.plugins[1].name == "custom-tool" + assert config.plugins[1].source == "github" + + +# --------------------------------------------------------------------------- +# Claude Code: adapter properties +# --------------------------------------------------------------------------- + + +def test_claude_adapter_name(claude_adapter): + assert claude_adapter.name == "claude_code" + assert claude_adapter.display_name == "Claude Code" + + +def test_claude_known_config_paths(claude_adapter): + paths = claude_adapter.known_config_paths + assert ".claude/settings.json" in paths + assert "CLAUDE.md" in paths + assert "~/.claude.json" in paths + + +# --------------------------------------------------------------------------- +# Cursor: detect() +# --------------------------------------------------------------------------- + + +def test_cursor_detect_with_dot_cursor_dir(cursor_adapter, tmp_path): + (tmp_path / ".cursor").mkdir() + assert cursor_adapter.detect(tmp_path) is True + + +def test_cursor_detect_with_cursorrules(cursor_adapter, tmp_path): + (tmp_path / ".cursorrules").write_text("rules here") + assert cursor_adapter.detect(tmp_path) is True + + +def test_cursor_detect_empty_dir(cursor_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.cursor.Path.home", lambda: fake_home) + assert cursor_adapter.detect(tmp_path) is False + + +# --------------------------------------------------------------------------- +# Cursor: discover_configs() +# --------------------------------------------------------------------------- + + +def test_cursor_discover_mcp_and_rules(cursor_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.cursor.Path.home", lambda: fake_home) + + cursor_dir = tmp_path / ".cursor" + cursor_dir.mkdir() + mcp = cursor_dir / "mcp.json" + mcp.write_text("{}") + + (tmp_path / ".cursorrules").write_text("rules") + + rules_dir = cursor_dir / "rules" + rules_dir.mkdir() + (rules_dir / "style.mdc").write_text("---\nalwaysApply: true\n---\nBody") + (rules_dir / "readme.txt").write_text("ignored") + + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + tasks = vscode_dir / "tasks.json" + tasks.write_text("{}") + + found = cursor_adapter.discover_configs(tmp_path) + assert mcp in found + assert tmp_path / ".cursorrules" in found + assert rules_dir / "style.mdc" in found + assert rules_dir / "readme.txt" not in found + assert tasks in found + + +# --------------------------------------------------------------------------- +# Cursor: _parse_mdc() +# --------------------------------------------------------------------------- + + +def test_parse_mdc_with_frontmatter(): + content = '---\ndescription: Check style\nglobs: "*.py"\nalwaysApply: false\n---\nBody content' + rule = _parse_mdc(content, "/fake/style.mdc") + assert rule is not None + assert rule.name == "style" + assert rule.content == "Body content" + assert rule.activation_mode == "glob" + assert rule.glob_pattern == "*.py" + + +def test_parse_mdc_always_apply(): + content = "---\nalwaysApply: true\n---\nAlways active" + rule = _parse_mdc(content, "/fake/always.mdc") + assert rule is not None + assert rule.activation_mode == "always" + assert rule.content == "Always active" + + +def test_parse_mdc_no_frontmatter(): + content = "Plain markdown content" + rule = _parse_mdc(content, "/fake/plain.mdc") + assert rule is not None + assert rule.activation_mode == "always" + assert rule.content == "Plain markdown content" + + +def test_parse_mdc_malformed_frontmatter(): + content = "---\nkey: value\nNo closing delimiter" + rule = _parse_mdc(content, "/fake/bad.mdc") + assert rule is not None + assert rule.activation_mode == "always" + + +def test_parse_mdc_manual_mode(): + content = "---\ndescription: Manual rule\nalwaysApply: false\n---\nManual content" + rule = _parse_mdc(content, "/fake/manual.mdc") + assert rule is not None + assert rule.activation_mode == "manual" + assert rule.glob_pattern == "" + + +# --------------------------------------------------------------------------- +# Cursor: parse() - MCP servers +# --------------------------------------------------------------------------- + + +def test_cursor_parse_mcp_servers(cursor_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.cursor.Path.home", lambda: fake_home) + + cursor_dir = tmp_path / ".cursor" + cursor_dir.mkdir() + mcp_data = { + "mcpServers": { + "db-server": { + "command": "node", + "args": ["server.js"], + "env": {"DB_HOST": "localhost"}, + "transport": "sse", + } + } + } + (cursor_dir / "mcp.json").write_text(json.dumps(mcp_data)) + + config = cursor_adapter.parse(tmp_path) + assert len(config.mcp_servers) == 1 + assert config.mcp_servers[0].name == "db-server" + assert config.mcp_servers[0].command == "node" + assert config.mcp_servers[0].args == ["server.js"] + assert config.mcp_servers[0].env == {"DB_HOST": "localhost"} + assert config.mcp_servers[0].transport == "sse" + + +# --------------------------------------------------------------------------- +# Cursor: parse() - tasks.json auto-run detection +# --------------------------------------------------------------------------- + + +def test_cursor_parse_tasks_folder_open(cursor_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.cursor.Path.home", lambda: fake_home) + + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + tasks = { + "tasks": [ + { + "label": "startup", + "type": "shell", + "command": "echo hello", + "runOptions": {"runOn": "folderOpen"}, + }, + { + "label": "manual-task", + "type": "shell", + "command": "npm test", + "runOptions": {"runOn": "default"}, + }, + ] + } + (vscode_dir / "tasks.json").write_text(json.dumps(tasks)) + + # Also need .cursor dir for detect to work during parse + (tmp_path / ".cursor").mkdir() + + config = cursor_adapter.parse(tmp_path) + assert len(config.hooks) == 1 + assert config.hooks[0].event == "folderOpen" + assert config.hooks[0].command == "echo hello" + assert config.hooks[0].hook_type == "shell" + + +# --------------------------------------------------------------------------- +# Cursor: parse() - rules +# --------------------------------------------------------------------------- + + +def test_cursor_parse_rules(cursor_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.cursor.Path.home", lambda: fake_home) + + (tmp_path / ".cursorrules").write_text("Global cursor rules") + + rules_dir = tmp_path / ".cursor" / "rules" + rules_dir.mkdir(parents=True) + (rules_dir / "python.mdc").write_text( + '---\ndescription: Python rules\nglobs: "*.py"\nalwaysApply: false\n---\nUse type hints' + ) + + config = cursor_adapter.parse(tmp_path) + assert len(config.rules) == 2 + + cursorrules = next(r for r in config.rules if r.name == ".cursorrules") + assert cursorrules.content == "Global cursor rules" + assert cursorrules.activation_mode == "always" + + python_rule = next(r for r in config.rules if r.name == "python") + assert python_rule.content == "Use type hints" + assert python_rule.activation_mode == "glob" + assert python_rule.glob_pattern == "*.py" + + +# --------------------------------------------------------------------------- +# Cursor: adapter properties +# --------------------------------------------------------------------------- + + +def test_cursor_adapter_name(cursor_adapter): + assert cursor_adapter.name == "cursor" + assert cursor_adapter.display_name == "Cursor" + + +# --------------------------------------------------------------------------- +# Windsurf: detect() +# --------------------------------------------------------------------------- + + +def test_windsurf_detect_with_dot_windsurf_dir(windsurf_adapter, tmp_path): + (tmp_path / ".windsurf").mkdir() + assert windsurf_adapter.detect(tmp_path) is True + + +def test_windsurf_detect_with_windsurfrules(windsurf_adapter, tmp_path): + (tmp_path / ".windsurfrules").write_text("rules") + assert windsurf_adapter.detect(tmp_path) is True + + +def test_windsurf_detect_via_codeium_path(windsurf_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + (fake_home / ".codeium" / "windsurf").mkdir(parents=True) + monkeypatch.setattr("agentsec.adapters.windsurf.Path.home", lambda: fake_home) + assert windsurf_adapter.detect(tmp_path) is True + + +def test_windsurf_detect_via_home_windsurf(windsurf_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + (fake_home / ".windsurf").mkdir() + monkeypatch.setattr("agentsec.adapters.windsurf.Path.home", lambda: fake_home) + assert windsurf_adapter.detect(tmp_path) is True + + +def test_windsurf_detect_empty_dir(windsurf_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.windsurf.Path.home", lambda: fake_home) + assert windsurf_adapter.detect(tmp_path) is False + + +# --------------------------------------------------------------------------- +# Windsurf: discover_configs() +# --------------------------------------------------------------------------- + + +def test_windsurf_discover_project_configs(windsurf_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.windsurf.Path.home", lambda: fake_home) + + ws_dir = tmp_path / ".windsurf" + ws_dir.mkdir() + mcp = ws_dir / "mcp.json" + mcp.write_text("{}") + + (tmp_path / ".windsurfrules").write_text("rules") + + rules_dir = ws_dir / "rules" + rules_dir.mkdir() + (rules_dir / "coding.md").write_text("Coding standards") + + found = windsurf_adapter.discover_configs(tmp_path) + assert mcp in found + assert tmp_path / ".windsurfrules" in found + assert rules_dir / "coding.md" in found + + +# --------------------------------------------------------------------------- +# Windsurf: parse() - MCP servers +# --------------------------------------------------------------------------- + + +def test_windsurf_parse_mcp_servers(windsurf_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.windsurf.Path.home", lambda: fake_home) + + ws_dir = tmp_path / ".windsurf" + ws_dir.mkdir() + mcp_data = { + "mcpServers": { + "search": { + "command": "npx", + "args": ["-y", "mcp-search"], + "headers": {"Authorization": "Bearer token"}, + } + } + } + (ws_dir / "mcp.json").write_text(json.dumps(mcp_data)) + + config = windsurf_adapter.parse(tmp_path) + assert len(config.mcp_servers) == 1 + assert config.mcp_servers[0].name == "search" + assert config.mcp_servers[0].requires_auth is True + + +# --------------------------------------------------------------------------- +# Windsurf: parse() - rules +# --------------------------------------------------------------------------- + + +def test_windsurf_parse_rules(windsurf_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.windsurf.Path.home", lambda: fake_home) + + (tmp_path / ".windsurfrules").write_text("Global windsurf rules") + + rules_dir = tmp_path / ".windsurf" / "rules" + rules_dir.mkdir(parents=True) + (rules_dir / "format.md").write_text("Formatting rules") + + config = windsurf_adapter.parse(tmp_path) + assert len(config.rules) == 2 + + ws_rule = next(r for r in config.rules if r.name == ".windsurfrules") + assert ws_rule.content == "Global windsurf rules" + + fmt_rule = next(r for r in config.rules if r.name == "format") + assert fmt_rule.content == "Formatting rules" + assert fmt_rule.activation_mode == "always" + + +# --------------------------------------------------------------------------- +# Windsurf: adapter properties +# --------------------------------------------------------------------------- + + +def test_windsurf_adapter_name(windsurf_adapter): + assert windsurf_adapter.name == "windsurf" + assert windsurf_adapter.display_name == "Windsurf" + + +# --------------------------------------------------------------------------- +# VS Code Copilot: detect() +# --------------------------------------------------------------------------- + + +def test_vscode_detect_with_mcp_json(vscode_adapter, tmp_path): + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + (vscode_dir / "mcp.json").write_text("{}") + assert vscode_adapter.detect(tmp_path) is True + + +def test_vscode_detect_with_copilot_instructions(vscode_adapter, tmp_path): + gh_dir = tmp_path / ".github" + gh_dir.mkdir() + (gh_dir / "copilot-instructions.md").write_text("# Instructions") + assert vscode_adapter.detect(tmp_path) is True + + +def test_vscode_detect_with_agents_md(vscode_adapter, tmp_path): + (tmp_path / "AGENTS.md").write_text("# Agents") + assert vscode_adapter.detect(tmp_path) is True + + +def test_vscode_detect_empty_dir(vscode_adapter, tmp_path): + assert vscode_adapter.detect(tmp_path) is False + + +# --------------------------------------------------------------------------- +# VS Code Copilot: discover_configs() +# --------------------------------------------------------------------------- + + +def test_vscode_discover_all_configs(vscode_adapter, tmp_path): + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + mcp = vscode_dir / "mcp.json" + mcp.write_text("{}") + settings = vscode_dir / "settings.json" + settings.write_text("{}") + tasks = vscode_dir / "tasks.json" + tasks.write_text("{}") + + gh_dir = tmp_path / ".github" + gh_dir.mkdir() + instructions = gh_dir / "copilot-instructions.md" + instructions.write_text("# Instructions") + + (tmp_path / "AGENTS.md").write_text("# Agents") + + found = vscode_adapter.discover_configs(tmp_path) + assert mcp in found + assert settings in found + assert tasks in found + assert instructions in found + assert tmp_path / "AGENTS.md" in found + + +def test_vscode_discover_empty_project(vscode_adapter, tmp_path): + found = vscode_adapter.discover_configs(tmp_path) + assert found == [] + + +# --------------------------------------------------------------------------- +# VS Code Copilot: parse() - "servers" key (not "mcpServers") +# --------------------------------------------------------------------------- + + +def test_vscode_parse_mcp_uses_servers_key(vscode_adapter, tmp_path): + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + mcp_data = { + "servers": { + "my-server": { + "command": "node", + "args": ["index.js"], + "env": {"PORT": "3000"}, + } + } + } + (vscode_dir / "mcp.json").write_text(json.dumps(mcp_data)) + + config = vscode_adapter.parse(tmp_path) + assert len(config.mcp_servers) == 1 + assert config.mcp_servers[0].name == "my-server" + assert config.mcp_servers[0].command == "node" + assert config.mcp_servers[0].args == ["index.js"] + assert config.mcp_servers[0].env == {"PORT": "3000"} + + +def test_vscode_parse_ignores_mcpservers_key(vscode_adapter, tmp_path): + """VS Code uses 'servers' not 'mcpServers'. Verify mcpServers is ignored.""" + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + mcp_data = { + "mcpServers": { + "wrong-key": { + "command": "node", + "args": ["wrong.js"], + } + } + } + (vscode_dir / "mcp.json").write_text(json.dumps(mcp_data)) + + config = vscode_adapter.parse(tmp_path) + assert len(config.mcp_servers) == 0 + + +# --------------------------------------------------------------------------- +# VS Code Copilot: parse() - autoApprove detection +# --------------------------------------------------------------------------- + + +def test_vscode_parse_auto_approve_bool(vscode_adapter, tmp_path): + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + settings = {"github.copilot.chat.agent.autoApprove": True} + (vscode_dir / "settings.json").write_text(json.dumps(settings)) + + config = vscode_adapter.parse(tmp_path) + assert "*" in config.permissions.auto_approve_tools + + +def test_vscode_parse_auto_approve_list(vscode_adapter, tmp_path): + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + settings = {"github.copilot.chat.agent.autoApprove": ["terminal", "editFile"]} + (vscode_dir / "settings.json").write_text(json.dumps(settings)) + + config = vscode_adapter.parse(tmp_path) + assert "terminal" in config.permissions.auto_approve_tools + assert "editFile" in config.permissions.auto_approve_tools + + +def test_vscode_parse_auto_approve_false(vscode_adapter, tmp_path): + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + settings = {"github.copilot.chat.agent.autoApprove": False} + (vscode_dir / "settings.json").write_text(json.dumps(settings)) + + config = vscode_adapter.parse(tmp_path) + assert config.permissions.auto_approve_tools == [] + + +# --------------------------------------------------------------------------- +# VS Code Copilot: parse() - tasks.json +# --------------------------------------------------------------------------- + + +def test_vscode_parse_tasks(vscode_adapter, tmp_path): + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + tasks = { + "tasks": [ + { + "label": "auto-lint", + "type": "shell", + "command": "npm run lint", + "runOptions": {"runOn": "folderOpen"}, + }, + { + "label": "build", + "type": "shell", + "command": "npm run build", + }, + ] + } + (vscode_dir / "tasks.json").write_text(json.dumps(tasks)) + + config = vscode_adapter.parse(tmp_path) + assert len(config.hooks) == 1 + assert config.hooks[0].event == "folderOpen" + assert config.hooks[0].command == "npm run lint" + + +# --------------------------------------------------------------------------- +# VS Code Copilot: parse() - rules +# --------------------------------------------------------------------------- + + +def test_vscode_parse_rules(vscode_adapter, tmp_path): + gh_dir = tmp_path / ".github" + gh_dir.mkdir() + (gh_dir / "copilot-instructions.md").write_text("Use TypeScript") + + (tmp_path / "AGENTS.md").write_text("Agent instructions") + + config = vscode_adapter.parse(tmp_path) + assert len(config.rules) == 2 + + copilot_rule = next(r for r in config.rules if r.name == "copilot-instructions.md") + assert copilot_rule.content == "Use TypeScript" + assert copilot_rule.activation_mode == "always" + + agents_rule = next(r for r in config.rules if r.name == "AGENTS.md") + assert agents_rule.content == "Agent instructions" + + +# --------------------------------------------------------------------------- +# VS Code Copilot: adapter properties +# --------------------------------------------------------------------------- + + +def test_vscode_adapter_name(vscode_adapter): + assert vscode_adapter.name == "vscode_copilot" + assert vscode_adapter.display_name == "VS Code Copilot" + + +# --------------------------------------------------------------------------- +# Cross-adapter: FrameworkConfig output structure +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "adapter_cls", + [ClaudeCodeAdapter, CursorAdapter, WindsurfAdapter, VSCodeCopilotAdapter], + ids=["claude", "cursor", "windsurf", "vscode"], +) +def test_parse_empty_dir_returns_valid_config(adapter_cls, tmp_path, monkeypatch): + """All adapters return a valid FrameworkConfig on an empty directory.""" + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + + for module in [ + "agentsec.adapters.claude_code", + "agentsec.adapters.cursor", + "agentsec.adapters.windsurf", + ]: + monkeypatch.setattr(f"{module}.Path.home", lambda: fake_home) + + adapter = adapter_cls() + config = adapter.parse(tmp_path) + + assert isinstance(config, FrameworkConfig) + assert config.framework == adapter.name + assert isinstance(config.mcp_servers, list) + assert isinstance(config.hooks, list) + assert isinstance(config.rules, list) + assert isinstance(config.permissions, PermissionsConfig) + + +@pytest.mark.parametrize( + ("adapter_cls", "marker_setup"), + [ + (ClaudeCodeAdapter, lambda p: (p / ".claude").mkdir()), + (CursorAdapter, lambda p: (p / ".cursor").mkdir()), + (WindsurfAdapter, lambda p: (p / ".windsurf").mkdir()), + ( + VSCodeCopilotAdapter, + lambda p: (p / ".vscode").mkdir() or (p / ".vscode" / "mcp.json").write_text("{}"), + ), + ], + ids=["claude", "cursor", "windsurf", "vscode"], +) +def test_detect_true_with_markers(adapter_cls, marker_setup, tmp_path): + marker_setup(tmp_path) + adapter = adapter_cls() + assert adapter.detect(tmp_path) is True + + +# --------------------------------------------------------------------------- +# Edge cases: malformed JSON +# --------------------------------------------------------------------------- + + +def test_claude_parse_malformed_json(claude_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.claude_code.Path.home", lambda: fake_home) + + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + (claude_dir / "settings.json").write_text("not valid json {{{") + + config = claude_adapter.parse(tmp_path) + assert isinstance(config, FrameworkConfig) + assert len(config.hooks) == 0 + + +def test_cursor_parse_malformed_mcp_json(cursor_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.cursor.Path.home", lambda: fake_home) + + cursor_dir = tmp_path / ".cursor" + cursor_dir.mkdir() + (cursor_dir / "mcp.json").write_text("broken json") + + config = cursor_adapter.parse(tmp_path) + assert isinstance(config, FrameworkConfig) + assert len(config.mcp_servers) == 0 + + +# --------------------------------------------------------------------------- +# Edge cases: MCP server with auth headers +# --------------------------------------------------------------------------- + + +def test_mcp_server_auth_via_headers(claude_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.claude_code.Path.home", lambda: fake_home) + + mcp_data = { + "mcpServers": { + "auth-server": { + "command": "mcp-remote", + "headers": {"Authorization": "Bearer secret"}, + } + } + } + (tmp_path / ".mcp.json").write_text(json.dumps(mcp_data)) + + config = claude_adapter.parse(tmp_path) + assert config.mcp_servers[0].requires_auth is True + + +# --------------------------------------------------------------------------- +# Edge cases: non-string env values filtered +# --------------------------------------------------------------------------- + + +def test_mcp_server_filters_non_string_env(claude_adapter, tmp_path, monkeypatch): + fake_home = tmp_path / "_fake_home" + fake_home.mkdir() + monkeypatch.setattr("agentsec.adapters.claude_code.Path.home", lambda: fake_home) + + mcp_data = { + "mcpServers": { + "test": { + "command": "node", + "env": {"GOOD": "value", "BAD": 12345}, + } + } + } + (tmp_path / ".mcp.json").write_text(json.dumps(mcp_data)) + + config = claude_adapter.parse(tmp_path) + assert config.mcp_servers[0].env == {"GOOD": "value"} diff --git a/tests/unit/test_agent_definitions.py b/tests/unit/test_agent_definitions.py new file mode 100644 index 0000000..3eb6ec8 --- /dev/null +++ b/tests/unit/test_agent_definitions.py @@ -0,0 +1,189 @@ +"""Tests for the agent definitions registry.""" + +import pytest + +from agentsec.models.agents import ( + AGENT_REGISTRY, + AgentDefinition, + get_agent, + get_all_agents, + get_supported_agents, +) + +_EXPECTED_AGENTS = [ + "claude_code", + "claude_desktop", + "cursor", + "windsurf", + "vscode_copilot", + "gemini_cli", + "codex", + "openclaw", + "amazon_q", + "kiro", + "continue_dev", + "roo_code", +] + +_SUPPORTED_AGENTS = [ + "claude_code", + "cursor", + "windsurf", + "vscode_copilot", + "openclaw", +] + +_OS_KEYS = ["darwin", "linux", "windows"] + + +# --------------------------------------------------------------------------- +# Registry completeness +# --------------------------------------------------------------------------- + + +def test_registry_has_all_expected_agents(): + assert len(AGENT_REGISTRY) == 12 + for name in _EXPECTED_AGENTS: + assert name in AGENT_REGISTRY + + +def test_registry_values_are_agent_definitions(): + for agent in AGENT_REGISTRY.values(): + assert isinstance(agent, AgentDefinition) + + +# --------------------------------------------------------------------------- +# get_agent() +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("name", _EXPECTED_AGENTS) +def test_get_agent_returns_correct_definition(name): + agent = get_agent(name) + assert agent is not None + assert agent.name == name + + +def test_get_agent_returns_none_for_unknown(): + assert get_agent("nonexistent_agent") is None + + +# --------------------------------------------------------------------------- +# get_supported_agents() +# --------------------------------------------------------------------------- + + +def test_get_supported_agents_returns_only_supported(): + supported = get_supported_agents() + for agent in supported: + assert agent.supported is True + + +def test_get_supported_agents_count(): + supported = get_supported_agents() + assert len(supported) == len(_SUPPORTED_AGENTS) + + +def test_get_supported_agents_names(): + supported = get_supported_agents() + names = {a.name for a in supported} + assert names == set(_SUPPORTED_AGENTS) + + +# --------------------------------------------------------------------------- +# get_all_agents() +# --------------------------------------------------------------------------- + + +def test_get_all_agents_returns_all(): + all_agents = get_all_agents() + assert len(all_agents) == 12 + + +# --------------------------------------------------------------------------- +# Required fields on every agent +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("name", _EXPECTED_AGENTS) +def test_agent_has_marker_paths_for_all_os(name): + agent = get_agent(name) + assert agent is not None + for os_key in _OS_KEYS: + assert os_key in agent.marker_paths, f"{name} missing marker_paths for {os_key}" + assert isinstance(agent.marker_paths[os_key], list) + assert len(agent.marker_paths[os_key]) > 0 + + +@pytest.mark.parametrize("name", _EXPECTED_AGENTS) +def test_agent_has_config_paths_for_all_os(name): + agent = get_agent(name) + assert agent is not None + for os_key in _OS_KEYS: + assert os_key in agent.config_paths, f"{name} missing config_paths for {os_key}" + assert isinstance(agent.config_paths[os_key], list) + assert len(agent.config_paths[os_key]) > 0 + + +@pytest.mark.parametrize("name", _EXPECTED_AGENTS) +def test_agent_has_display_name(name): + agent = get_agent(name) + assert agent is not None + assert len(agent.display_name) > 0 + + +@pytest.mark.parametrize("name", _EXPECTED_AGENTS) +def test_agent_has_agent_type(name): + agent = get_agent(name) + assert agent is not None + assert len(agent.agent_type) > 0 + + +@pytest.mark.parametrize("name", _EXPECTED_AGENTS) +def test_agent_has_mcp_config_paths_for_all_os(name): + agent = get_agent(name) + assert agent is not None + for os_key in _OS_KEYS: + assert os_key in agent.mcp_config_paths, f"{name} missing mcp_config_paths for {os_key}" + assert isinstance(agent.mcp_config_paths[os_key], list) + + +# --------------------------------------------------------------------------- +# Frozen dataclass +# --------------------------------------------------------------------------- + + +def test_agent_definition_is_frozen(): + agent = get_agent("claude_code") + assert agent is not None + with pytest.raises(AttributeError): + agent.name = "modified" + + +# --------------------------------------------------------------------------- +# Specific agent properties +# --------------------------------------------------------------------------- + + +def test_claude_code_has_binary(): + agent = get_agent("claude_code") + assert agent is not None + assert "claude" in agent.binary_names + + +def test_cursor_has_binary(): + agent = get_agent("cursor") + assert agent is not None + assert "cursor" in agent.binary_names + + +def test_windsurf_has_no_binary(): + agent = get_agent("windsurf") + assert agent is not None + assert agent.binary_names == [] + + +def test_openclaw_has_env_overrides(): + agent = get_agent("openclaw") + assert agent is not None + assert "OPENCLAW_HOME" in agent.env_overrides diff --git a/tests/unit/test_discovery.py b/tests/unit/test_discovery.py new file mode 100644 index 0000000..9e0c060 --- /dev/null +++ b/tests/unit/test_discovery.py @@ -0,0 +1,185 @@ +"""Tests for the agent discovery engine.""" + +import pytest + +from agentsec.discovery import ( + DiscoveredAgent, + _expand_path, + _resolve_paths, + _resolve_project_paths, + discover_agents, +) +from agentsec.models.agents import AGENT_REGISTRY + + +@pytest.fixture +def mock_home_with_claude(tmp_path, monkeypatch): + """Create a fake home directory with Claude Code markers.""" + fake_home = tmp_path / "fake_home" + fake_home.mkdir() + (fake_home / ".claude").mkdir() + (fake_home / ".claude" / "settings.json").write_text("{}") + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("USERPROFILE", str(fake_home)) + monkeypatch.setattr("pathlib.Path.home", lambda: fake_home) + monkeypatch.setattr("os.path.expanduser", lambda p: p.replace("~", str(fake_home))) + return fake_home + + +@pytest.fixture +def mock_home_empty(tmp_path, monkeypatch): + """Create a fake home directory with no agent markers.""" + fake_home = tmp_path / "fake_home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("USERPROFILE", str(fake_home)) + monkeypatch.setattr("pathlib.Path.home", lambda: fake_home) + monkeypatch.setattr("os.path.expanduser", lambda p: p.replace("~", str(fake_home))) + return fake_home + + +# --------------------------------------------------------------------------- +# discover_agents() - global detection +# --------------------------------------------------------------------------- + + +def test_discover_finds_claude_code(mock_home_with_claude): + agents = discover_agents() + names = [a.name for a in agents] + assert "claude_code" in names + + +def test_discover_empty_home_returns_empty(mock_home_empty): + agents = discover_agents() + assert agents == [] + + +def test_discover_returns_discovered_agent_type(mock_home_with_claude): + agents = discover_agents() + for agent in agents: + assert isinstance(agent, DiscoveredAgent) + + +def test_discover_agent_fields(mock_home_with_claude): + agents = discover_agents() + claude = next(a for a in agents if a.name == "claude_code") + assert claude.display_name == "Claude Code" + assert claude.agent_type == "claude-code" + assert claude.supported is True + assert claude.scope == "global" + assert claude.version is None # detect_versions defaults to False + + +# --------------------------------------------------------------------------- +# discover_agents() - project-level detection +# --------------------------------------------------------------------------- + + +def test_discover_project_level_cursor(mock_home_empty, tmp_path): + cursor_dir = tmp_path / ".cursor" + cursor_dir.mkdir() + (cursor_dir / "mcp.json").write_text("{}") + + agents = discover_agents(target=tmp_path) + names = [a.name for a in agents] + assert "cursor" in names + + +def test_discover_project_level_vscode(mock_home_empty, tmp_path): + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + (vscode_dir / "mcp.json").write_text("{}") + + agents = discover_agents(target=tmp_path) + names = [a.name for a in agents] + assert "vscode_copilot" in names + + +def test_discover_project_only_scope(mock_home_empty, tmp_path): + """When an agent is only found at project level, scope should be 'project'.""" + cursor_dir = tmp_path / ".cursor" + cursor_dir.mkdir() + (cursor_dir / "mcp.json").write_text("{}") + + agents = discover_agents(target=tmp_path) + cursor = next(a for a in agents if a.name == "cursor") + assert cursor.scope == "project" + + +# --------------------------------------------------------------------------- +# discover_agents() - version detection disabled +# --------------------------------------------------------------------------- + + +def test_discover_version_detection_off_by_default(mock_home_with_claude): + agents = discover_agents() + for agent in agents: + assert agent.version is None + + +# --------------------------------------------------------------------------- +# discover_agents() - sorted by display_name +# --------------------------------------------------------------------------- + + +def test_discover_results_sorted(mock_home_with_claude, tmp_path, monkeypatch): + """Results should be sorted alphabetically by display_name.""" + # Add cursor markers too + list(AGENT_REGISTRY.values())[0] # just check sorting + agents = discover_agents() + display_names = [a.display_name for a in agents] + assert display_names == sorted(display_names) + + +# --------------------------------------------------------------------------- +# _expand_path() +# --------------------------------------------------------------------------- + + +def test_expand_path_tilde(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setattr("os.path.expanduser", lambda p: p.replace("~", str(tmp_path))) + result = _expand_path("~/test") + assert str(tmp_path) in result + + +# --------------------------------------------------------------------------- +# _resolve_paths() +# --------------------------------------------------------------------------- + + +def test_resolve_paths_finds_existing(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / "test_file").write_text("content") + found = _resolve_paths([str(tmp_path / "test_file")]) + assert len(found) == 1 + + +def test_resolve_paths_skips_missing(): + found = _resolve_paths(["/nonexistent/path/that/does/not/exist"]) + assert found == [] + + +# --------------------------------------------------------------------------- +# _resolve_project_paths() +# --------------------------------------------------------------------------- + + +def test_resolve_project_paths_finds_relative(tmp_path): + (tmp_path / ".cursor").mkdir() + (tmp_path / ".cursor" / "mcp.json").write_text("{}") + + found = _resolve_project_paths([".cursor/mcp.json"], tmp_path) + assert len(found) == 1 + assert found[0] == tmp_path / ".cursor" / "mcp.json" + + +def test_resolve_project_paths_ignores_home_relative(tmp_path): + found = _resolve_project_paths(["~/.cursor/mcp.json"], tmp_path) + assert found == [] + + +def test_resolve_project_paths_ignores_env_var_paths(tmp_path): + found = _resolve_project_paths(["%USERPROFILE%\\.cursor\\mcp.json"], tmp_path) + assert found == []