18. One shadow, many agents: wrap Codex the way we wrap Claude#
Date: 2026-09-07
Status#
Accepted
Extends 9. Relocate the real Claude binary off PATH so the shadow always wins (ADR 9) and 13. Deliver the integrity guard globally via managed-settings (ADR 13) to a second agent.
Context#
Claude Code is no longer the only terminal coding agent people here run against a checkout. OpenAI’s Codex CLI — the client for GPT-6 Astra, released 2026-09-03 — is a peer: it reads the repository, executes shell commands, calls the network, and is steered by text it did not write. Every premise of the threat model applies to it unchanged. A prompt injection that reaches Codex reaches whatever Codex can reach.
Today claude is wrapped and codex is not. That is not a partial defence,
it is an open door beside a locked one: a user who has both installed gets
full host credentials the moment they type the unwrapped name, and the
sandbox’s own guard says nothing, because the guard only knows about Claude.
The vendors’ surfaces turn out to be near-isomorphic, which is what makes a shared implementation honest rather than a forced fit:
Concern |
Claude Code |
Codex CLI |
|---|---|---|
Real binary lands at |
|
|
Login / config state |
|
|
Admin-controlled policy |
|
|
Guard hook points |
|
|
Blocking a turn |
exit 2 on |
exit 2 on |
Update-driven bypass |
auto-updater re-creates the binary |
self-update re-creates the binary |
So the same bwrap argv, the same egress jail, the same script(1) pty wrap,
and the same two guard scripts are correct for both. Only four things differ:
which binary to exec, which $HOME paths carry the login, which flags to
inject (--no-chrome is Claude-only and would abort Codex), and which env the
sandbox sets for itself.
Decision#
One shadow file, installed under both names, dispatching on argv[0].
install.sh places the same claude-shadow at /usr/local/bin/claude and
/usr/local/bin/codex. The script resolves an agent profile from the name
it was invoked as; everything security-critical is shared code below that
point. A closed-set CLAUDE_SANDBOX_AGENT override exists for tests and
renamed symlinks, and can only ever select a hard-coded profile — never an
arbitrary binary.
The rejected alternative is a second codex-shadow. Duplicating ~800 lines of
bwrap argv construction would rebuild precisely the “surface too big to audit
in one read” that the bash-only rewrite walked back from — and the copies
would drift silently, so a hardening fix landed in one would quietly not exist
in the other. A profile table is the smallest thing that expresses “these two
are the same sandbox”.
The guard is delivered through Codex’s managed layer, not its user config.
/etc/codex/requirements.toml carries the SessionStart verifier and the
UserPromptSubmit gate, pointing at the same root-owned
/usr/libexec/claude-sandbox/*.sh scripts. This is the same reasoning as
13. Deliver the integrity guard globally via managed-settings: the entries live in /etc (not removable by
editing a user file), and the scripts live off-PATH and read-only inside the
sandbox (not rewritable to exit 0 from inside the jail). It matters
particularly for Codex, whose project-scoped .codex/config.toml sits inside
the read-write workspace and is therefore attacker-writable from inside a
compromised session — the hard tier is above it.
We do not set allow_managed_hooks_only, for the same reason we do not set
Claude’s allowManagedHooksOnly: it would silence the owner’s own hooks.
The codex shadow is installed even when the Codex binary is not. The
shadow has to own the name on $PATH before the vendor’s installer can claim
it. An unbacked shadow loud-fails with instructions; an unshadowed vendor
binary runs outside the jail. Fetching the binary is best-effort — a failed
download warns and continues, because it must not brick a Claude install.
TOML is owned, not merged. This repo is bash-only and has no TOML
equivalent of jq. Rather than half-parse TOML, the installer writes files it
marks as its own and refuses to touch a requirements.toml it did not write,
warning with the exact snippet to paste. Bricking a site’s real Codex policy
would be worse than not wiring the guard — the same call the non-JSON
managed-settings path already makes.
Consequences#
claudebehaviour is bit-for-bit unchanged. The claude profile reproduces the previous hard-coded values exactly, which the existing 138 argv assertions confirm unmodified.Each agent sees only its own credentials: a Codex session gets no
~/.claude/~/.claude.jsonbind, and vice versa. Compromising one does not hand over the other’s login.~/.codexjoins~/.claudein the cross-container share, on the 6. Scope credentials to the container; re-paste PATs on every rebuild reading: it is one vendor login, not a repo-scoped forge PAT. Forge PATs stay container-scoped. The share is fail-soft — a read-only shared store costs a re-login, never the install.Codex updates become deliberate, like Claude’s:
check_for_update_on_startup = falsein the managed config, plusCODEX_UPDATE_DISABLED=1set inside the sandbox. For Codex the in-sandbox half is belt-and-braces rather than load-bearing — the binary it execs is read-only in the session (see the exec-in-place bullet below), so a self-update cannot rewrite it; what the variable buys is not spending a session’s first seconds fetching a release it cannot install, and not leaving a half-unpacked tree inCODEX_HOME. Claude’s path is the weaker one: its real binary is bind-mounted read-write at$HOME/.local/bin/claude(Invariant 1 wants the conventional path to exist), so there an in-session self-update genuinely would rewrite the host’s relocated binary, andDISABLE_AUTOUPDATERis the only thing standing in the way. Making that bind read-only is worth considering separately.Neither agent’s binary is upgraded by re-running
./install:install_claude_binaryandinstall_codex_binaryboth early-return once the relocated binary exists. With the in-container auto-updater deliberately off, the only way to move to a newer agent release today is to delete the relocated copy first. That is a pre-existing property of the Claude path which this ADR inherits rather than introduces, but “updates become a deliberate./install” is not yet literally true, and wiring an explicit re-fetch is left as follow-up work.Codex’s install layout needed handling that Claude’s did not, in four ways that only surfaced by reading the vendor’s installer and then running it for real:
It unpacks a versioned release under
$CODEX_HOME/packages/standalone/releases/<version>/and puts only a symlink at~/.local/bin/codex. Relocating the link would relocate nothing, so we resolve it first.Codex is a package, not a binary. The release carries
bin/codexalongside ripgrep (codex-path/rg) and Codex’s own bwrap and zsh helpers (codex-resources/) — the vendor’s own validity check requires them together. So the whole release directory is copied to/usr/libexec/claude-sandbox/codex-dist/, and codex is exec’d in place from there with no bind-back:/usr/libexecis already visible via--ro-bind / /, the package’s internal layout stays intact, and the binary we exec is consequently read-only in the session. That is strictly better than Claude’s bind-back, where the rw bind means an in-session self-update can rewrite the host’s relocated binary.The real binary ships inside
~/.codex, the directory we bind read-write because it is alsoCODEX_HOME. A writable copy of the agent’s own binary inside its own session is a persistence foothold, so the shadow tmpfs-masks~/.codex/packages— the same treatment Claude’s versioned binary cache at~/.local/share/claudegets. The mask is emitted after the bind it covers, because bwrap applies argv in order. The managed daemon reads/proc/<pid>/statusing sandbox-local PIDs, which do not match the outer procfs retained by the sandbox. Consequently,codex-launchruns inside bwrap and supervises a foreground app-server forcodex agents, connecting the client with--remoteover a private socket in the sandbox’s/tmp. Configuration overrides are forwarded to the server, and--cdsets its working directory. Both fresh launches and nested spawns use this helper, with the binary path supplied by the shadow. The helper cleans up on client exit or a termination signal, escalating from TERM to KILL after two seconds. Unsupported agents options produce a warning before native startup. Explicit remote connections and other commands pass through. This server is scoped to one invocation and its workspace; sharing a persistent server across sandbox launches is not implemented. Foreground startup uses the relocated binary directly, so it requires neither the managed standalone installation path nor a package bind-back. The Unix socket has no application-level authentication. Its access boundary is the sandbox’s private/tmpand the mode-0700 directory created bymktemp, not a token or a client identity check. Another process running as the same user inside that sandbox can discover the socket, connect to app-server, and exercise its capabilities with the server’s credentials and workspace access. This is accepted: processes within one sandbox share a trust boundary; the socket does not isolate the interface from tools, hooks, or subagents in that sandbox. It must not be moved into sharedCODEX_HOMEor exposed over a network listener without revisiting authentication and cross-workspace access.By the time
install_codex_binaryruns,link_terminal_confighas usually already symlinked~/.codexinto the shared cross-container store — so letting the vendor unpack there would push a versioned release tree, tens of MB per release and pruned by nothing, onto the host share that every other container mounts. The in-session tmpfs mask hides that tree but does not stop it accumulating. So the vendor installer is pointed at a temporaryCODEX_HOME, and both it and any tree a future installer drops in the real one are deleted once the/usr/libexeccopy is taken. Only the copy we exec survives the install.
Two vendor-installer behaviours make an unattended install fail, both worked around where the script leaves a seam. It ends with a
Start Codex now? [y/N]prompt read from/dev/tty, not stdin — so piping toshdoes not make it unattended (CODEX_NON_INTERACTIVE=1). And its release tarball carries uid/gid 1001, which tar-as-root tries to restore; under a rootless container that chown is denied and tar exits non-zero, failing the whole install (TAR_OPTIONS=--no-same-owner).A site that already ships its own
/etc/codex/requirements.tomlgets a warning and no guard until someone merges the snippet by hand. That is the accepted cost of not owning other people’s policy files.Adding a third agent is now a profile entry plus a managed-config writer, not another shadow.