Root cause
SSRF & network boundaries introduced
feat(interview): enhance interview mode with codebase access and MCP support
How AI contributed: Direct introductionPublished Aug 3, 2026 in
q00/ouroborosAll 2 findings in this repository
Ouroboros (ouroboros-ai pip package) RCE via untrusted project-directory .env, incomplete-fix bypass of CVE-2026-47211 (GHSA-c4m7-2gwp-vw76): src/ouroboros/config/loader.py auto-loads ./env from the current directory at import (load_dotenv(), trusted=False semantics - repo-controlled values written into os.environ), and execution-routing variables read from os.environ are honored when Ouroboros spawns vendor CLIs. CVE-2026-47211's 0.39.0 fix (4e70b760) introduced _UNTRUSTED_ENV_DENYLIST to block this but omitted keys of the same RCE class: backend config-home roots (CODEX_HOME, OPENCODE_CONFIG, OPENCODE_CONFIG_DIR, XDG_CONFIG_HOME - redirect nested Codex/OpenCode agents to attacker config with mcp_servers command/args RCE and approval_policy never/danger-full-access), MCP bridge/plugin roster keys (OUROBOROS_MCP_CONFIG, OUROBOROS_PLUGIN_LOCKFILE, OUROBOROS_PLUGIN_TRUST_ROOT), SSRF guard toggle OUROBOROS_ALLOW_LOCAL_TRANSPORT, instruction/capability roots (OUROBOROS_AGENTS_DIR, COPILOT_CUSTOM_INSTRUCTIONS_DIRS, OUROBOROS_RUNTIME_PROFILE, OUROBOROS_TOOL_CAPABILITIES), plus cwd auto-discovery of ./.ouroboros/mcp_servers.yaml (RCE with no .env). A cloned malicious repo shipping .env executes attacker code with the victim's rights (CWE-15/CWE-94, HIGH). Fixed in 0.42.1.
Root cause
feat(interview): enhance interview mode with codebase access and MCP support
Fix
Removes project-directory MCP config auto-discovery and strips additional config-home, MCP/plugin, transport, agent-root, and capability variables from untrusted .env files before they can redirect execution.
AI-assisted fix: GitHub Copilot + ChatGPT/Codex + Claude Code · Q00
Case facts
@@ -28,6 +28,7 @@ import asyncio import os+from collections.abc import Callable from pathlib import Path import structlog@@ -46,13 +47,16 @@ # Retry configuration for transient API errors _MAX_RETRIES = 3-_INITIAL_BACKOFF_SECONDS = 1.0+_INITIAL_BACKOFF_SECONDS = 2.0 # Increased for custom CLI startup _RETRYABLE_ERROR_PATTERNS = ( "concurrency", "rate", "timeout", "overloaded", "temporarily",+ "empty response", # custom CLI startup delay+ "need retry", # explicit retry request+ "startup", ) @@ -84,6 +88,9 @@ def __init__( self, permission_mode: str = "default", cli_path: str | Path | None = None,+ allowed_tools: list[str] | None = None,+ max_turns: int = 1,+ on_message: Callable[[str, str], None] | None = None, ) -> None: """Initialize Claude Code adapter. @@ -94,26 +101,48 @@ def __init__( cli_path: Path to the Claude CLI binary. If not provided, checks OUROBOROS_CLI_PATH env var, then falls back to SDK's bundled CLI.+ allowed_tools: List of tools to allow. None means no tools.+ For interview mode with codebase access, use ["Read", "Glob", "Grep"].+ max_turns: Maximum turns for the conversation. Default 1 for single response.+ Increase for tool use scenarios.+ on_message: Callback for streaming messages. Called with (type, content):+ - ("thinking", "content") for agent reasoning+ - ("tool", "tool_name") for tool usage """ self._permission_mode: str = permission_mode self._cli_path: Path | None = self._resolve_cli_path(cli_path)+ self._allowed_tools: list[str] = allowed_tools or []+ self._max_turns: int = max_turns+ self._on_message: Callable[[str, str], None] | None = on_message log.info( "claude_code_adapter.initialized", permission_mode=permission_mode, cli_path=str(self._cli_path) if self._cli_path else None, ) def _resolve_cli_path(self, cli_path: str | Path | None) -> Path | None:- """Resolve the CLI path from parameter or environment variable.+ """Resolve the CLI path from parameter, config, or environment variable.++ Priority:+ 1. Explicit cli_path parameter+ 2. OUROBOROS_CLI_PATH environment variable+ 3. config.yaml orchestrator.cli_path+ 4. None (SDK default) Args: cli_path: Explicit CLI path from constructor. Returns: Resolved Path if set and exists, None otherwise (falls back to SDK default). """- # Priority: explicit parameter > environment variable > None (SDK default)- path_str = str(cli_path) if cli_path else os.environ.get("OUROBOROS_CLI_PATH", "")+ # Priority: explicit parameter > env var / config > SDK default+ if cli_path:+ path_str = str(cli_path)+ else:+ # Use config helper (checks env var then config.yaml)+ from ouroboros.config import get_cli_path++ path_str = get_cli_path() or "" path_str = path_str.strip() if not path_str:@@ -288,14 +317,29 @@ async def _execute_single_request( """@@ -225,6 +225,29 @@ class LoggingConfig(BaseModel, frozen=True): include_reasoning: bool = True +class OrchestratorConfig(BaseModel, frozen=True):+ """Orchestrator configuration for Claude Agent SDK.++ Attributes:+ cli_path: Path to Claude CLI binary. Supports:+ - Absolute path: /path/to/my-claude-wrapper+ - ~ expansion: ~/.my-claude-wrapper/bin/my-claude-wrapper+ - None: Use SDK bundled CLI+ default_max_turns: Default max turns for agent execution+ """++ cli_path: str | None = None+ default_max_turns: int = Field(default=10, ge=1)++ @field_validator("cli_path")+ @classmethod+ def expand_cli_path(cls, v: str | None) -> str | None:+ """Expand ~ in cli_path."""+ if v is None:+ return None+ return str(Path(v).expanduser())++ class OuroborosConfig(BaseModel, frozen=True): """Top-level Ouroboros configuration. @@ -252,6 +275,7 @@ class OuroborosConfig(BaseModel, frozen=True): persistence: PersistenceConfig = Field(default_factory=PersistenceConfig) drift: DriftConfig = Field(default_factory=DriftConfig) logging: LoggingConfig = Field(default_factory=LoggingConfig)+ orchestrator: OrchestratorConfig = Field(default_factory=OrchestratorConfig) def get_default_config() -> OuroborosConfig:@@ -8,16 +8,22 @@ load_credentials: Load credentials from ~/.ouroboros/credentials.yaml create_default_config: Create default configuration files ensure_config_dir: Ensure ~/.ouroboros/ directory exists+ get_cli_path: Get CLI path from env var or config """ import os from pathlib import Path import stat from typing import Any +from dotenv import load_dotenv from pydantic import ValidationError as PydanticValidationError import yaml +# Load .env file from current directory and ~/.ouroboros/+load_dotenv() # Current directory .env+load_dotenv(Path.home() / ".ouroboros" / ".env") # Global .env+ from ouroboros.config.models import ( CredentialsConfig, OuroborosConfig,@@ -290,3 +296,32 @@ def credentials_file_secure(credentials_path: Path | None = None) -> bool: file_mode = credentials_path.stat().st_mode # Check that only owner has read/write permissions return (file_mode & 0o777) == 0o600+++def get_cli_path() -> str | None:+ """Get CLI path from environment variable or config file.++ Priority:+ 1. OUROBOROS_CLI_PATH environment variable+ 2. config.yaml orchestrator.cli_path+ 3. None (use SDK default)++ Returns:+ Path to CLI binary or None to use SDK default.+ """+ # 1. Check environment variable (highest priority)+ env_path = os.environ.get("OUROBOROS_CLI_PATH", "").strip()+ if env_path:+ return str(Path(env_path).expanduser())++ # 2. Check config file+ try:+ config = load_config()+ if config.orchestrator.cli_path:+ return config.orchestrator.cli_path+ except ConfigError:+ # Config doesn't exist or is invalid - fall back to default+ pass++ # 3. Default: None (SDK uses bundled CLI)+ return None@@ -28,8 +28,20 @@ class MCPBridgeConfig: tool_prefix: str = "" -def discover_config(cwd: Path | None = None) -> Path | None:- """Auto-discover bridge configuration file."""+def discover_config() -> Path | None:+ """Auto-discover bridge configuration file.++ Trust boundary: only two trusted sources are honored — the explicit+ ``OUROBOROS_MCP_CONFIG`` env var (which the loader's untrusted-.env denylist+ strips from a cloned-repo ``.env``) and the home config+ ``~/.ouroboros/mcp_servers.yaml``. The project-directory+ ``./.ouroboros/mcp_servers.yaml`` is deliberately NOT auto-discovered: it+ travels with a cloned (untrusted) repo, and a bridge config's server+ ``command``/``args`` are spawned verbatim via ``stdio_client`` — loading it+ from the project dir would be remote code execution (same trust boundary as+ CVE-2026-47211). Do NOT re-introduce cwd-based discovery without an explicit+ trust gate.+ """ env_path = os.environ.get(_ENV_VAR) if env_path: p = Path(env_path)@@ -41,12 +53,6 @@ def discover_config(cwd: Path | None = None) -> Path | None: log.info("bridge.config.discovered", source="home", path=str(_HOME_CONFIG)) return _HOME_CONFIG - if cwd is not None:- cwd_config = cwd / ".ouroboros" / "mcp_servers.yaml"- if cwd_config.is_file():- log.info("bridge.config.discovered", source="cwd", path=str(cwd_config))- return cwd_config- return None @@ -172,15 +172,72 @@ def _is_placeholder_api_key(value: str) -> bool: "GJC_CODING_AGENT_DIR", "GJC_CONFIG_DIR", "PI_CONFIG_DIR",+ # Copilot custom-instruction roots — same instruction-injection class+ # as GJC_CODING_AGENT_DIR. `copilot/cli_policy.py` derives the child+ # env from os.environ and only *appends* the setup-owned dir, so an+ # untrusted .env entry survives and a spawned Copilot loads attacker+ # AGENTS.md from it.+ "COPILOT_CUSTOM_INSTRUCTIONS_DIRS",+ # Ouroboros agent-definition root. `agents/loader.py` resolves every+ # agent's role/persona markdown (socratic-interviewer, evaluator, …)+ # from this dir first; an untrusted .env pointing it at a committed+ # repo dir lets a cloned repo replace the system prompt of every+ # spawned sub-agent — instruction injection, same class as above.+ "OUROBOROS_AGENTS_DIR",+ # Backend config-home roots. The spawned vendor CLI resolves its own+ # config file — which can name MCP servers to launch, disable the+ # approval gate, and widen the sandbox — from these vars, and the var+ # passes through the child env untouched (it is not in any backend's+ # strip_keys). An untrusted repo .env must not redirect a nested agent+ # at attacker-controlled config. Codex honors $CODEX_HOME/config.toml+ # (mcp_servers.<name>.command/args -> arbitrary command execution;+ # approval_policy="never" + sandbox_mode="danger-full-access" ->+ # silent removal of the human approval gate). OpenCode resolves its+ # config from OPENCODE_CONFIG / OPENCODE_CONFIG_DIR and otherwise+ # falls back to $XDG_CONFIG_HOME/opencode. Completes CVE-2026-47211.+ "CODEX_HOME",+ "OPENCODE_CONFIG",+ "OPENCODE_CONFIG_DIR",+ "XDG_CONFIG_HOME",+ # Ouroboros' own MCP-bridge / plugin execution roster roots. Each+ # selects a file whose contents name an external command that the+ # bridge or plugin dispatcher then spawns verbatim — direct RCE, the+ # same threat model as the backend config-home roots above:+ # - OUROBOROS_MCP_CONFIG -> mcp/bridge/config.py:discover_config+ # returns the path; the YAML's server `command`/`args` are spawned+ # via stdio_client (loader -> discover_config -> MCPClientAdapter+ # -> stdio_client).+ # - OUROBOROS_PLUGIN_LOCKFILE / OUROBOROS_PLUGIN_TRUST_ROOT ->+ # plugin_dispatch resolves the installed-plugin roster and trust+ # root from these; redirecting them lets a cloned repo register an+ # attacker manifest / mark a malicious plugin as trusted, so+ # `ooo <name>` dispatches into attacker code.+ "OUROBOROS_MCP_CONFIG",+ "OUROBOROS_PLUGIN_LOCKFILE",+ "OUROBOROS_PLUGIN_TRUST_ROOT",+ # SSRF guard toggle. `mcp/types.py` blocks loopback/private/link-local+ # MCP transport targets unless this is "1"; an untrusted .env must not+ # be able to re-enable connections to internal addresses.+ "OUROBOROS_ALLOW_LOCAL_TRANSPORT", # Runtime/backend selectors — choose which adapter is spawned. "OUROBOROS_AGENT_RUNTIME", "OUROBOROS_RUNTIME", "OUROBOROS_LLM_BACKEND",+ # Backend profile selector (get_runtime_profile): chooses the+ # orchestrator backend profile and therefore which backend behavior /+ # executable is used — same routing class as the selectors above.+ "OUROBOROS_RUNTIME_PROFILE", # Permission-mode overrides — must not silently disable the # user's approval gate from an untrusted repo. "OUROBOROS_AGENT_PERMISSION_MODE", "OUROBOROS_LLM_PERMISSION_MODE", "OUROBOROS_OPENCODE_PERMISSION_MODE",+ # Tool-capability override file. The override YAML can lower a tool's+ # approval_class (e.g. ELEVATED -> DEFAULT), weakening the human+ # approval gate for non-built-in tools. External control of this path+ # is therefore an approval-gate-bypass sink — same class as the+ # permission-mode overrides above.+ "OUROBOROS_TOOL_CAPABILITIES", } ) @@ -23,8 +23,8 @@ async def create_bridge_from_config_file(path: Path) -> MCPBridge: return bridge -def create_bridge_from_env(cwd: Path | None = None) -> MCPBridge | None:- config_path = discover_config(cwd)+def create_bridge_from_env() -> MCPBridge | None:+ config_path = discover_config() if config_path is None: return None result = load_bridge_config(config_path)AI-assisted change cec114cae2bbf96a1223454c673a25b7d882187b9f4939a1e777df75eb17e89b · Fix 23493182cd8c08218528f0d6104a201f1a1b78c57c5091d2e78e044177f5d66e