Root cause
Injection & unsafe execution
The Claude-coauthored PR member read OUROBOROS_CLI_PATH, resolved it as a file, and handed it to the SDK as the CLI executable.
How AI contributed: New attack surfacePublished May 29, 2026 in
q00/ouroborosAll 2 findings in this repository
Claude-coauthored code in src/ouroboros/providers/claude_code_adapter.py added a _resolve_cli_path helper that honours OUROBOROS_CLI_PATH and returns the resolved file as the CLI binary. Because src/ouroboros/config/loader.py merged a project .env into os.environ with no trust distinction, a cloned repository could point that variable at a file it ships, and the Claude Agent SDK would then run that file with the user's privileges. The fix treats the project .env as untrusted and skips execution-affecting keys such as OUROBOROS_CLI_PATH, keeping the home .env trusted.
_resolve_cli_path treated the existence and file type of the resolved path as sufficient, so nothing established who had chosen it before the adapter handed the value to the SDK as the binary to launch. Loading project .env values into os.environ gave an untrusted checkout the same influence over that choice as the user's own configuration.
Root cause
The Claude-coauthored PR member read OUROBOROS_CLI_PATH, resolved it as a file, and handed it to the SDK as the CLI executable.
Fix
The later patch made the project .env untrusted and blocked execution-affecting selectors before they enter the process environment.
Case facts
@@ -58,12 +79,54 @@ class ClaudeCodeAdapter: permission_mode: Permission mode for SDK operations. - "default": Standard permissions - "acceptEdits": Auto-approve edits (not needed for interview)+ 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. """ self._permission_mode: str = permission_mode+ self._cli_path: Path | None = self._resolve_cli_path(cli_path) 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.++ Args:+ cli_path: Explicit CLI path from constructor.++ Returns:+ Resolved Path if set and exists, None otherwise.+ """+ # Priority: explicit parameter > environment variable > None (SDK default)+ path_str = cli_path or os.environ.get("OUROBOROS_CLI_PATH")++ if not path_str:+ return None++ resolved = Path(path_str).expanduser().resolve()++ if not resolved.exists():+ log.warning(+ "claude_code_adapter.cli_path_not_found",+ cli_path=str(resolved),+ )+ return None++ if not resolved.is_file():+ log.warning(+ "claude_code_adapter.cli_path_not_file",+ cli_path=str(resolved),+ )+ return None++ log.debug(+ "claude_code_adapter.using_custom_cli",+ cli_path=str(resolved), )+ return resolved async def complete( self,@@ -107,6 +170,7 @@ class ClaudeCodeAdapter: allowed_tools=[], # No tools - pure conversation permission_mode=self._permission_mode, # type: ignore[arg-type] cwd=os.getcwd(),+ cli_path=self._cli_path, ) # Collect the response@@ -118,7 +118,53 @@ def _is_placeholder_api_key(value: str) -> bool: ) -def _load_env_file(path: Path) -> None:+# Environment variables that determine HOW Ouroboros executes work. This+# is the single authoritative trust boundary: a cloned repository's `.env`+# must not be able to change which binary runs or whether the user's+# approval gate applies. Three classes, all remote-code-execution sinks+# when sourced from an untrusted location:+# 1. Explicit CLI path overrides fed straight into a subprocess.+# 2. Runtime/backend selectors that pick which adapter (and therefore+# which executable) is spawned — a selector can route to a backend+# whose CLI then resolves via a weak shutil.which / bare-name lookup.+# 3. Permission-mode overrides — setting acceptEdits/bypassPermissions+# silently removes the human approval gate, letting a malicious repo+# auto-approve arbitrary tool calls (effectively RCE).+# These keys are only honored from trusted sources (the real process+# environment, ~/.ouroboros/.env, ~/.ouroboros/config.yaml), never from+# the project-directory .env that travels with a cloned repo. Enforcing+# this here — at the .env load — keeps the policy in one place rather than+# split across downstream sinks.+_UNTRUSTED_ENV_DENYLIST = frozenset(+ {+ # Explicit executable-path overrides.+ "OUROBOROS_CLI_PATH",+ "OUROBOROS_CODEX_CLI_PATH",+ "OUROBOROS_COPILOT_CLI_PATH",+ "OUROBOROS_KIRO_CLI_PATH",+ "OUROBOROS_OPENCODE_CLI_PATH",+ "OUROBOROS_HERMES_CLI_PATH",+ "OUROBOROS_GOOSE_CLI_PATH",+ "OUROBOROS_GEMINI_CLI_PATH",+ # Bare provider aliases (no OUROBOROS_ prefix) that adapters also+ # honor and then execute. Any new such alias MUST be added here:+ # `opencode_config._configured_opencode_cli_path` reads+ # OPENCODE_CLI_PATH and runs it via subprocess.run.+ "OPENCODE_CLI_PATH",+ # Runtime/backend selectors — choose which adapter is spawned.+ "OUROBOROS_AGENT_RUNTIME",+ "OUROBOROS_RUNTIME",+ "OUROBOROS_LLM_BACKEND",+ # 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",+ }+)+++def _load_env_file(path: Path, *, trusted: bool = False) -> None: if not path.is_file(): return @@ -136,6 +182,11 @@ def _load_env_file(path: Path) -> None: if not key or any(ch.isspace() for ch in key): continue + if not trusted and key in _UNTRUSTED_ENV_DENYLIST:+ # Untrusted project-directory .env must not redirect which+ # binary Ouroboros executes (remote code execution guard).+ continue+ parsed_value = _parse_env_value(raw_value) if not parsed_value or _is_placeholder_api_key(parsed_value): continue@@ -145,8 +196,13 @@ def _load_env_file(path: Path) -> None: os.environ[key] = parsed_value -for env_path in (Path(".env"), Path.home() / ".ouroboros" / ".env"):- _load_env_file(env_path)+# The project-directory .env travels with whatever repository the user+# cloned and is therefore untrusted; ~/.ouroboros/.env lives in the user's+# home and is trusted. The trust flag gates execution-redirecting keys above.+# `_load_env_file` defaults to trusted=False (fail-closed) so any future+# caller is safe-by-default; trust must be opted into explicitly.+_load_env_file(Path(".env"), trusted=False)+_load_env_file(Path.home() / ".ouroboros" / ".env", trusted=True) def ensure_config_dir() -> Path:AI-assisted change d7dc0fc30783606cace15eaf0bcdeb53d7738ba0db1bc231722ab446f5255149 · Fix f8042190564a9c526bc43f7d718db7608a98b72519974f2f0391bff8d3b9f17a