acpmux: a narrower policy holds on presets, resume, asides, handoffs and a deny-all default

Merged
TE
16d ago
+449-58

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 after cap_full_access on 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-reads ran in agent);
    • 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.
  • Every policy change, including acpmux's own.
    • Hub::set_policy now follows the policy, the same way _acpmux/set_policy already 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 default on 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, or permissionPolicy: deny-all in 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 ask does 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 covers session/set_mode and the mode config option, from any client. A harness with no such mode (OpenCode, pi) is left as before; it logs policy_unenforced.

Also:

  • The _acpmux/start_mode preview answers the policy's mode with source: "policy", for a preset's policy or a deny-all default.
  • The set_policy race from the #23572 review is closed:
    • kill marks the chat closing until 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_policy in that window saves the mode instead of starting the chat again.
    • A smaller window is left: a kill that lands between follow_policy's running check and its set_mode still starts the chat again. Any client's set_mode has 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_it fails at tests/start_mode.rs:643: the Codex preset chat with approve-reads runs in agent;
    • a_policy_acpmux_sets_itself_moves_the_chat_out_of_full_access fails at :659: after Hub::set_policy(deny-all) the chat stays in agent-full-access;
    • deny_all_as_the_default_policy_holds_on_every_chat fails at :678: after set_default_policy deny-all the live chat stays in agent-full-access.
    • Every other policy test passes.
  • Green at d9d0e2ca:
    • run 39652497071, filter policy: 108 passed, 0 failed, the three new tests included;
    • regressions: run 39652507007 aside 9/0, run 39652512044 handoff 45/0, run 39652517111 btw 13/0;
    • run 39652502106 _mode: 34 passed, 1 failed. The failure is transfer_guard::a_chat_with_no_asking_mode_moves_under_peer_control_but_not_under_web, a base red.
  • Red at a75f9873 (test only), run 39659063257, filter pinned_claude: a_pinned_claude_chat_with_a_narrower_policy_leaves_full_access fails at tests/start_mode.rs:722. A Claude Code harness that pins --permission-mode bypassPermissions kept 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 red transfer_guard::a_chat_with_no_asking_mode_moves_under_peer_control_but_not_under_web.
    • (run 39659077913, filter start_gate, selected no tests.)
  • 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_meanwhile fails at tests/start_mode.rs:762. After the move aborts, the chat is still in agent-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 and deny_all_in_config_holds_on_every_chat_from_startup (config.json deny-all, then finish_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 red transfer_guard::a_chat_with_no_asking_mode_moves_under_peer_control_but_not_under_web.
  • Green at bdc81bdd (a failed begin goes through the same end_move as an abort): run 39666228156, filter deny_all, 12/0; run 39666231887, filter policy, 109/0. Run 39666222956, filter transfer, 22/1: only the base red transfer_guard::a_chat_with_no_asking_mode_moves_under_peer_control_but_not_under_web fails.
  • 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 bypassPermissions under approve-reads runs in default.
    • With the fake Codex bridge, a deny-all default moves a live chat and a stopped chat to read-only. The stopped chat stays read-only after its next prompt starts it again, and a new chat starts there.
    • The preview answers read-only with source policy.
    • session/set_mode to agent-full-access is refused with policy_mode.
  • Not run:
    • ask_aside and handoff_prepare end to end. Both now go through Hub::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 clearing closing, and an unpinned Claude chat resumed from bypassPermissions.
    • A move whose transfer_begin fails (a turn that doesn't stop within 20 s, a bundle error) while deny-all becomes the default. It goes through the same end_move as an abort, which the move-abort test covers.
    • The closing race 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_mode preview for a web Codex chat (a remote chat answers no preview).

Agent session: cosmic-gray

🤖 Generated with Claude Code