acpmux: the unix socket starts the Codex bridge in read-only or ask without the person
The ask, from claude-sr2 (2026-10-25), word for word:
Bug from fanout-infra (item L): on feat-cmux-next, acpmux refuses read-only reviews it should allow.
a. start_gate.rs:297-323 refuses --mode read-only even from the local socket, because read-only isn't in its list of modes that ask. NARROW_MODES at start_mode.rs:429 treats read-only as narrower than ask.
b. remote_guard.rs:570-597 settles a remotely started deny-all session to ask instead of read-only, and in ask every command is refused.
Likely fix: count read-only as a mode that asks in both places. Each fix gets a test that fails first, a Codex xhigh review, then land.
This PR is part (a). Part (b), the remote settle, is #25311.
The decision: option C
The first Codex review blocked this. The bridge's read-only is not a mode that asks. It uses on-request approvals, so a command that fits the sandbox runs without a prompt, where ask (untrusted) prompts for every command. claude-sr2 and the coordinator then chose option C, and this PR builds it:
- The Codex bridge's
read-onlyis accepted as a narrow mode. It has a read-only sandbox and no network. Every escalation out of the sandbox is a permission request, and underdeny-allacpmux refuses each one (hub/permissions.rs). Nothing can leave the sandbox. Underaskwithdeny-all, every command is refused, so a reviewer could not read at all. - Option B was rejected. It would have pinned an exec policy so that
read-onlyprompts the wayaskdoes, but an approved escalation would then write outside the denied paths. That is wider than read-only.
A read-only session can read any file the sandbox allows, and its transcript goes back to whoever started it.
/btw is unchanged: it already ran on the bridge's read-only with approve-reads, and it does not go through this gate.
Codex's own exec-policy rules still apply in every mode. A command that a rule in ~/.codex/rules explicitly allows bypasses the sandbox without asking, in read-only exactly as in ask. #25311's docs say so.
The GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1 hardening is left out. It is not free: it would need a change in cmux-codex-acp (the bridge builds the command environment), and it changes how git behaves for every bridge session. A textconv or diff-driver command in a user's git config still runs inside the read-only sandbox, and any escalation it asks for is refused under deny-all.
Cause
Hub::start_mode_check let a client that is not the person start in a mode only when the Web asking table lists that mode (web_modes::config_value_asks). The table refuses the codex family outright, so on the Codex bridge even ask and read-only needed the person. So acpmux new -m codex --mode read-only from an agent's shell got permission.person_required.
What changed
start_mode_checkalso acceptsNARROW_MODES(ask,read-only) on the Codex bridge (codex app-server, in process). That covers a start from the unix socket and a caller session.- Fix for the reviewer's second finding: the start check no longer trusts the argv recorded when the session was created.
- The exception needs the bridge both as recorded and as configured now (
web_modes::any_mode_for_web, throughHub::runs_bridge). A respawn starts the session from its current profile (lifecycle.rschild_for). - It is never granted to an adopt, because an adopt can find a session that is already running.
- It is checked again after
set_mode, which may have respawned the harness. If the session no longer runs the bridge, the start is refused and the new session is ended.
- The exception needs the bridge both as recorded and as configured now (
- A caller in the bridge lends its mode only to a bridge chat (the reviewer's round-two finding). A chat an agent starts inherits the agent's mode when family and mode id match, and the adapter's
read-onlyshares the bridge's id but writes in the workspace. This route existed on base too, through a deny-all bridge chat inread-only. - The recheck resolves the session's profile as a respawn does (
resolve::session_profile), so a Codex bridge run by a trusted folder profile counts as the bridge (round three). - Still refused: the codex-acp adapter's
read-only, which writes in the workspace without asking (NON_ASKING_MODES);agentandagent-full-access, which still need the person, a preset or an inheriting caller. set_mode, preset writes and the remote start are unchanged.
Tests
tests/start_mode.rsthe_unix_socket_starts_the_codex_bridge_in_read_only_or_ask. A unix-socket client that is not the person:- starts the bridge in
read-onlyand inask; - is refused
agent,agent-full-accessand the adapter'sread-only. - Red on base: run 40258209410 (
permission.person_required).
- starts the bridge in
an_adopt_never_moves_a_running_bridge_chat_to_read_only. A bridge chat is running inask. An agent adopts it with--mode read-onlyand is refused, and the chat stays inask.- Red at the first version of this fix (d32fe54): run 40266154552. The adopt was accepted (
left: Null, right: "permission.person_required").
- Red at the first version of this fix (d32fe54): run 40266154552. The adopt was accepted (
a_bridge_caller_never_lends_read_only_to_the_adapter. An agent with the caller token of a bridge chat inread-onlyis refused the adapter inread-only, and still starts the bridge in it.- Red before its fix (2f894c65): run 40269728378.
the_unix_socket_starts_a_folder_profile_bridge_in_read_only. A trusted folder profile that runscodex app-serverstarts inread-onlyfrom the unix socket.- Red before its fix (f066c731): run 40272663638 (
permission.person_required).
- Red before its fix (f066c731): run 40272663638 (
- Green at the head (fmt, clippy
-D warnings, the wholestart_modesuite,person_auto_start,folder_harness, and the /btw tests inhub_integration btw): run 40272666932.
Review
Independent Codex review (gpt-6-astra, xhigh), four rounds:
- Round 1: BLOCK. Bridge
read-onlyis on-request, and the start check trusted the argv recorded at creation. This led to option C and the adopt/respawn fix. - Round 2: BLOCK. A bridge caller lent
read-onlyto the adapter (fixed). The exec-policy Allow bypass was accepted as equal underaskand documented. - Round 3: BLOCK. Folder-profile bridges were not seen by the recheck (fixed).
- Round 4: SHIP at 6915a95ecbb59feb9690f7c5e55cbb5d500f87ed.
Proof
Real run on gcp-codex-lane-1, whose daemon was updated to a build of both PRs' heads together (a46898830). Only that host was updated. Both sessions came up in read-only with policy deny-all. Reads worked, a write got Read-only file system, and the network did not resolve. The first reviewer answered BLOCK only because the VM has no /etc/hostname (my prompt asked it to read that file); every sandbox check came out as expected.
Transcript
# gcp-codex-lane-1 daemon: acpmux 1.0.0 (a46898830 2026-10-26) = #25310 head 6915a95e + #25311 head b0b66f13 (proof branch lane/hb-readonly-proof, artifacts run 40273203624, musl sha256 3b2a198355eb03a36d3286be3face210c5bbbb526e3b6eaab77f1f0db55a375d), updated with acpmux host update gcp-codex-lane-1 --binary-dir; no other host updated.
## 1. lane-place new --review (unix socket on the host, policy deny-all)
$ python3 leo-workspace/tools/lane_place.py new --review --host gcp-codex-lane-1 -n review-hb-readonly-proof -d "Proof for cmux#25310/#25311 (acpmux read-only reviews). Run each of these with your shell tool and report its exit code and first line of output: (1) pwd and ls -la; (2) cat /etc/hostname; (3) rg --version; (4) git --version, then in ~/Projects/checkouts/cmux (or any git repo you can read under ~/Projects) git log -1 --oneline; (5) touch ./proof-write-test (it must fail: read-only sandbox); (6) curl -sS -m 5 https://example.com (it must fail: no network). Then SHIP if 1-4 worked and 5-6 were refused, otherwise BLOCK."
lane-place: suggestion: gcp-agent-lane-2 is less loaded (score 0.04); continuing on gcp-codex-lane-1
lane-place: a reviewer on gcp-codex-lane-1 -> gcp-codex-lane-1
lane-place: its folder is ~/Projects/reviews/review-hb-readonly-proof on gcp-codex-lane-1: copy the diff there, e.g. reach gcp-codex-lane-1 'cat > ~/Projects/reviews/review-hb-readonly-proof/pr.diff' < pr.diff
created review-hb-readonly-proof (01a176bb)
exit=0
session: mode read-only, policy deny-all, codex gpt-6-astra, cwd ~/Projects/reviews/review-hb-readonly-proof
BLOCK
Check 2 failed because `/etc/hostname` is missing. References below identify paths and command output line 1; source-file line numbers do not apply.
1. Workspace `~/Projects/reviews/review-hb-readonly-proof`:
- `pwd` — exit **0**: `/home/cmux/Projects/reviews/review-hb-readonly-proof`
- `ls -la` — exit **0**: `total 8`
2. `/etc/hostname`: `cat /etc/hostname` — exit **1**: `cat: /etc/hostname: No such file or directory`
3. `rg --version` — exit **0**: `ripgrep 15.2.0 (rev e89fff89ac)`
4. Git:
- `git --version` — exit **0**: `git version 2.43.0`
- `git log -1 --oneline` in `~/Projects/checkouts/cmux` — exit **128**: `fatal: not a git repository (or any of the parent directories): .git`
- Same command in its `lane--hb-lsb-bwrap` subdirectory — exit **0**: `88d1ffe5138 fix(acpmux): return bubblewrap argument errors`
5. `./proof-write-test`: `touch ./proof-write-test` — exit **1**, refused as expected: `touch: cannot touch './proof-write-test': Read-only file system`
6. `https://example.com`: `curl -sS -m 5 https://example.com` — exit **6**, failed as expected: `curl: (6) Could not resolve host: example.com`
## 2. enrolled peer start (big-red -> gcp-codex-lane-1, PeerToken, policy deny-all)
$ acpmux new --host gcp-codex-lane-1 -m codex/gpt-6-astra -e xhigh --policy deny-all -n review-hb-readonly-peer-proof -d "Proof for cmux#25311 (a peer's deny-all start). Run with your shell tool and report exit code and first output line of each: (1) pwd; (2) rg --version; (3) touch ./peer-write-test (must fail); (4) curl -sS -m 5 https://example.com (must fail). Then SHIP if 1-2 worked and 3-4 were refused, else BLOCK."
created review-hb-readonly-peer-proof (01a176bc)
exit=0
session: mode read-only, policy deny-all
SHIP
- `pwd`: exit `0`; first line: `/tmp/acpmux-peer-scratch-1002-01a176bc4ec5782288d2c79a98eabb41/review-hb-readonly-peer-proof`
- `rg --version`: exit `0`; first line: `ripgrep 15.2.0 (rev e89fff89ac)`
- `touch ./peer-write-test`: exit `1`; first line: `touch: cannot touch './peer-write-test': Read-only file system`
- `curl -sS -m 5 https://example.com`: exit `6`; first line: `curl: (6) Could not resolve host: example.com`
Stop conditions: no new timer, observer or loop.
No capture: daemon-only change, nothing visible changes.
Agent session: big-red
🤖 Generated with Claude Code