ouroboros-ai Vulnerable to Remote Code Execution via Untrusted Project-Directory .env
Description
Impact
A Remote Code Execution (RCE) vulnerability was discovered in Ouroboros. If a user clones a malicious repository and runs Ouroboros commands within that directory, it can lead to arbitrary code execution and potential system takeover.
The vulnerability (CWE-426: Untrusted Search Path & CWE-15: External Control of System Setting) stems from Ouroboros loading the .env file from the current working directory. Prior to the patch, execution-affecting environment variables such as OUROBOROS_CLI_PATH, OPENCODE_CLI_PATH, and other backend selectors were accepted directly from this local .env. An attacker could include a malicious script in the repository and point the CLI path variable to it (e.g., OUROBOROS_CLI_PATH=./malicious_script.sh). When the user executes a command like ouroboros init or any command that instantiates the adapter, the malicious script is executed instead of the intended CLI.
Patches
The vulnerability has been patched in version 0.39.0 via PR #1078. The fix establishes a strict trust boundary by applying a denylist to project-local .env loading. It blocks execution-affecting environment variables (such as runtime selectors and CLI path overrides) from being loaded from the project directory. Explicit constructor overrides and trusted user-owned home configurations (~/.ouroboros/.env) remain fully functional.
Users are strongly advised to upgrade to version 0.39.0 or later.
Workarounds
If upgrading is not immediately possible, users must carefully inspect any .env file inside cloned repositories before running Ouroboros commands to ensure it does not contain unexpected OUROBOROS_*_CLI_PATH or OPENCODE_CLI_PATH overrides.
### References - GitHub PR: https://github.com/Q00/ouroboros/pull/1078
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Ouroboros loads untrusted .env from working directory, allowing RCE via malicious repository by overriding CLI path variables.
Vulnerability
Ouroboros prior to version 0.39.0 loads the .env file from the current working directory at import time, treating it with the same trust as the user's home configuration (~/.ouroboros/.env). This allows a malicious repository to supply a .env file containing execution-affecting environment variables such as OUROBOROS_CLI_PATH, OPENCODE_CLI_PATH, and runtime/backend selectors (OUROBOROS_AGENT_RUNTIME, OUROBOROS_RUNTIME, OUROBOROS_LLM_BACKEND). These variables control which binary the runtime adapters spawn. The vulnerability is classified as CWE-426 (Untrusted Search Path) and CWE-15 (External Control of System Setting) [1][3].
Exploitation
An attacker creates a repository containing a malicious script (e.g., malicious_script.sh) and a .env file that sets a CLI path variable to point to that script (e.g., OUROBOROS_CLI_PATH=./malicious_script.sh). When a victim clones the repository and runs any Ouroboros command that instantiates an adapter (such as ouroboros init), the malicious script is executed instead of the intended CLI. No additional authentication or user interaction beyond running the command is required [1][3].
Impact
Successful exploitation leads to arbitrary code execution in the context of the victim user, potentially resulting in full system takeover. The attacker gains the ability to execute arbitrary commands, access sensitive data, and install persistent malware [3][4].
Mitigation
The vulnerability is patched in Ouroboros version 0.39.0 via PR #1078 [1]. The fix introduces a denylist that blocks execution-affecting environment variables from being loaded from project-local .env files, while trusted sources (shell export, ~/.ouroboros/.env, ~/.ouroboros/config.yaml) remain functional. If upgrading is not immediately possible, users must manually inspect any .env file in cloned repositories for unexpected OUROBOROS_*_CLI_PATH or OPENCODE_CLI_PATH overrides before running Ouroboros commands [3][4].
AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Patches
14e70b760b4ebfix(security): block RCE via untrusted project-directory .env (#1078)
4 files changed · +227 −3
src/ouroboros/config/loader.py+59 −3 modified@@ -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:
src/ouroboros/providers/claude_code_adapter.py+6 −0 modified@@ -222,6 +222,12 @@ def _resolve_cli_path(self, cli_path: str | Path | None) -> Path | None: if not path_str: return None + # The untrusted-`.env` trust boundary is enforced upstream in + # config.loader (OUROBOROS_CLI_PATH and aliases are stripped from a + # cloned repo's .env), so any path that reaches here came from a + # trusted source — an explicit caller, the real environment, or + # ~/.ouroboros config. No source-blind path rejection here: that + # would break legitimate relative wrapper overrides. resolved = Path(path_str).expanduser().resolve() if not resolved.exists():
tests/unit/config/test_loader_env.py+133 −0 modified@@ -5,9 +5,27 @@ import os from pathlib import Path +import pytest + from ouroboros.config.loader import _load_env_file +@pytest.fixture(autouse=True) +def _restore_environ(): + """`_load_env_file` writes os.environ directly, bypassing monkeypatch. + + Without an explicit restore these tests leak keys (notably the + runtime/backend selectors) into the session and break every later + test that resolves a backend. + """ + saved = os.environ.copy() + try: + yield + finally: + os.environ.clear() + os.environ.update(saved) + + def test_load_env_file_sets_missing_values(tmp_path: Path, monkeypatch) -> None: env_file = tmp_path / ".env" env_file.write_text("export FIRST=value\nSECOND='two words'\nTHIRD=three # trailing comment\n") @@ -57,3 +75,118 @@ def test_load_env_file_skips_template_placeholders(tmp_path: Path, monkeypatch) _load_env_file(home_env) assert os.environ["OPENROUTER_API_KEY"] == "real-key" + + +_DENYLISTED_KEYS = ( + "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 alias (no OUROBOROS_ prefix) honored + executed by + # opencode_config._configured_opencode_cli_path. + "OPENCODE_CLI_PATH", + # Runtime/backend selectors route to an adapter whose CLI then + # resolves via a weak PATH lookup — also an RCE sink. + "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 test_untrusted_env_cannot_set_bare_opencode_alias( + tmp_path: Path, + monkeypatch, +) -> None: + """Regression: opencode_config reads bare OPENCODE_CLI_PATH and runs it.""" + env_file = tmp_path / ".env" + env_file.write_text("OPENCODE_CLI_PATH=./evil\n") + monkeypatch.delenv("OPENCODE_CLI_PATH", raising=False) + + _load_env_file(env_file, trusted=False) + + assert "OPENCODE_CLI_PATH" not in os.environ + + +def test_untrusted_env_cannot_disable_approval_gate( + tmp_path: Path, + monkeypatch, +) -> None: + """Regression: a cloned repo must not force bypassPermissions.""" + env_file = tmp_path / ".env" + env_file.write_text("OUROBOROS_AGENT_PERMISSION_MODE=bypassPermissions\n") + monkeypatch.delenv("OUROBOROS_AGENT_PERMISSION_MODE", raising=False) + + _load_env_file(env_file, trusted=False) + + assert "OUROBOROS_AGENT_PERMISSION_MODE" not in os.environ + + +@pytest.mark.parametrize("key", _DENYLISTED_KEYS) +def test_untrusted_env_cannot_redirect_executable( + tmp_path: Path, + monkeypatch, + key: str, +) -> None: + """A cloned-repo .env must not set executable-path vars (RCE guard).""" + env_file = tmp_path / ".env" + env_file.write_text(f"{key}=./malicious_script.sh\n") + monkeypatch.delenv(key, raising=False) + + _load_env_file(env_file, trusted=False) + + assert key not in os.environ + + +@pytest.mark.parametrize("key", _DENYLISTED_KEYS) +def test_trusted_env_may_set_executable_path( + tmp_path: Path, + monkeypatch, + key: str, +) -> None: + """The home .env stays trusted and may set a custom CLI path.""" + env_file = tmp_path / ".env" + env_file.write_text(f"{key}=/usr/local/bin/claude\n") + monkeypatch.delenv(key, raising=False) + + _load_env_file(env_file, trusted=True) + + assert os.environ[key] == "/usr/local/bin/claude" + + +def test_untrusted_env_still_loads_non_sensitive_keys( + tmp_path: Path, + monkeypatch, +) -> None: + """Denylisting must be surgical: ordinary keys still load untrusted.""" + env_file = tmp_path / ".env" + env_file.write_text("OUROBOROS_CLI_PATH=./evil.sh\nOPENROUTER_API_KEY=key-123\n") + monkeypatch.delenv("OUROBOROS_CLI_PATH", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + + _load_env_file(env_file, trusted=False) + + assert "OUROBOROS_CLI_PATH" not in os.environ + assert os.environ["OPENROUTER_API_KEY"] == "key-123" + + +def test_load_env_file_defaults_to_untrusted_fail_closed( + tmp_path: Path, + monkeypatch, +) -> None: + """Default trusted=False: callers are safe-by-default (fail-closed).""" + env_file = tmp_path / ".env" + env_file.write_text("OUROBOROS_CLI_PATH=./evil.sh\n") + monkeypatch.delenv("OUROBOROS_CLI_PATH", raising=False) + + _load_env_file(env_file) # no trusted kwarg → must be treated as untrusted + + assert "OUROBOROS_CLI_PATH" not in os.environ
tests/unit/providers/test_claude_code_adapter.py+29 −0 modified@@ -250,6 +250,35 @@ async def fake_query(*args, **kwargs): assert "system_prompt" not in options_call_kwargs +class TestResolveCliPathPreservesPublicContract: + """The explicit cli_path override keeps its pre-hardening behavior. + + The untrusted-.env trust boundary is enforced in config.loader, so the + adapter must NOT second-guess the provenance of an explicit path: + a relative wrapper override still resolves relative to the cwd. + """ + + def test_relative_explicit_override_resolves_against_cwd(self, tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + wrapper = tmp_path / "claude-wrapper" + wrapper.write_text("#!/bin/sh\n") + wrapper.chmod(0o755) + + adapter = ClaudeCodeAdapter(cli_path="./claude-wrapper") + + assert adapter._cli_path == wrapper.resolve() + + def test_absolute_override_is_accepted(self, tmp_path) -> None: + binary = tmp_path / "bin" / "claude" + binary.parent.mkdir() + binary.write_text("#!/bin/sh\n") + binary.chmod(0o755) + + adapter = ClaudeCodeAdapter(cli_path=str(binary)) + + assert adapter._cli_path == binary.resolve() + + class TestAdapterOverheadReductions: """Test per-call overhead optimizations in ClaudeCodeAdapter."""
Vulnerability mechanics
Root cause
"Missing trust boundary in `_load_env_file` — the project-directory `.env` was loaded with the same trust as the user's home configuration, allowing execution-affecting environment variables to be set from an untrusted cloned repository."
Attack vector
An attacker creates a malicious repository containing a `.env` file that sets execution-affecting environment variables such as `OUROBOROS_CLI_PATH=./malicious_script.sh` or `OPENCODE_CLI_PATH=./evil` [ref_id=1]. When a victim clones this repository and runs any Ouroboros command (e.g., `ouroboros init`) that instantiates a runtime adapter, the `_load_env_file` function loads the untrusted `.env` into `os.environ` [patch_id=3129347]. The adapter then spawns the attacker-controlled script instead of the intended CLI binary, achieving arbitrary code execution. The bare `OPENCODE_CLI_PATH` alias was also exploitable because `opencode_config._configured_opencode_cli_path` reads and executes it via `subprocess.run` [ref_id=1].
Affected code
The vulnerability is in `src/ouroboros/config/loader.py`, specifically the `_load_env_file` function which loaded `./.env` from the current working directory at import time without distinguishing trust boundaries. The patch also touches `src/ouroboros/providers/claude_code_adapter.py` (`_resolve_cli_path`) and adds tests in `tests/unit/config/test_loader_env.py` and `tests/unit/providers/test_claude_code_adapter.py`.
What the fix does
The patch introduces `_UNTRUSTED_ENV_DENYLIST` in `src/ouroboros/config/loader.py`, a frozen set of keys (CLI path overrides, runtime/backend selectors, and permission-mode overrides) that are skipped when loading an untrusted `.env` [patch_id=3129347]. The `_load_env_file` function now defaults to `trusted=False` (fail-closed); only `~/.ouroboros/.env` is loaded with `trusted=True`. This ensures a cloned repository's `.env` cannot redirect which binary Ouroboros executes or silently disable the user's approval gate. The adapter-level `_resolve_cli_path` was also hardened to reject non-absolute paths as defense-in-depth, though the primary trust boundary is enforced in the loader [ref_id=1].
Preconditions
- inputVictim must clone a malicious repository containing a crafted .env file
- inputVictim must run an Ouroboros command (e.g., ouroboros init) from within the cloned repository directory
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.