ADR 0012 — Codex as a backend, held by its own sandbox
Status: accepted 2026-09-25.
ADR 0006 asks three facts of any new backend. Measured on 2026-09-25 against codex-cli 0.157.0
(gpt-6-luna, a ChatGPT login), through codex app-server, the JSON-RPC protocol Codex's own IDE
clients use (types from codex app-server generate-ts).
The three facts
- Resume by id.
thread/startreturns the id Codex mints (UUIDv7), so the session row is renamed as for grok.thread/resumewith that id in a new process continues the conversation, with no replay of old items. - Permission prompt without a TTY. Codex sends
item/commandExecution/requestApprovalanditem/fileChange/requestApprovalto the client and waits foracceptordecline. After a decline the model reports that nothing ran. - Multi-turn process. One
turn/startper turn, ended byturn/completed(status completed, failed or interrupted). Four to thirteen seconds a turn.
The sandbox does what pi needed a gate for
Codex runs every command inside an OS sandbox (Seatbelt on macOS, bubblewrap on Linux) shaped by a
permission profile: filesystem entries read, write or deny, deny winning, network off unless
enabled, Unix sockets only when allowlisted. Measured with codex sandbox: a deny entry blocked a
read directly, through a shell and through a symlink, a listing and a write; without it the same
read went through.
So Angelia writes no gate of its own (the lesson of the paused pi backend, ADR 0011). The daemon
passes the profile on the app-server's command line with -c, where the agent cannot edit it:
default_permissions="angelia", extending:workspace(:read-onlyin plan mode).- The rules compile wrote to
.claude/settings.json(the launch guard checks them before every start): a path with a Read rule is denied, one with only an Edit rule (the launch files) is read-only, and add_dirs,_common/and directory capabilities are writable. A rule with a glob inside the path is not passed and is logged; compile writes none. - Network on, through Codex's network proxy (
features.network_proxy=true, every web domain allowed), and only Angelia's API socket in the socket list. A review measured that with network on and the proxy off every Unix socket on the machine answers (Angelia's tmux server included); with the proxy on, only a listed socket does, and HTTPS still works.angelia send-mediaandangelia turnneed that one socket. - The network is web only: through the proxy, HTTP and HTTPS work; SSH, raw DNS, binding a port
and connecting to a local one do not (measured).
allow_local_bindingwould give ports back but opens every local listener, so it stays off; the agent is told. - Package caches (npm, uv, pip, XDG) point at the profile's own folder under Angelia's state
(
cache/codex/<hash of the name>), writable to that profile alone; the other profiles' caches are denied to it (measured: own read and write work, another's read, write and listing do not). Not~/.npm, whose_npxholds code run later outside the sandbox, and not temp, which every sandboxed profile may write: a review planted code in another profile's npx cache there. - A user-level Codex MCP server the profile does not get is switched off
(
mcp_servers.<name>.enabled=false); compile records it as anmcp__<name>deny. - For Codex, compile also makes
.codex/and.agents/read-only in the profile folder, links allowed skills into.agents/skills(where Codex looks), and~/.codex/auth.jsonjoins every profile's floor. Codex's:workspacekeeps a.gitat the top of a writable folder read-only, so the agent cannot commit there; it is told so. - At every start Angelia checks what Codex applied: the thread must report the
angeliapermission profile (not full access) and approvals going to the user.approvalsReviewer: "user"is pinned, since a Codex config can route approvals to a Codex model reviewer. A[permissions.angelia]table set by any Codex config layer other than Angelia's own flags (the owner's, a trusted project's, the system's) would merge with Angelia's and reopen what it closes, so afterinitializeAngelia reads Codex's merged config with its layers (config/read) and refuses the start if one does (a layer Codex does not apply is skipped), or if any MCP server it would load is not one the last compile gave the profile (recorded in its compiled record); every TOML spelling and a trusted project's config count. A denied server whose name-ccannot address refuses too. - The launch guard records the backend and whether a Codex profile was compiled sandboxed; another
backend, or
sandbox: falsein the table since then, refuses the start until a compile accepts it.
The system temp folders stay writable: that is Codex's :workspace.
Modes
An approval in Codex always asks for more than its sandbox gives; inside it, commands run without
asking. So default and acceptEdits map to on-request (ask only for more; the permission line offers
add_dirs instead), bypassPermissions to never (never asks, never widens), plan to never with the
read-only profile. sandbox: false on a profile gives full access (danger-full-access) and passes
no permission profile.
A yes never lifts the sandbox. Measured on 0.157.0 (the first real chat test, 2026-09-25): with any
deny entry in the permission profile, and the floor always has some, Codex calls the denials
"non-escalatable" and runs an approved "outside the sandbox" command inside the whole sandbox
anyway, so the owner's yes did nothing. What works is asking for a folder: in default and
acceptEdits Angelia turns on features.exec_permission_approvals (a command carries
additionalPermissions, shown in the permission line once initialize opts into the experimental
API) and features.request_permissions_tool (item/permissions/requestApproval, relayed to the chat
as "more access, this turn"; a yes grants exactly what was asked, scope turn, a no grants nothing).
Measured through the orchestrator on real Codex: yes wrote the file, no left nothing, and a yes for
another profile's folder still failed, since a deny entry beats a grant. Both features are marked
"under development" in Codex; a Codex without them keeps the old behaviour, approvals that do nothing.
The owner's rule for the agent (2026-09-25): when the sandbox stops something, it says so and suggests
adding the folder to add_dirs. developerInstructions carries Angelia's self prompt and a note with
the writable folders, the profile's name for the compile command, and that suggestion. In default
and acceptEdits the note also lets the agent ask for access to the exact folder for the turn, and says
a command fully outside the sandbox does not work: a review measured that without such a line Codex
never asked, so those modes acted like bypass.
Measured after: a blocked write produced the prompt in the chat, and a decline kept it blocked.
sandbox: false counts as running unasked for the open-group check, whatever the mode.
Other facts
- Codex reads AGENTS.md;
project_doc_fallback_filenames=["CLAUDE.md"]makes it read the profile's CLAUDE.md (listed ininstructionSources). - Without a profile model, the one
model/listmarks as default:~/.codex/config.tomlon this Mac named a model the ChatGPT login refuses (400). /compactisthread/compact/start. The rollout file ($CODEX_HOME/sessions/<y>/<m>/<d>/rollout-*-<thread>.jsonl) feedsangelia export.- OPENAI_API_KEY, OPENAI_BASE_URL and CODEX_API_KEY join the variables no agent gets.
- The app-server protocol is marked experimental: below 0.157.0 the backend refuses with that reason.
- Only a missing thread ("no rollout found") starts fresh on resume; a thread another client holds is an error, so /resume can still reach it. A failed turn's message is the chat's text too, so a usage limit reads as one (with the /model hint).
angeliacommands inside the sandbox cannot stat other profiles' folders: there (Codex setsCODEX_SANDBOX), the table loader takes a folder it may not look at as present, and a daemon pid it may not signal as alive. Outside the sandbox both stay errors: a pid reused by another user's process must not keep Angelia from starting.- Not measured: Linux (bubblewrap). Other profiles' transcripts under
~/.codex/sessions(and Claude's under~/.claude/projects) stay readable, as for every backend today. - The
-ckey formfilesystem."<path>"="deny"keeps the quotes and fails; the inline table value (filesystem={"<path>"="deny"}) works.