Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion agent/transports/codex_app_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
from dataclasses import dataclass, field
from typing import Any, Optional

from tools.environments.local import hermes_subprocess_env

# Default minimum codex version we test against. The PR sets this from the
# `codex --version` parsed at install time; bumping is a one-line change here.
MIN_CODEX_VERSION = (0, 125, 0)
Expand Down Expand Up @@ -74,7 +76,13 @@ def __init__(
env: Optional[dict[str, str]] = None,
) -> None:
self._codex_bin = codex_bin
spawn_env = os.environ.copy()
# Codex app-server is a model-driving CLI executor: it legitimately
# needs LLM provider credentials, so inherit_credentials=True. But it
# still must not inherit Tier-1 secrets (gateway bot tokens, GitHub
# auth, infra tokens) that have nothing to do with running code —
# the previous `os.environ.copy()` handed those over unfiltered to
# every codex subprocess (#29157 sibling gap).
spawn_env = hermes_subprocess_env(inherit_credentials=True)
if env:
spawn_env.update(env)
if codex_home:
Expand Down
100 changes: 100 additions & 0 deletions tests/agent/transports/test_codex_app_server_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,3 +295,103 @@ def kill(self):
)
assert "sandbox_workspace_write.network_access=false" in cmd
assert all("danger" not in part for part in cmd)


class TestSpawnEnvSecretStripping:
"""Codex app-server is a model-driving CLI executor (sibling of the
#29157 subprocess-env-leak class): it legitimately needs LLM provider
credentials, but must not inherit Tier-1 secrets (gateway bot tokens,
GitHub auth, infra tokens) that have nothing to do with running code.

Before this fix, ``spawn_env`` was a raw ``os.environ.copy()`` with no
filtering at all — every secret in the Hermes process environment was
handed unfiltered to the spawned ``codex`` subprocess.
"""

def test_tier1_secrets_stripped_from_spawn_env(self, monkeypatch):
import subprocess
from agent.transports import codex_app_server as cas

captured = {}

class FakePopen:
def __init__(self, cmd, *args, **kwargs):
captured["env"] = kwargs.get("env", {}).copy()
self.stdin = None
self.stdout = None
self.stderr = None
self.pid = 1
self.returncode = None

def poll(self):
return None

def terminate(self):
pass

def wait(self, timeout=None):
return 0

def kill(self):
pass

monkeypatch.setattr(subprocess, "Popen", FakePopen)
monkeypatch.setenv("HOME", "/users/alice")
monkeypatch.setenv("GH_TOKEN", "ghp_super_secret")
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "bot-secret")
monkeypatch.setenv("MODAL_TOKEN_SECRET", "modal-secret")
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "dash-secret")

client = cas.CodexAppServerClient(codex_bin="codex")
client._closed = True

env = captured["env"]
for leaked in (
"GH_TOKEN",
"TELEGRAM_BOT_TOKEN",
"MODAL_TOKEN_SECRET",
"HERMES_DASHBOARD_SESSION_TOKEN",
):
assert leaked not in env, f"{leaked} leaked into codex subprocess env"
# The Tier-1 strip must not collide with HOME preservation
# (TestSpawnEnvIsolation) — codex's own shell tool still needs it.
assert env.get("HOME") == "/users/alice"

def test_provider_credentials_still_reach_codex(self, monkeypatch):
"""Codex is a model-driving CLI — it needs its own provider auth
(e.g. OPENAI_API_KEY) to actually authenticate. The Tier-1 strip
must not collaterally remove the credential codex itself needs."""
import subprocess
from agent.transports import codex_app_server as cas

captured = {}

class FakePopen:
def __init__(self, cmd, *args, **kwargs):
captured["env"] = kwargs.get("env", {}).copy()
self.stdin = None
self.stdout = None
self.stderr = None
self.pid = 1
self.returncode = None

def poll(self):
return None

def terminate(self):
pass

def wait(self, timeout=None):
return 0

def kill(self):
pass

monkeypatch.setattr(subprocess, "Popen", FakePopen)
monkeypatch.setenv("HOME", "/users/alice")
monkeypatch.setenv("OPENAI_API_KEY", "sk-fake-codex-key")

client = cas.CodexAppServerClient(codex_bin="codex")
client._closed = True

assert captured["env"].get("OPENAI_API_KEY") == "sk-fake-codex-key"
Loading