diff --git a/CLAUDE.md b/CLAUDE.md index ef1d7d08..dd42fddb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -372,6 +372,61 @@ blocked_commands: - `examples/org_config.yaml` - Org config example (all commented by default) - `examples/README.md` - Comprehensive guide with use cases, testing, and troubleshooting +#### Custom MCP Servers + +The agent supports adding custom MCP (Model Context Protocol) servers via configuration files. This allows extending the agent's capabilities with additional tools. + +**Configuration Locations:** +- **Org-level**: `~/.autocoder/config.yaml` - Available to ALL projects +- **Project-level**: `{project}/.autocoder/allowed_commands.yaml` - Project-specific + +**MCP Server Config Schema:** + +```yaml +# ~/.autocoder/config.yaml (org-level) +version: 1 + +mcp_servers: + - name: filesystem + command: npx + args: + - "@anthropic/mcp-server-filesystem" + - "${PROJECT_DIR}" # Variable substitution + env: + SOME_VAR: value + allowed_tools: # REQUIRED - explicit list of tools to allow + - read_file + - list_directory + +# Org-level can block specific tools across ALL projects +blocked_mcp_tools: + - filesystem__write_file # Block write across all projects + - filesystem__delete_file +``` + +**Variable Substitution:** +- `${PROJECT_DIR}` - Absolute path to project directory +- `${HOME}` - User's home directory + +**Tool Naming Convention:** +- MCP tools follow the pattern `mcp__{server}__{tool}` +- Config uses short names: `allowed_tools: [read_file]` +- Becomes: `mcp__filesystem__read_file` + +**Key Behaviors:** +- `allowed_tools` is REQUIRED for each MCP server - must explicitly list tools +- Org `blocked_mcp_tools` takes precedence - projects cannot allow blocked tools +- Environment variables merge with parent environment +- Project can override org MCP server by using the same name + +**Example Output:** + +``` +Created security settings at /path/to/project/.claude_settings.json + - MCP servers: playwright (browser), features (database), filesystem (custom) + - Blocked MCP tools (org): filesystem__write_file, filesystem__delete_file +``` + ### Vertex AI Configuration (Optional) Run coding agents via Google Cloud Vertex AI: diff --git a/client.py b/client.py index d31b5add..704ae812 100644 --- a/client.py +++ b/client.py @@ -11,13 +11,19 @@ import shutil import sys from pathlib import Path +from typing import Any from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient from claude_agent_sdk.types import HookContext, HookInput, HookMatcher, SyncHookJSONOutput from dotenv import load_dotenv from env_constants import API_ENV_VARS -from security import SENSITIVE_DIRECTORIES, bash_security_hook +from security import ( + SENSITIVE_DIRECTORIES, + bash_security_hook, + get_effective_mcp_servers, + get_effective_mcp_tools, +) # Load environment variables from .env file if present load_dotenv() @@ -182,6 +188,37 @@ def get_extra_read_paths() -> list[Path]: return validated_paths +def substitute_variables(value: str, project_dir: Path) -> str: + """ + Substitute variables in a configuration value. + + Supported variables: + - ${PROJECT_DIR} - Absolute path to project directory + - ${HOME} - User's home directory + + Args: + value: String value that may contain variables + project_dir: Path to the project directory + + Returns: + String with variables substituted + """ + result = value + result = result.replace("${PROJECT_DIR}", str(project_dir.resolve())) + result = result.replace("${HOME}", str(Path.home())) + return result + + +def substitute_variables_in_list(values: list[str], project_dir: Path) -> list[str]: + """Substitute variables in a list of strings.""" + return [substitute_variables(v, project_dir) for v in values] + + +def substitute_variables_in_dict(values: dict[str, str], project_dir: Path) -> dict[str, str]: + """Substitute variables in a dict of strings.""" + return {k: substitute_variables(v, project_dir) for k, v in values.items()} + + # Per-agent-type MCP tool lists. # Only expose the tools each agent type actually needs, reducing tool schema # overhead and preventing agents from calling tools meant for other roles. @@ -327,12 +364,21 @@ def create_client( } max_turns = max_turns_map.get(agent_type, 300) + # Load user-configured MCP servers from org and project configs + user_mcp_servers, blocked_mcp_tools = get_effective_mcp_servers(project_dir) + user_mcp_tools, user_mcp_permissions = get_effective_mcp_tools( + user_mcp_servers, blocked_mcp_tools + ) + # Build allowed tools list based on mode and agent type. # In YOLO mode, exclude Playwright tools for faster prototyping. allowed_tools = [*BUILTIN_TOOLS, *feature_tools] if not yolo_mode: allowed_tools.extend(PLAYWRIGHT_TOOLS) + # Add user-configured MCP tools + allowed_tools.extend(user_mcp_tools) + # Build permissions list. # We permit ALL feature MCP tools at the security layer (so the MCP server # can respond if called), but the LLM only *sees* the agent-type-specific @@ -367,6 +413,9 @@ def create_client( # Allow Playwright MCP tools for browser automation (standard mode only) permissions_list.extend(PLAYWRIGHT_TOOLS) + # Add user-configured MCP tool permissions + permissions_list.extend(user_mcp_permissions) + # Create comprehensive security settings # Note: Using relative paths ("./**") restricts access to project directory # since cwd is set to project_dir @@ -394,10 +443,22 @@ def create_client( if extra_read_paths: print(f" - Extra read paths (validated): {', '.join(str(p) for p in extra_read_paths)}") print(" - Bash commands restricted to allowlist (see security.py)") - if yolo_mode: + + # Report MCP servers + builtin_servers = ["features (database)"] + if not yolo_mode: + builtin_servers.insert(0, "playwright (browser)") + user_server_names = [s["name"] for s in user_mcp_servers] + if user_server_names: + all_servers = builtin_servers + [f"{name} (custom)" for name in user_server_names] + print(f" - MCP servers: {', '.join(all_servers)}") + elif yolo_mode: print(" - MCP servers: features (database) - YOLO MODE (no Playwright)") else: print(" - MCP servers: playwright (browser), features (database)") + + if blocked_mcp_tools: + print(f" - Blocked MCP tools (org): {', '.join(sorted(blocked_mcp_tools))}") print(" - Project settings enabled (skills, commands, CLAUDE.md)") print() @@ -448,6 +509,35 @@ def create_client( "args": playwright_args, } + # Add user-configured MCP servers from org and project configs + for server_config in user_mcp_servers: + server_name = server_config["name"] + + # Skip if server name conflicts with built-in servers + if server_name in mcp_servers: + print(f" - Warning: Custom MCP server '{server_name}' conflicts with built-in, skipping") + continue + + # Build server entry with variable substitution + server_entry: dict[str, Any] = { + "command": substitute_variables(server_config["command"], project_dir), + } + + # Add args with variable substitution + if "args" in server_config: + server_entry["args"] = substitute_variables_in_list( + server_config["args"], project_dir + ) + + # Add env with variable substitution, merging with parent environment + if "env" in server_config: + server_entry["env"] = substitute_variables_in_dict( + server_config["env"], project_dir + ) + + mcp_servers[server_name] = server_entry + print(f" - Custom MCP server '{server_name}': {server_config['command']}") + # Build environment overrides for API endpoint configuration # These override system env vars for the Claude CLI subprocess, # allowing AutoCoder to use alternative APIs (e.g., GLM) without diff --git a/examples/org_config.yaml b/examples/org_config.yaml index f86d9f3a..debbfa41 100644 --- a/examples/org_config.yaml +++ b/examples/org_config.yaml @@ -91,6 +91,78 @@ blocked_commands: [] # - puppet +# ========================================== +# Custom MCP Servers +# ========================================== +# Configure additional MCP (Model Context Protocol) servers that will be +# available to the agent in ALL projects. +# +# Each MCP server MUST specify: +# - name: Unique identifier for the server +# - command: Command to run (e.g., "npx", "python") +# - allowed_tools: REQUIRED list of tools to allow (explicit allowlist) +# +# Optional fields: +# - args: List of command arguments +# - env: Environment variables (merged with parent environment) +# +# Variable substitution is supported: +# - ${PROJECT_DIR} - Absolute path to current project directory +# - ${HOME} - User's home directory +# +# By default, no custom MCP servers are configured. + +mcp_servers: [] + + # Example: Filesystem MCP server (read-only) + # - name: filesystem + # command: npx + # args: + # - "@anthropic/mcp-server-filesystem" + # - "${PROJECT_DIR}" + # allowed_tools: + # - read_file + # - list_directory + # - search_files + + # Example: Memory MCP server for persistent context + # - name: memory + # command: npx + # args: + # - "@anthropic/mcp-server-memory" + # env: + # MEMORY_DIR: "${HOME}/.autocoder/memory" + # allowed_tools: + # - store_memory + # - retrieve_memory + # - search_memory + + +# ========================================== +# Blocked MCP Tools (Organization-Wide) +# ========================================== +# Tools listed here are BLOCKED across ALL projects. +# Projects CANNOT override these blocks. +# +# Tool naming convention: {server}__{tool} or mcp__{server}__{tool} +# Examples: +# - filesystem__write_file (blocks filesystem server's write_file tool) +# - filesystem__delete_file (blocks filesystem server's delete_file tool) +# - myserver__* (blocks ALL tools from myserver - wildcard) +# +# By default, no tools are blocked. + +blocked_mcp_tools: [] + + # Block dangerous filesystem operations + # - filesystem__write_file + # - filesystem__delete_file + # - filesystem__move_file + + # Block all tools from a specific server (wildcard) + # - dangerous_server__* + + # ========================================== # Global Settings (Phase 3 feature) # ========================================== diff --git a/examples/project_allowed_commands.yaml b/examples/project_allowed_commands.yaml index 2a3bdf54..499742be 100644 --- a/examples/project_allowed_commands.yaml +++ b/examples/project_allowed_commands.yaml @@ -103,6 +103,53 @@ commands: [] # description: Deploy to staging environment +# ========================================== +# Custom MCP Servers +# ========================================== +# Configure additional MCP (Model Context Protocol) servers for THIS PROJECT. +# These are added to any org-level MCP servers defined in ~/.autocoder/config.yaml +# +# Each MCP server MUST specify: +# - name: Unique identifier for the server +# - command: Command to run (e.g., "npx", "python") +# - allowed_tools: REQUIRED list of tools to allow (explicit allowlist) +# +# Optional fields: +# - args: List of command arguments +# - env: Environment variables (merged with parent environment) +# +# Variable substitution is supported: +# - ${PROJECT_DIR} - Absolute path to current project directory +# - ${HOME} - User's home directory +# +# By default, no custom MCP servers are configured. + +mcp_servers: [] + + # Example: SQLite MCP server for this project's database + # - name: sqlite + # command: npx + # args: + # - "@anthropic/mcp-server-sqlite" + # - "${PROJECT_DIR}/data.db" + # allowed_tools: + # - read_query + # - list_tables + # - describe_table + + # Example: Custom project-specific MCP server + # - name: my_project_tools + # command: python + # args: + # - "${PROJECT_DIR}/tools/mcp_server.py" + # env: + # PROJECT_ROOT: "${PROJECT_DIR}" + # allowed_tools: + # - build_project + # - run_tests + # - deploy_staging + + # ========================================== # Notes and Best Practices # ========================================== diff --git a/security.py b/security.py index 1e7455f5..ddf87e47 100644 --- a/security.py +++ b/security.py @@ -11,10 +11,19 @@ import re import shlex from pathlib import Path -from typing import Optional +from typing import Any, Optional, TypedDict import yaml + +class MCPServerConfig(TypedDict, total=False): + """Configuration for a custom MCP server.""" + name: str # Required: unique identifier for the server + command: str # Required: command to run (e.g., "npx", "python") + args: list[str] # Optional: command arguments + env: dict[str, str] # Optional: environment variables + allowed_tools: list[str] # Required: explicit list of tools to allow + # Logger for security-related events (fallback parsing, validation failures, etc.) logger = logging.getLogger(__name__) @@ -825,6 +834,326 @@ def get_effective_pkill_processes(project_dir: Optional[Path]) -> set[str]: return processes +# ============================================================================= +# MCP Server Configuration +# ============================================================================= + + +def validate_mcp_server(server_config: dict, source: str = "config") -> tuple[bool, str]: + """ + Validate a single MCP server configuration entry. + + Args: + server_config: Dict with MCP server configuration + source: Description of where the config came from (for error messages) + + Returns: + Tuple of (is_valid, error_message) + """ + if not isinstance(server_config, dict): + return False, "MCP server config must be a dict" + + # Validate name (required) + if "name" not in server_config: + return False, "MCP server must have 'name' field" + name = server_config["name"] + if not isinstance(name, str) or not name.strip(): + return False, "MCP server name must be a non-empty string" + + # Validate command (required) + if "command" not in server_config: + return False, f"MCP server '{name}' must have 'command' field" + command = server_config["command"] + if not isinstance(command, str) or not command.strip(): + return False, f"MCP server '{name}' command must be a non-empty string" + + # Validate args (optional, must be list of strings) + if "args" in server_config: + args = server_config["args"] + if not isinstance(args, list): + return False, f"MCP server '{name}' args must be a list" + for i, arg in enumerate(args): + if not isinstance(arg, str): + return False, f"MCP server '{name}' args[{i}] must be a string" + + # Validate env (optional, must be dict of string keys and values) + if "env" in server_config: + env = server_config["env"] + if not isinstance(env, dict): + return False, f"MCP server '{name}' env must be a dict" + for key, value in env.items(): + if not isinstance(key, str): + return False, f"MCP server '{name}' env key must be a string" + if not isinstance(value, str): + return False, f"MCP server '{name}' env['{key}'] must be a string" + + # Validate allowed_tools (required, must be list of strings) + if "allowed_tools" not in server_config: + return False, f"MCP server '{name}' must have 'allowed_tools' field (explicit tool list required)" + allowed_tools = server_config["allowed_tools"] + if not isinstance(allowed_tools, list): + return False, f"MCP server '{name}' allowed_tools must be a list" + if len(allowed_tools) == 0: + return False, f"MCP server '{name}' allowed_tools cannot be empty" + for i, tool in enumerate(allowed_tools): + if not isinstance(tool, str) or not tool.strip(): + return False, f"MCP server '{name}' allowed_tools[{i}] must be a non-empty string" + + return True, "" + + +def _validate_mcp_servers_list( + servers: Any, + config_path: Path, + max_servers: int = 20 +) -> Optional[list[dict]]: + """ + Validate a list of MCP server configurations. + + Args: + servers: The mcp_servers value from config (should be a list) + config_path: Path to config file (for error messages) + max_servers: Maximum number of servers allowed + + Returns: + Validated list of server configs, or None if invalid + """ + if not isinstance(servers, list): + logger.warning(f"Config at {config_path}: 'mcp_servers' must be a list") + return None + + if len(servers) > max_servers: + logger.warning( + f"Config at {config_path} exceeds {max_servers} MCP server limit ({len(servers)} servers)" + ) + return None + + validated = [] + for i, server in enumerate(servers): + valid, error = validate_mcp_server(server, str(config_path)) + if not valid: + logger.warning(f"Config at {config_path}: mcp_servers[{i}] - {error}") + return None + validated.append(server) + + return validated + + +def _validate_blocked_mcp_tools( + tools: Any, + config_path: Path +) -> Optional[list[str]]: + """ + Validate blocked_mcp_tools list from org config. + + Args: + tools: The blocked_mcp_tools value from config + config_path: Path to config file (for error messages) + + Returns: + Validated list of blocked tool patterns, or None if invalid + """ + if not isinstance(tools, list): + logger.warning(f"Config at {config_path}: 'blocked_mcp_tools' must be a list") + return None + + validated = [] + for i, tool in enumerate(tools): + if not isinstance(tool, str): + logger.warning(f"Config at {config_path}: blocked_mcp_tools[{i}] must be a string") + return None + tool = tool.strip() + if not tool: + logger.warning(f"Config at {config_path}: blocked_mcp_tools[{i}] cannot be empty") + return None + validated.append(tool) + + return validated + + +def get_mcp_servers_from_org_config() -> tuple[list[dict], set[str]]: + """ + Load MCP server configurations from org-level config. + + Returns: + Tuple of (mcp_servers, blocked_mcp_tools) + """ + org_config = load_org_config() + if not org_config: + return [], set() + + mcp_servers = [] + blocked_tools: set[str] = set() + + # Load mcp_servers + if "mcp_servers" in org_config: + config_path = get_org_config_path() + validated_servers = _validate_mcp_servers_list( + org_config["mcp_servers"], + config_path, + max_servers=20 + ) + if validated_servers: + mcp_servers = validated_servers + + # Load blocked_mcp_tools + if "blocked_mcp_tools" in org_config: + config_path = get_org_config_path() + validated_tools = _validate_blocked_mcp_tools( + org_config["blocked_mcp_tools"], + config_path + ) + if validated_tools: + blocked_tools = set(validated_tools) + + return mcp_servers, blocked_tools + + +def get_mcp_servers_from_project_config(project_dir: Path) -> list[dict]: + """ + Load MCP server configurations from project-level config. + + Args: + project_dir: Path to the project directory + + Returns: + List of validated MCP server configurations + """ + project_config = load_project_commands(project_dir) + if not project_config: + return [] + + if "mcp_servers" not in project_config: + return [] + + config_path = project_dir.resolve() / ".autocoder" / "allowed_commands.yaml" + validated = _validate_mcp_servers_list( + project_config["mcp_servers"], + config_path, + max_servers=20 + ) + + return validated if validated else [] + + +def get_effective_mcp_servers(project_dir: Optional[Path]) -> tuple[list[dict], set[str]]: + """ + Get effective MCP server configurations after hierarchy resolution. + + Hierarchy: + 1. Org MCP servers - available to all projects with their allowed_tools + 2. Org blocked_mcp_tools - tools blocked across ALL projects + 3. Project MCP servers - project-specific additions + + Args: + project_dir: Path to the project directory, or None + + Returns: + Tuple of (mcp_servers, blocked_mcp_tools) + - mcp_servers: List of all MCP server configs (org + project) + - blocked_mcp_tools: Set of tool patterns blocked at org level + """ + # Get org-level servers and blocked tools + org_servers, blocked_tools = get_mcp_servers_from_org_config() + + # Get project-level servers + project_servers = [] + if project_dir: + project_servers = get_mcp_servers_from_project_config(project_dir) + + # Merge servers (org first, then project) + # Use dict to prevent duplicate server names (project can override org) + servers_by_name: dict[str, dict] = {} + for server in org_servers: + servers_by_name[server["name"]] = server + for server in project_servers: + servers_by_name[server["name"]] = server + + return list(servers_by_name.values()), blocked_tools + + +def get_effective_mcp_tools( + mcp_servers: list[dict], + blocked_tools: set[str] +) -> tuple[list[str], list[str]]: + """ + Get effective allowed and blocked MCP tools from server configurations. + + Tool naming convention: mcp__{server}__{tool} + Config uses short names: allowed_tools: [read_file] + Becomes: mcp__filesystem__read_file + + Args: + mcp_servers: List of MCP server configurations + blocked_tools: Set of tool patterns blocked at org level + + Returns: + Tuple of (allowed_tools, permissions) + - allowed_tools: List of full tool names (mcp__server__tool format) + - permissions: List of permission strings for the allowed tools + """ + allowed_tools: list[str] = [] + permissions: list[str] = [] + + for server in mcp_servers: + server_name = server["name"] + server_tools = server.get("allowed_tools", []) + + for tool in server_tools: + # Build full tool name + full_tool_name = f"mcp__{server_name}__{tool}" + + # Check if tool is blocked (exact match or pattern) + is_blocked = False + for blocked_pattern in blocked_tools: + if _matches_mcp_tool_pattern(full_tool_name, blocked_pattern, server_name): + is_blocked = True + logger.debug( + f"MCP tool '{full_tool_name}' blocked by pattern '{blocked_pattern}'" + ) + break + + if not is_blocked: + allowed_tools.append(full_tool_name) + permissions.append(full_tool_name) + + return allowed_tools, permissions + + +def _matches_mcp_tool_pattern(tool_name: str, pattern: str, server_name: str) -> bool: + """ + Check if an MCP tool matches a blocked pattern. + + Patterns can be: + - Full name: "mcp__filesystem__write_file" + - Short name with server: "filesystem__write_file" + - Wildcard: "filesystem__*" (blocks all tools from server) + + Args: + tool_name: Full tool name (mcp__server__tool) + pattern: Blocked pattern to check against + server_name: The server name for context + + Returns: + True if tool matches the blocked pattern + """ + # Normalize pattern (add mcp__ prefix if not present) + if not pattern.startswith("mcp__"): + pattern = f"mcp__{pattern}" + + # Exact match + if tool_name == pattern: + return True + + # Wildcard match (e.g., mcp__filesystem__* or filesystem__*) + if pattern.endswith("__*"): + prefix = pattern[:-1] # Remove trailing * + if tool_name.startswith(prefix): + return True + + return False + + def is_command_allowed(command: str, allowed_commands: set[str]) -> bool: """ Check if a command is allowed (supports patterns). diff --git a/test_security.py b/test_security.py index 40c1fa14..6d7ec352 100644 --- a/test_security.py +++ b/test_security.py @@ -15,15 +15,21 @@ from pathlib import Path from security import ( + _matches_mcp_tool_pattern, bash_security_hook, extract_commands, get_effective_commands, + get_effective_mcp_servers, + get_effective_mcp_tools, get_effective_pkill_processes, + get_mcp_servers_from_org_config, + get_mcp_servers_from_project_config, load_org_config, load_project_commands, matches_pattern, validate_chmod_command, validate_init_script, + validate_mcp_server, validate_pkill_command, validate_project_command, ) @@ -923,6 +929,405 @@ def test_pkill_extensibility(): return passed, failed +def test_mcp_server_validation(): + """Test MCP server configuration validation.""" + print("\nTesting MCP server validation:\n") + passed = 0 + failed = 0 + + # Test cases: (config, should_be_valid, description) + test_cases = [ + # Valid configurations + ( + { + "name": "test_server", + "command": "npx", + "args": ["@anthropic/mcp-server-test"], + "allowed_tools": ["read_file", "write_file"], + }, + True, + "valid complete config", + ), + ( + { + "name": "minimal", + "command": "python", + "allowed_tools": ["tool1"], + }, + True, + "valid minimal config (no args/env)", + ), + ( + { + "name": "with_env", + "command": "npx", + "args": ["@test/mcp"], + "env": {"VAR": "value", "ANOTHER": "test"}, + "allowed_tools": ["tool1"], + }, + True, + "valid config with env", + ), + + # Invalid configurations + ({}, False, "empty config"), + ({"command": "npx", "allowed_tools": ["tool"]}, False, "missing name"), + ({"name": "test", "allowed_tools": ["tool"]}, False, "missing command"), + ({"name": "test", "command": "npx"}, False, "missing allowed_tools"), + ({"name": "", "command": "npx", "allowed_tools": ["tool"]}, False, "empty name"), + ({"name": "test", "command": "", "allowed_tools": ["tool"]}, False, "empty command"), + ({"name": "test", "command": "npx", "allowed_tools": []}, False, "empty allowed_tools"), + ( + {"name": "test", "command": "npx", "args": "invalid", "allowed_tools": ["tool"]}, + False, + "args not a list", + ), + ( + {"name": "test", "command": "npx", "args": [123], "allowed_tools": ["tool"]}, + False, + "args item not string", + ), + ( + {"name": "test", "command": "npx", "env": "invalid", "allowed_tools": ["tool"]}, + False, + "env not a dict", + ), + ( + {"name": "test", "command": "npx", "env": {"key": 123}, "allowed_tools": ["tool"]}, + False, + "env value not string", + ), + ( + {"name": "test", "command": "npx", "allowed_tools": ["", "tool"]}, + False, + "empty tool in allowed_tools", + ), + ] + + for config, should_be_valid, description in test_cases: + valid, error = validate_mcp_server(config) + if valid == should_be_valid: + print(f" PASS: {description}") + passed += 1 + else: + expected = "valid" if should_be_valid else "invalid" + actual = "valid" if valid else "invalid" + print(f" FAIL: {description}") + print(f" Expected: {expected}, Got: {actual}") + if error: + print(f" Error: {error}") + failed += 1 + + return passed, failed + + +def test_mcp_tool_pattern_matching(): + """Test MCP tool pattern matching for blocked tools.""" + print("\nTesting MCP tool pattern matching:\n") + passed = 0 + failed = 0 + + # Test cases: (tool_name, pattern, server_name, should_match, description) + test_cases = [ + # Exact matches + ("mcp__filesystem__read_file", "filesystem__read_file", "filesystem", True, "short pattern match"), + ("mcp__filesystem__read_file", "mcp__filesystem__read_file", "filesystem", True, "full pattern match"), + + # Wildcard matches + ("mcp__filesystem__read_file", "filesystem__*", "filesystem", True, "wildcard matches read_file"), + ("mcp__filesystem__write_file", "filesystem__*", "filesystem", True, "wildcard matches write_file"), + ("mcp__filesystem__delete_file", "mcp__filesystem__*", "filesystem", True, "full wildcard pattern"), + + # Non-matches + ("mcp__filesystem__read_file", "other__read_file", "filesystem", False, "different server"), + ("mcp__filesystem__read_file", "filesystem__write_file", "filesystem", False, "different tool"), + ("mcp__memory__store", "filesystem__*", "memory", False, "wildcard wrong server"), + ] + + for tool_name, pattern, server_name, should_match, description in test_cases: + result = _matches_mcp_tool_pattern(tool_name, pattern, server_name) + if result == should_match: + print(f" PASS: {description}") + passed += 1 + else: + expected = "match" if should_match else "no match" + actual = "match" if result else "no match" + print(f" FAIL: {description}") + print(f" Tool: {tool_name}, Pattern: {pattern}") + print(f" Expected: {expected}, Got: {actual}") + failed += 1 + + return passed, failed + + +def test_mcp_server_config_loading(): + """Test MCP server configuration loading from org and project configs.""" + print("\nTesting MCP server config loading:\n") + passed = 0 + failed = 0 + + # Test 1: Org config with MCP servers + with tempfile.TemporaryDirectory() as tmphome: + with temporary_home(tmphome): + org_dir = Path(tmphome) / ".autocoder" + org_dir.mkdir() + org_config_path = org_dir / "config.yaml" + + org_config_path.write_text("""version: 1 +mcp_servers: + - name: test_server + command: npx + args: + - "@test/mcp" + allowed_tools: + - tool1 + - tool2 +blocked_mcp_tools: + - other__dangerous_tool +""") + + servers, blocked = get_mcp_servers_from_org_config() + + if len(servers) == 1 and servers[0]["name"] == "test_server": + print(" PASS: Org MCP servers loaded correctly") + passed += 1 + else: + print(f" FAIL: Org MCP servers not loaded correctly: {servers}") + failed += 1 + + if "other__dangerous_tool" in blocked: + print(" PASS: Org blocked_mcp_tools loaded correctly") + passed += 1 + else: + print(f" FAIL: Org blocked_mcp_tools not loaded correctly: {blocked}") + failed += 1 + + # Test 2: Project config with MCP servers + with tempfile.TemporaryDirectory() as tmpproject: + project_dir = Path(tmpproject) + autocoder_dir = project_dir / ".autocoder" + autocoder_dir.mkdir() + + config_path = autocoder_dir / "allowed_commands.yaml" + config_path.write_text("""version: 1 +commands: [] +mcp_servers: + - name: project_server + command: python + args: + - "${PROJECT_DIR}/tools/mcp.py" + env: + MY_VAR: "${HOME}/data" + allowed_tools: + - my_tool +""") + + servers = get_mcp_servers_from_project_config(project_dir) + + if len(servers) == 1 and servers[0]["name"] == "project_server": + print(" PASS: Project MCP servers loaded correctly") + passed += 1 + else: + print(f" FAIL: Project MCP servers not loaded correctly: {servers}") + failed += 1 + + if servers[0].get("env", {}).get("MY_VAR") == "${HOME}/data": + print(" PASS: Project MCP server env preserved (substitution happens in client.py)") + passed += 1 + else: + print(f" FAIL: Project MCP server env not preserved: {servers[0]}") + failed += 1 + + # Test 3: Invalid MCP server config is rejected + with tempfile.TemporaryDirectory() as tmphome: + with temporary_home(tmphome): + org_dir = Path(tmphome) / ".autocoder" + org_dir.mkdir() + org_config_path = org_dir / "config.yaml" + + org_config_path.write_text("""version: 1 +mcp_servers: + - name: invalid_server + command: npx + # Missing required allowed_tools +""") + + servers, _ = get_mcp_servers_from_org_config() + + if len(servers) == 0: + print(" PASS: Invalid MCP server config rejected") + passed += 1 + else: + print(f" FAIL: Invalid MCP server config should be rejected: {servers}") + failed += 1 + + return passed, failed + + +def test_mcp_hierarchy_resolution(): + """Test MCP server hierarchy resolution (org + project).""" + print("\nTesting MCP hierarchy resolution:\n") + passed = 0 + failed = 0 + + with tempfile.TemporaryDirectory() as tmphome: + with tempfile.TemporaryDirectory() as tmpproject: + with temporary_home(tmphome): + # Set up org config + org_dir = Path(tmphome) / ".autocoder" + org_dir.mkdir() + org_config_path = org_dir / "config.yaml" + + org_config_path.write_text("""version: 1 +mcp_servers: + - name: org_server + command: npx + args: ["@org/mcp"] + allowed_tools: + - org_tool + - name: shared_server + command: org_cmd + allowed_tools: + - org_version_tool +blocked_mcp_tools: + - org_server__blocked_tool +""") + + # Set up project config + project_dir = Path(tmpproject) + autocoder_dir = project_dir / ".autocoder" + autocoder_dir.mkdir() + + config_path = autocoder_dir / "allowed_commands.yaml" + config_path.write_text("""version: 1 +commands: [] +mcp_servers: + - name: project_server + command: python + allowed_tools: + - project_tool + - name: shared_server + command: project_cmd + allowed_tools: + - project_version_tool +""") + + # Test get_effective_mcp_servers + servers, blocked_tools = get_effective_mcp_servers(project_dir) + server_names = {s["name"] for s in servers} + + # Should have org_server, project_server, and shared_server (project version) + if "org_server" in server_names and "project_server" in server_names: + print(" PASS: Both org and project servers included") + passed += 1 + else: + print(f" FAIL: Expected org_server and project_server in {server_names}") + failed += 1 + + # shared_server should use project version (project overrides org) + shared = next((s for s in servers if s["name"] == "shared_server"), None) + if shared and shared["command"] == "project_cmd": + print(" PASS: Project overrides org for same server name") + passed += 1 + else: + print(f" FAIL: shared_server should use project_cmd: {shared}") + failed += 1 + + if "org_server__blocked_tool" in blocked_tools: + print(" PASS: Org blocked_mcp_tools preserved") + passed += 1 + else: + print(f" FAIL: Expected org_server__blocked_tool in {blocked_tools}") + failed += 1 + + # Test get_effective_mcp_tools + allowed_tools, permissions = get_effective_mcp_tools(servers, blocked_tools) + + # Check that org_tool is in allowed list + if "mcp__org_server__org_tool" in allowed_tools: + print(" PASS: Org server tools included") + passed += 1 + else: + print(f" FAIL: Expected mcp__org_server__org_tool in {allowed_tools}") + failed += 1 + + # Check that project_tool is in allowed list + if "mcp__project_server__project_tool" in allowed_tools: + print(" PASS: Project server tools included") + passed += 1 + else: + print(f" FAIL: Expected mcp__project_server__project_tool in {allowed_tools}") + failed += 1 + + return passed, failed + + +def test_mcp_blocked_tools_enforcement(): + """Test that org-level blocked MCP tools are enforced.""" + print("\nTesting MCP blocked tools enforcement:\n") + passed = 0 + failed = 0 + + # Create a server config with some tools + servers = [ + { + "name": "filesystem", + "command": "npx", + "allowed_tools": ["read_file", "write_file", "delete_file"], + } + ] + + # Block write and delete + blocked_tools = {"filesystem__write_file", "filesystem__delete_file"} + + allowed_tools, _ = get_effective_mcp_tools(servers, blocked_tools) + + # read_file should be allowed + if "mcp__filesystem__read_file" in allowed_tools: + print(" PASS: Unblocked tool (read_file) is allowed") + passed += 1 + else: + print(f" FAIL: read_file should be allowed: {allowed_tools}") + failed += 1 + + # write_file should be blocked + if "mcp__filesystem__write_file" not in allowed_tools: + print(" PASS: Blocked tool (write_file) is excluded") + passed += 1 + else: + print(f" FAIL: write_file should be blocked: {allowed_tools}") + failed += 1 + + # delete_file should be blocked + if "mcp__filesystem__delete_file" not in allowed_tools: + print(" PASS: Blocked tool (delete_file) is excluded") + passed += 1 + else: + print(f" FAIL: delete_file should be blocked: {allowed_tools}") + failed += 1 + + # Test wildcard blocking + servers2 = [ + { + "name": "dangerous", + "command": "danger", + "allowed_tools": ["tool1", "tool2", "tool3"], + } + ] + blocked_wildcard = {"dangerous__*"} + + allowed_tools2, _ = get_effective_mcp_tools(servers2, blocked_wildcard) + + if len([t for t in allowed_tools2 if "dangerous" in t]) == 0: + print(" PASS: Wildcard blocks all tools from server") + passed += 1 + else: + print(f" FAIL: Wildcard should block all tools: {allowed_tools2}") + failed += 1 + + return passed, failed + + def main(): print("=" * 70) print(" SECURITY HOOK TESTS") @@ -991,6 +1396,31 @@ def main(): passed += pkill_passed failed += pkill_failed + # Test MCP server validation + mcp_val_passed, mcp_val_failed = test_mcp_server_validation() + passed += mcp_val_passed + failed += mcp_val_failed + + # Test MCP tool pattern matching + mcp_pattern_passed, mcp_pattern_failed = test_mcp_tool_pattern_matching() + passed += mcp_pattern_passed + failed += mcp_pattern_failed + + # Test MCP server config loading + mcp_load_passed, mcp_load_failed = test_mcp_server_config_loading() + passed += mcp_load_passed + failed += mcp_load_failed + + # Test MCP hierarchy resolution + mcp_hier_passed, mcp_hier_failed = test_mcp_hierarchy_resolution() + passed += mcp_hier_passed + failed += mcp_hier_failed + + # Test MCP blocked tools enforcement + mcp_block_passed, mcp_block_failed = test_mcp_blocked_tools_enforcement() + passed += mcp_block_passed + failed += mcp_block_failed + # Commands that SHOULD be blocked # Note: blocklisted commands (sudo, shutdown, dd, aws) are tested in # test_blocklist_enforcement(). chmod validation is tested in diff --git a/ui/package-lock.json b/ui/package-lock.json index ae46a24c..b0864e6d 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -81,6 +81,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2694,6 +2695,7 @@ "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2704,6 +2706,7 @@ "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2714,6 +2717,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2763,6 +2767,7 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -3067,6 +3072,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3184,6 +3190,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3396,6 +3403,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -3587,6 +3595,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4570,6 +4579,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4675,6 +4685,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4684,6 +4695,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4993,6 +5005,7 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5131,6 +5144,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0",