acpmux + mobile host: a paired phone gets its own credential, so its Full-access setting (off by default) can cover it
Follows #25038 (acpmux per-device Full-access setting), #25070 and #25069 (Settings > Devices switch for paired client machines).
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".
claude-sr2's build order: a per-device setting, off by default, set from the Mac; revoking it takes effect at once; remote_guard is unchanged for a device without it; a test proves a device without the setting is refused.
Why phones could not have it
Cause: a phone's acpmux traffic reaches the daemon through the Mac's mobile host (cmux-mobile-host) on the shared dashboard token (ConnAuth::ListenerToken, Conn::device = None), so acpmux cannot tell one phone from another. #25038's full_access_device has no device id to read.
The path today:
- The iPhone opens an
acpmuxlink channel to hostd (Worker relay or WebRTC). - hostd's
services.rsacpmux()opens the loopback WebSocket with the dashboard token the app supplies (IPhoneHostService+Wiring.swifthands itconnection.dashboardToken). - hostd's own filter (
acpmux_policy.rs) answers any switch to a mode that does not ask, and any prompt to a chat in one, withphone.mode_not_asking, before acpmux sees it. - acpmux serves what passes as
Origin::Web/ListenerToken.full_access_deviceneedsConnAuth::Deviceplus a device id, so it is alwaysNonefor a phone.
What changed
This PR is the daemon and mobile-host half, Rust only. The Mac app half is a second PR, kept separate to stay under the size limit (see "Next").
acpmux: a phone gets its own pairing record, reusing #25038's mechanism.
- New pairing role
phone. Only the person issues one, with_acpmux/pair_issue {name, role: "phone"}from a connection that has proved it is the person. Otherwisepermission.person_required, so an agent on the Mac cannot hold a phone credential. A ticket never makes one:pair_ticket_newrefuses rolephone. - A phone credential is served as
Origin::WebwithConnAuth::Deviceand its id. Every Web rule stays (controlWeb, asking-mode allow lists, folder roots, trust), andfull_access_devicenow names the phone. So the samepairing.jsonfullAccessflag,_acpmux/pair_set,Pairing::full_accessread on every request,hold_policies_againon off/revoke, andfull_access_holdall apply to phones unchanged. No second policy path. Device::grants_full_accessandpair_seton acceptclientorphone(Role::drives_chats). Read-only and daemon still never grant.- When the app issues a new credential for the same phone (it does each launch and keeps the token in memory only), the old record is removed: its open connections close and the new one inherits the setting. One record per phone, so
pairing.jsondoes not grow. _meta.acpmux.requireFullAccess: a request carrying it is refused (remote.full_access_required) unless the connection's device setting covers it. The mark only narrows.initializereports_meta.acpmux.fullAccessfor a device connection.
cmux-mobile-host: the phone's channel presents its own credential.
- New stdin line
{"type":"phone_credentials","credentials":{"<phone id>":"<token>"}}. The whole set comes each time, and it is never logged. acpmux()connects with that phone's credential when it has one. If the phone has none, or acpmux refuses it, the dashboard token and today's behaviour stay.- On the phone's own credential, a message the phone filter would refuse is forwarded marked
requireFullAccessinstead, and acpmux decides by that phone's setting. Without the setting, acpmux still refuses (remote.full_access_requiredorpolicy_mode). _acpmux/steernames a brief, which acpmux turns into asession/promptto the brief's chat, so the filter cannot read that chat's mode. It is now held to the same rule: refused on the dashboard token, marked on a phone's credential. The iPhone app never sends one. This also closes the same hole on base, where a phone on the dashboard token could steer a brief unchecked.SECURITY.mddescribes the new path.
What it does not widen. Nothing changes for a phone until the person issues it a credential (the next PR) and turns its switch on. With the setting off, a phone credential is a Web connection with the shared token's rules, plus the mark that only refuses. Deny-all still holds a chat, and a chat started on the Mac with policy ask keeps its mode.
Tests (red first)
Red commit 3edc50fec28 adds tests only. On base, pair_issue refuses role phone ("role must be daemon, client or read-only"), so both new integration tests fail. Fleet run 40166087896: 9 passed, 2 failed.
a_paired_phone_is_refused_full_access_without_its_setting_and_allowed_with_it:- a phone credential is served
remote, authdevice,fullAccess: false; - setting off:
set_mode agent-full-accessis refused (policy_mode); - setting off: a marked prompt to a Mac-started chat in a non-asking mode is refused for the phone and for the shared token (
remote.full_access_required); - setting on: the marked switch and marked prompts pass;
- a second phone without the setting is refused on its own chat, on the marked prompt, and on the first phone's Full-access chat (
remote.full_access_device).
- a phone credential is served
only_the_person_issues_a_phone_credential_and_a_new_one_ends_the_old:- a non-person unix client gets
person_required; - there is no ticket for role phone;
- a new credential ends the old one, carries the setting over, and refuses the old id;
- a client machine's name is never taken over (
name_taken).
- a non-person unix client gets
- The first test also covers a marked
_acpmux/steer: refused for the shared token and for a phone with the setting off, and allowed past the gate with it on. - Unit tests:
pairing::tests::a_new_phone_credential_ends_the_old_one_and_keeps_its_setting, cmux-mobile-hostacpmux_policy::a_refused_request_is_marked_for_acpmux_to_decide, andcontrol::phone_credentials_replace_the_set_and_never_print.
Fleet (cmux-ci test) at 2c0ab22: cmux-mobile-host lib 179/179, clippy clean, fmt clean for the changed files; acpmux remote_guard_codex_bridge 11/11, fmt clean (run in green2). Earlier, at 8ce2175: acpmux cargo fmt --check and clippy --all-targets -D warnings are clean, and remote_guard_codex_bridge 11/11, pairing 6/6 and lib pairing 19/19 pass (run 40166300670). cmux-mobile-host: 179 + 18 + 9 tests pass and clippy is clean (run 40166728877). Its cargo fmt --check is clean for the files this PR changes; chief_policy.rs diffs the same way on base.
Review
Codex gpt-6-astra xhigh, asked to attack the gate: who can issue or turn on a phone credential, whether a phone with the setting off gets exactly the shared token's Web rules, abuse or bypass of the mark, the replacement and revocation races, credential logging, and the dashboard fallback.
-
Round 1, BLOCK at 64c41feb:
_acpmux/steerbypasses the phone filter, because it becomes asession/promptto the brief's chat, whose mode hostd cannot read.
Fixed in 2c0ab22: steer is held to the Full-access rule, with tests.
-
Round 2, SHIP, no findings: "Batches, peer forwarding, fallback to the shared token, replacement, revocation, and immediate setting changes remain fail-closed... Handoff remains a pre-existing, unchanged path and is not a blocker for this change." Reviewed head: 2c0ab22eb246515e39fe9bcb35c911a80499e5c0.
lawrencecchen is requested as a reviewer; landing does not wait on it.
Next (separate PR)
The Mac app issues each paired phone's credential over the approver sync's proven person line and sends phone_credentials to hostd. Settings > Devices shows the same Full access switch on phone rows, calling pair_set with the phone's record. Removing a phone revokes its record. The iPhone's mode picker can then offer non-asking modes when initialize says fullAccess.
Stop conditions
No new timer, observer or loop. The phone credential map is replaced whole on each stdin line.
No capture: daemon and mobile host only. The Settings switch for phones comes in the Mac PR, with its capture.
Agent session: big-red
š¤ Generated with Claude Code