acpmux: a narrower permission policy starts Codex and Claude in a mode that asks

Merged
TE
16d ago
+387-4

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 policyCodex mode (approval, sandbox)Claude Code mode
ask, approve-reads, approve-editsask (untrusted, workspace-write): Codex asks before every command that is not a known-safe read and before every edit, and the policy answers each onedefault
deny-allread-only (on-request, read-only): the sandbox refuses every write, and the policy refuses whatever Codex asks fordefault
approve-allthe start level, unchangedthe 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_it fails at tests/start_mode.rs:544 (a Codex approve-reads chat started in agent-full-access);
    • a_narrower_policy_on_a_live_chat_leaves_full_access fails at :574.
  • Green at 3c4aae8b (run 39633079154, filter policy): every test with policy in its name passes, including both new ones and the unit tests each_policy_maps_onto_a_codex_mode_that_keeps_it and claude_asks_in_default_and_opencode_and_a_modeless_harness_cannot_keep_a_policy.

  • Regression (run 39633091918, filter _mode): the only failure is transfer_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_agent fails at tests/start_mode.rs:608 (setting the policy started the agent).

  • Green at 1aa82c5b (run 39639543468, Linux test job 119359274132, filter policy): every policy test passes, the stopped-chat one included.

  • Restart after the stopped-chat fix:

    • red at 02cd5fd0 (test only, run 39641885830): the stopped-chat test, now prompting after set_policy, fails at tests/start_mode.rs:619; the Codex chat came back in agent-full-access;
    • green at 437f0021 (run 39641891463, filter policy): all pass.
  • 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_yet fails at tests/durable_agents.rs:1683: the hosted agent was never sent set_permission_mode;
    • green at cd4ffe71 (run 39644412674, filter policy): 105 passed, 0 failed, the new test included.

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 in read-only, approve-all in agent-full-access. Claude starts in default for all four narrower policies and in bypassPermissions for 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 to read-only, then approve-all leaves it in read-only. Claude plan with ask stays in plan.
  • 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 in cmux-tui-core/src/server/rows_tests.rs (redundant clone) before it reaches acpmux.

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-all isn't applied to the start mode;
  • the start_mode_answer preview 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_policy right 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