acpmux: a paired machine's Full-access setting (off by default) lets it drive a remote chat in Full access
The ask
Leo, relayed by claude-sr2: "#2 seems reasonable i think... it's a gate that you can enable for your phone, which is an attack vector you opt in to".
The brief, word for word: "Build it: a per-device setting (off by default) that lets a paired phone drive a Full-access chat. Turning it on is an explicit choice on the Mac, per device, and shows what it allows. Revoking it takes effect immediately. Without the setting, remote_guard stays as it is. Update tests/remote_guard_codex_bridge.rs so the 2 base-red tests cover both states (setting off refuses, setting on allows), and add a test that a device without the setting is still refused."
Lawrence, 2026-10-13: "remote connection must be authed, and should be able to pretty much use yolo mode".
Why the two tests are red on base
remote_guard gives every chat a remote connection starts its own policy ask. Since #23572/#23639 a chat's own narrower policy holds its mode for every client (hub/policy_mode.rs refuse_wider_mode, reason: policy_mode). A Web client cannot set approve-all either, and a chat on approve-all is not Web-controllable (remote.policy_not_asking). So no Web client can put the Codex bridge into agent-full-access any more. any_mode_for_web still says yes, but the policy hold says no.
How a phone connects today, and what the gate keys on
- Phones do not carry a per-device identity to acpmux today. The iPhone talks to the Mac's
cmux-mobile-host(hostd) over the Worker relay or WebRTC. hostd splices the phone'sacpmuxchannel into the loopback listener with the shared dashboard token (IPhoneHostService+Wiring.swifthandsconnection.dashboardTokento hostd). acpmux serves that asConnAuth::ListenerToken, the same for every phone, so a per-phone setting has nothing to attach to on that path. - The gate therefore only ever applies to a paired machine's own credential (
ConnAuth::Device, aclientpairing from_acpmux/pair_redeem/pair_issue), whose id the handshake proves (server/pairing.rsadmit). The connection now carries that id (Conn::device), set only by the transport, never from a request.- The shared dashboard token, a peer token, LocalApp and a read-only machine (
ReadOnlyDevice) never get it. - It covers only that machine's requests held to the Web's rules, i.e. forwarded for its own Web client (
via: web). Its ownControl::Peerrequests keep the person rule.
- The shared dashboard token, a peer token, LocalApp and a read-only machine (
- Follow-up, not in this PR: for a phone to use it, hostd would present that phone's own
clientcredential (marking its requestsvia: web) instead of the dashboard token.
What changed
- The setting lives on the paired machine's record (
pairing.jsondevices[].fullAccess). It is off by default; a record saved before this reads as off. Only a liveclientmachine can have it (Device::grants_full_access), so revoking the machine ends it. _acpmux/pair_set {id, fullAccess}is served on the unix socket only.- On needs the person (
hub/person.rs). Otherwise an agent on this Mac could pair a machine itself and grant it. - Off works from any unix socket client, since narrowing is always allowed.
- The reply's
allowssays what it lets the machine do: "lets NAME run chats in Full access: the agent edits files and runs commands on this Mac without asking". pair_listshowsfull_access.
- On needs the person (
- CLI:
cmux host ticket full-access ID on|off;ticket lsshows "Full access on". On from the CLI getsperson_required, pointing to the app. - What it allows, and nothing more: a
session/set_mode/session/set_config_optionfrom a covered request may leave the mode that the policy of a chat a remote connection started holds (refuse_wider_mode_as).- Deny-all (the chat's own, or the default's lockdown) still holds.
- A chat started on this Mac keeps its policy's mode.
- Every other remote_guard rule is unchanged: modes per family (only the bridge takes any mode), policies, rules, mcpServers, env, paths, trust.set, process, btw, peers, file_put, media, workflows.
sessions.allowFullAccessoff still refuses every full-access switch.
- A chat in that state is controlled only by a request the setting covers now. The remote guard holds
session/prompt,session/cancel,_acpmux/queue_edit,_acpmux/queue_removeand permission answers to that machine;queue_editandqueue_removecheck again right before they change the queue. - Details:
Hub::full_access_holdchecks prompt dispatch, steers and queued prompts; the prompt's machine travels inPromptOptions/PromptRun.full_access_device. The shared token and other machines getremote.full_access_device. An edit keeps a queued prompt's machine only if the same machine makes it, and a prompt restored after a restart names none. - Revoking takes effect at once:
- The setting is read from the store on every request (
Pairing::full_access). - Turning it off, or
pair_revokeof the machine, runsHub::hold_policies_againbefore the Mac's request is answered. Every remote-started chat whose mode its narrower policy no longer holds moves back to its policy's mode (follow_policy), and its running turn is cancelled. - A switch that raced with the revoke reads the setting again after it lands; if the setting is off by then, the chat is put back and the request fails (
full_access_ended). - A running Web turn on such a chat is floor-cancelled (
remote_floor_cancel) before the chat moves back, and a turn checks the setting again just before its prompt goes out, so a prompt past its dispatch check is not sent after a revoke. The remaining window is the remote floor's existing one: its cancel reaches the agent after the prompt. - Daemon restart: which machine moved a chat is not kept, so at start, after the agent hosts are adopted, every such chat goes back to its policy's mode, and an adopted Web turn in such a mode is floor-cancelled. This fails closed; a still-granted machine switches again.
- A revoked machine's WebSocket connections still close as before.
- The setting is read from the store on every request (
Tests (red first)
Red commit 45477fecae5 (tests plus scaffolding, no gate): the four "setting on allows" assertions fail with policy_mode, and the refusal states pass. Green at the head.
a_paired_device_drives_the_codex_bridge_in_full_access_only_with_its_setting(wasan_authenticated_web_connection_drives_the_codex_bridge_in_full_access):- the shared token is refused;
- a paired machine with the setting off is refused (mode and config option);
- the person turns it on:
allowsandpair_listshow it, and the same connection's next requests run every bridge mode with a prompt each, plus the config option.
the_unix_socket_and_a_peers_local_client_still_need_the_person:- unix socket:
person_required; - peer token, without via web:
person_required; - peer token, via web: refused (it names no machine);
- paired machine via web, setting off: refused;
- paired machine with the setting, for its own unix client:
person_required; - paired machine via web, setting on: allowed.
- unix socket:
- New,
a_paired_device_without_the_setting_stays_refused_while_one_with_it_is_allowed:- machine B without the setting is refused on its own chat and on A's;
- A is allowed on both;
- deny-all still holds;
- a chat started on the Mac with policy ask still holds.
- New,
turning_the_setting_off_or_revoking_the_machine_counts_for_its_next_request:- off from a plain unix client puts the chat back to
ask(held_again: 1), and the same connection's next switch is refused; - on again, then
pair_revoke: the chat is back and the next switch is refused, with no reconnect.
- off from a plain unix client puts the chat back to
- In the two-machine test: the shared token and the machine without the setting cannot prompt a chat in Full access by the setting (
remote.full_access_device); the machine with it can. After off, the machine prompts its chat again inask. - New,
only_the_person_turns_the_setting_on_for_a_client_machine:- a non-person unix client gets
person_required; - Web and peer connections get "local socket only";
- a read-only machine gets
full_access_role; - an unknown id gets
not_found.
- a non-person unix client gets
- Unit (
pairing/tests.rs): off by default, old records read as off, off is not serialized, revoked has none, and read-only and daemon roles never grant, hand-edited ones included.
Review
Codex gpt-6-astra xhigh, asked to attack the gate: spoofing a device id, the shared token, a peer forwarding via: web, ReadOnlyDevice, and races with revoke.
-
Round 1, BLOCK:
- the shared token could still cancel, or rewrite the queue of, a chat in Full access by the setting;
- a turn past its dispatch check could send its prompt after a revoke;
- the machine was dropped on send-now steers and dispatch prompts.
All fixed (commit "review: only the granted machine changes a Full-access chat's turn and queue"), with the shared-token cancel and queue assertions in the two-machine test.
-
Round 2, BLOCK:
- a durable Web turn adopted after a restart was not checked against the current grant;
queue_removehad no check after its log wait.
Both fixed.
-
Round 3: SHIP. "No remaining gate bypass was found across identity, forwarding, roles, propagation, permissions, or queue paths." Reviewed head: 1ad49da4a564d9c57f8c96125331c68c288158d6.
The SHIP receipt for the head is posted as a PR comment.
Lawrence (who built the original yolo mode) is requested as a reviewer; landing does not wait on it.
Fleet (cmux-ci test) at the head:
cargo fmt --checkandcargo clippy -p acpmux --all-targetsare clean.- The full acpmux suite is red only on
protocol_ir_is_current,protocol_registry_names_every_served_method,daemon_chat_openandsession_pool::pooled_sessions_end_with_the_daemon_and_after_a_crash, all red the same way on base de9989a868d.
Size: about 900 changed lines, a bit over the usual 800. About 330 are tests, and the review rounds added the queue, restart and race holds. Splitting the gate from its holds would land an unsafe middle state.
Stop conditions
No new timer, observer or loop. hold_policies_again is a one-shot pass over the sessions. It runs when a setting is turned off, when a machine is revoked, and once at daemon start, and it ends once every chat it found has been moved.
Known, unchanged by this PR
On base, an authenticated Web connection on the shared token may already prompt (and switch the mode of) a Codex bridge chat that has no policy of its own, e.g. one started on the Mac, which starts in Full access by default (any_mode_for_web, Lawrence's yolo). This PR does not change that ("Without the setting, remote_guard stays as it is").
No capture: daemon and CLI only; the Settings > Devices toggle is a separate PR.
Agent session: big-red
š¤ Generated with Claude Code