acpmux + mobile host: a paired phone gets its own credential, so its Full-access setting (off by default) can cover it

Merged
TE
12d ago
+433-34

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:

  1. The iPhone opens an acpmux link channel to hostd (Worker relay or WebRTC).
  2. hostd's services.rs acpmux() opens the loopback WebSocket with the dashboard token the app supplies (IPhoneHostService+Wiring.swift hands it connection.dashboardToken).
  3. 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, with phone.mode_not_asking, before acpmux sees it.
  4. acpmux serves what passes as Origin::Web / ListenerToken. full_access_device needs ConnAuth::Device plus a device id, so it is always None for 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. Otherwise permission.person_required, so an agent on the Mac cannot hold a phone credential. A ticket never makes one: pair_ticket_new refuses role phone.
  • A phone credential is served as Origin::Web with ConnAuth::Device and its id. Every Web rule stays (control Web, asking-mode allow lists, folder roots, trust), and full_access_device now names the phone. So the same pairing.json fullAccess flag, _acpmux/pair_set, Pairing::full_access read on every request, hold_policies_again on off/revoke, and full_access_hold all apply to phones unchanged. No second policy path.
  • Device::grants_full_access and pair_set on accept client or phone (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.json does 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.
  • initialize reports _meta.acpmux.fullAccess for 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 requireFullAccess instead, and acpmux decides by that phone's setting. Without the setting, acpmux still refuses (remote.full_access_required or policy_mode).
  • _acpmux/steer names a brief, which acpmux turns into a session/prompt to 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.md describes 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, auth device, fullAccess: false;
    • setting off: set_mode agent-full-access is 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).
  • 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).
  • 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-host acpmux_policy::a_refused_request_is_marked_for_acpmux_to_decide, and control::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:

    1. _acpmux/steer bypasses the phone filter, because it becomes a session/prompt to 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