acpmux: a narrower permission policy starts Codex and Claude in a mode that asks
Summary
A chat's acpmux permission policy (ask, approve-reads, approve-edits, deny-all) only answers the questions the harness asks. Since chats start at the full-access level by default, Codex ran agent-full-access (approval never, sandbox danger-full-access) and Claude Code ran bypassPermissions. Neither ever asks, so every narrower policy was a no-op: an approve-reads Codex chat ran every write and command without a question.
Now a chat whose own policy is narrower than approve-all starts in the harness's mode that asks. Setting a narrower policy on a live chat (_acpmux/set_policy) moves it there too. Chats with no narrower policy still default to full access (Lawrence 2026-10-14); that default is unchanged.
| acpmux policy | Codex mode (approval, sandbox) | Claude Code mode |
|---|---|---|
| ask, approve-reads, approve-edits | ask (untrusted, workspace-write): Codex asks before every command that is not a known-safe read and before every edit, and the policy answers each one | default |
| deny-all | read-only (on-request, read-only): the sandbox refuses every write, and the policy refuses whatever Codex asks for | default |
| approve-all | the start level, unchanged | the start level, unchanged |
Setting a narrower policy on a stopped chat (its agent not running) starts no agent: the chat's saved mode becomes the policy's mode, and its next start restores it. For Codex this also covers the bridge's mode config option, which replay_config replays after the mode; otherwise it would bring full access back. The check runs under the spawn lock, so it cannot race a cold start.
A chat whose agent still runs in its host counts as running even before the daemon has re-adopted that host (after a restart, or when the link broke), so the policy's mode reaches that agent.
On a live chat, a mode that already asks and is at least as narrow (Claude plan, Codex read-only) stays. Deny-all moves Codex ask to read-only. A refused mode change is soft: it is logged, and the chat keeps running.
OpenCode and pi cannot keep the policy.
- OpenCode's permissions come from its own config on this machine, which starts from
"*": "allow"(web_modes.rs). acpmux cannot make it ask. - pi, and any ACP agent with no modes, has no mode that asks.
For these chats acpmux now logs policy_unenforced {policy, modeId, reason} instead of silently implying the policy holds, so a client can say so. Enforcing a policy on OpenCode means writing its permission config, which is a separate change.
New file hub/policy_mode.rs; small hooks in start_mode.rs, lifecycle.rs and the _acpmux/set_policy handler.
Testing
Focused command: cmux-tui.yml dispatch, mode=focused.
-
Red at 6be8a324, tests only (run 39633066559, filter
narrower_policy):a_chat_with_a_narrower_policy_starts_in_the_mode_that_keeps_itfails attests/start_mode.rs:544(a Codex approve-reads chat started inagent-full-access);a_narrower_policy_on_a_live_chat_leaves_full_accessfails at:574.
-
Green at 3c4aae8b (run 39633079154, filter
policy): every test withpolicyin its name passes, including both new ones and the unit testseach_policy_maps_onto_a_codex_mode_that_keeps_itandclaude_asks_in_default_and_opencode_and_a_modeless_harness_cannot_keep_a_policy. -
Regression (run 39633091918, filter
_mode): the only failure istransfer_guard::a_chat_with_no_asking_mode_moves_under_peer_control_but_not_under_web. It fails the same way at base b8567e4c (run 39636868989), so it is a base red; that chat is approve-all, which this change leaves alone. -
Stopped chat, red at e2022831 (tests only, run 39639537829, Linux test job 119359231090):
a_narrower_policy_on_a_stopped_chat_starts_no_agentfails attests/start_mode.rs:608(setting the policy started the agent). -
Green at 1aa82c5b (run 39639543468, Linux test job 119359274132, filter
policy): everypolicytest passes, the stopped-chat one included. -
Restart after the stopped-chat fix:
-
Hosted agent not yet adopted:
- red at b2bc7bc9 (test only, run 39644408154, filter
narrower_policy_reaches):durable_agents::a_narrower_policy_reaches_an_agent_whose_host_is_not_adopted_yetfails attests/durable_agents.rs:1683: the hosted agent was never sentset_permission_mode; - green at cd4ffe71 (run 39644412674, filter
policy): 105 passed, 0 failed, the new test included.
- red at b2bc7bc9 (test only, run 39644408154, filter
Observed / Not run
- Observed: the runs above.
- Each policy against the fake Codex and Claude mode lists: ask, approve-reads and approve-edits start in Codex
ask, deny-all inread-only, approve-all inagent-full-access. Claude starts indefaultfor all four narrower policies and inbypassPermissionsfor approve-all. - A plain harness logs one
policy_unenforced. - On a live chat: Codex full access → approve-reads moves it to
ask, then deny-all toread-only, then approve-all leaves it inread-only. Claudeplanwithaskstays inplan.
- Each policy against the fake Codex and Claude mode lists: ask, approve-reads and approve-edits start in Codex
- Not run:
- A real Codex or Claude Code binary. The tests use the fake agents with the real bridge's mode ids (cmux-codex-acp
MODES). - Clippy for acpmux. The
lint (linux)job stops on a base clippy red incmux-tui-core/src/server/rows_tests.rs(redundant clone) before it reaches acpmux.
- A real Codex or Claude Code binary. The tests use the fake agents with the real bridge's mode ids (cmux-codex-acp
Follow-ups (not in this PR)
These paths still skip the policy mode:
- resuming a chat doesn't re-check the policy;
- a handoff to an existing target, the aside fork, and an explicit or preset start mode;
- an explicit
set_default_policy deny-allisn't applied to the start mode; - the
start_mode_answerpreview ignores the policy; - harnesses like Gemini with no mode that asks stay in yolo (they log
policy_unenforced); - OpenCode needs its permission config written to keep a policy.
- A
set_policyright after a hosted chat is closed, while its host is still exiting, counts the chat as running and can start it again (still in the policy's mode).
Changelog
Fixed: a chat with a narrower permission policy (ask, approve reads or edits, deny all) now starts Codex and Claude Code in a mode that asks, instead of full access that never asks.
No capture: daemon-only change with no UI.
Agent session: cosmic-gray
🤖 Generated with Claude Code