acpmux: a narrower policy holds on presets, resume, asides, handoffs and a deny-all default
Merged
TE
16d ago+449+449+012345678901234567890123456789-58-58-01234567890123456789
Summary
#23572 put a chat with a narrower permission policy (ask, approve-reads, approve-edits, deny-all) in a mode that asks, but only on a plain session/new and on _acpmux/set_policy. Several other paths could still leave the chat in a mode that never asks, so the policy held nothing there. This PR closes those paths, at four points:
- Every agent start (
hold_policy_mode, run aftercap_full_accesson a cold or pooled start, and on a Claude Code start, which spawns in the policy's mode when its saved mode doesn't ask). This covers:- a preset's or folder profile's chat (a Codex preset with
approve-readsran inagent); - a profile that pins its mode;
- a handoff target that keeps its harness's mode;
- a resumed chat whose saved mode is full access;
- a forked chat.
- a preset's or folder profile's chat (a Codex preset with
- Every policy change, including acpmux's own.
Hub::set_policynow follows the policy, the same way_acpmux/set_policyalready did.- An aside is forked from its chat, which copies the chat's mode, and is then set to deny-all. So an aside forked from a full-access chat answered its question in full access under deny-all. It now runs read-only on Codex and
defaulton Claude. - A handoff target narrowed to its source policy follows the same way.
- deny-all as the default policy (
_acpmux/set_default_policy deny-all, orpermissionPolicy: deny-allin config.json at startup).- It is a lockdown: every chat without a policy of its own follows it, live or stopped, and new chats start in its mode. The chats follow concurrently.
- The default
askdoes not narrow anything. Chats still start in full access by design (Lawrence 2026-10-14).
- Mode switches. A switch into a mode that does not ask, including a preset's start mode, is refused with
data.reason: "policy_mode"while the chat's harness has a mode that keeps the policy. This coverssession/set_modeand themodeconfig option, from any client. A harness with no such mode (OpenCode, pi) is left as before; it logspolicy_unenforced.
Also:
- The
_acpmux/start_modepreview answers the policy's mode withsource: "policy", for a preset's policy or a deny-all default. - The
set_policyrace from the #23572 review is closed:killmarks the chatclosinguntil its agent starts again (or is readopted).- A chat being closed no longer counts as running just because its host still holds its record while it exits, so a
set_policyin that window saves the mode instead of starting the chat again. - A smaller window is left: a
killthat lands betweenfollow_policy's running check and itsset_modestill starts the chat again. Any client'sset_modehas the same window today.
- A profile that pins its mode in argv is always moved to the policy's mode, because the chat does not record the pinned mode until its first turn. A pinned mode that already asks (Claude
plan, a Codex read-only sandbox) becomes the policy's mode, which also asks. - A chat that is moving when deny-all becomes the default is skipped. It follows the policy when its move ends without happening (an abort, or a begin that fails), before its queued prompts run again.
Changelog
Fixed: a chat with a narrower permission policy now stays in a mode that asks when it is started from a preset, resumed, forked as an aside, handed off, or locked down by a deny-all default policy.
No capture: daemon-only change with no UI.
Testing
Focused command: cmux-tui.yml dispatch, mode=focused.
- Red at a788477a (tests only), run 39651918935, filter
policy:a_preset_chat_with_a_narrower_policy_starts_in_the_mode_that_keeps_itfails attests/start_mode.rs:643: the Codex preset chat with approve-reads runs inagent;a_policy_acpmux_sets_itself_moves_the_chat_out_of_full_accessfails at:659: afterHub::set_policy(deny-all)the chat stays inagent-full-access;deny_all_as_the_default_policy_holds_on_every_chatfails at:678: afterset_default_policy deny-allthe live chat stays inagent-full-access.- Every other
policytest passes.
- Green at d9d0e2ca:
- run 39652497071, filter
policy: 108 passed, 0 failed, the three new tests included; - regressions: run 39652507007
aside9/0, run 39652512044handoff45/0, run 39652517111btw13/0; - run 39652502106
_mode: 34 passed, 1 failed. The failure istransfer_guard::a_chat_with_no_asking_mode_moves_under_peer_control_but_not_under_web, a base red.
- run 39652497071, filter
- Red at a75f9873 (test only), run 39659063257, filter
pinned_claude:a_pinned_claude_chat_with_a_narrower_policy_leaves_full_accessfails attests/start_mode.rs:722. A Claude Code harness that pins--permission-mode bypassPermissionskept it under approve-reads. - Green at 52df0395 (the Claude start path, plus untested changes for preset start modes and readopt; see Not run):
- run 39659067571, filter
policy: 109 passed, 0 failed; - run 39659083826, filter
claude: 168 passed, 0 failed, the new test included; - run 39659072642, filter
_mode: 34 passed, 1 failed, the same base redtransfer_guard::a_chat_with_no_asking_mode_moves_under_peer_control_but_not_under_web. - (run 39659077913, filter
start_gate, selected no tests.)
- run 39659067571, filter
- Red at ec1e9645 (the move-abort test on a75f9873's code: c068c4dd plus 328b8cef's test fix), run 39663666308, filter
deny_all:a_chat_whose_move_aborts_follows_a_deny_all_default_set_meanwhilefails attests/start_mode.rs:762. After the move aborts, the chat is still inagent-full-access. - Green at 328b8cef (the abort follows the policy):
- run 39663672035, filter
deny_all: 12 passed, 0 failed. That includes the move-abort test anddeny_all_in_config_holds_on_every_chat_from_startup(config.json deny-all, thenfinish_startup). - At e9c09c49, the same code as 328b8cef with the test's old transport: run 39662107360, filter
policy, 109/0. Runs 39662112199 (transfer, 22/1) and 39662120630 (move, 43/1) fail only the base redtransfer_guard::a_chat_with_no_asking_mode_moves_under_peer_control_but_not_under_web.
- run 39663672035, filter
- Green at bdc81bdd (a failed begin goes through the same
end_moveas an abort): run 39666228156, filterdeny_all, 12/0; run 39666231887, filterpolicy, 109/0. Run 39666222956, filtertransfer, 22/1: only the base redtransfer_guard::a_chat_with_no_asking_mode_moves_under_peer_control_but_not_under_webfails. lint (linux)is red only on the base clippy redundant clones.
Observed / Not run
- Observed: the runs above.
- With the fake Claude Code, a harness pinned to
bypassPermissionsunder approve-reads runs indefault. - With the fake Codex bridge, a deny-all default moves a live chat and a stopped chat to
read-only. The stopped chat staysread-onlyafter its next prompt starts it again, and a new chat starts there. - The preview answers
read-onlywith sourcepolicy. session/set_modetoagent-full-accessis refused withpolicy_mode.
- With the fake Claude Code, a harness pinned to
- Not run:
ask_asideandhandoff_prepareend to end. Both now go throughHub::set_policy, which the test calls directly.- Preset start modes yielding to the policy in
start_gate(the preset test covers the cold-start hold, not the start-mode refusal), readopt clearingclosing, and an unpinned Claude chat resumed frombypassPermissions. - A move whose
transfer_beginfails (a turn that doesn't stop within 20 s, a bundle error) while deny-all becomes the default. It goes through the sameend_moveas an abort, which the move-abort test covers. - The
closingrace on a hosted chat. I couldn't make the window between a kill and its host's exit deterministic in a test; the fix follows from the code. - A real Codex or Claude Code binary.
- The
_acpmux/start_modepreview for a web Codex chat (a remote chat answers no preview).
Agent session: cosmic-gray
🤖 Generated with Claude Code