acpmux: a paired device's Deny (and the person's proved Allow) counts under approve-reads; no Allow drawn that cannot count

Merged
TE
10d ago
+737-76

Adopted and rebased (2026-10-25)

The lane that opened this is gone. Its change (e01aa4ef91b..cdfb86c5a39) is squashed onto feat-cmux-next, now ec66898145b. Conflicts: webviews/test/permission-ask-row.test.tsx (this PR's test now comes after base's newer grouped-row tests); hub/permissions.rs, where base's respond_permission_annotated (question annotations, #24356) now holds this PR's Web answer check in place of the plain web_control_check; and server/peer_forward.rs, which keeps base's attach presence beside this PR's allowRefused. The commits added on top are tests and rustfmt only. The daemon and pane code is the original lane's.

Rebased again onto a8d57c9af88, over #25424, which lets a question answered in the pane count on another machine under the person's proof. There was one conflict, in respond_permission_annotated:

  • Base's needs_proof and its annotations refusal are kept.
  • The proof check goes through this PR's answer_proof. It now takes the annotations and passes the answers and annotations to decision_proves, as base does.
  • A phone's signed allow still counts only with no answeredBy and no annotations.
  • The early Web check passes the same annotations.

What changes, plainly: a paired device (a Web connection: the iPhone, the dashboard, a peer that serves this Mac as Web) can now:

  • Deny a prompt of a session whose mode or policy does not ask, such as approve-reads. Before, every answer was refused.
  • Allow such a prompt once, but only with the person's proof or the person's enrolled phone's signature for that exact prompt.

An Allow with neither is refused exactly as before. The pane no longer draws an Allow that can't count.

Landing terms (2026-10-25), and where each holds

  1. Replay binding.
    • What is signed: the phone's ECDSA P-256 signature (hub/approvers.rs message) covers the daemon run (personInstance), the Mac id, the approver, the session id, the permission id, the option (the decision), the prompt's one-time decisionNonce, expiresAt (at most 300 s ahead) and a digest of any answers. The person's HMAC proof (person.rs decision_proof) covers the run, session, permission id, option and nonce, and since #25424 any answers and annotations.
    • No tool-call id: the signed fields do not name the tool-call id. The tool call is bound through the permission id. The daemon mints one (Uuid::now_v7) for each session/request_permission it receives, and each request carries exactly one toolCall. A re-ask is a new request with a new id.
    • After a restart: the next daemon adopts the agent's still-open request and keeps that same id for that same request (reregister_permission). It gives the request a new nonce and a new run id, so a proof from before the restart counts nothing.
    • Single use: the answer that counts spends the nonce under the prompt's lock (spend_decision), and a second use is refused.
    • Later option: signing the toolCallId itself, or a digest of the tool call's input, would be a later cnx-ios + acpmux change. This PR doesn't need it.
  2. A one-time allow only.
    • A Web answer to an allow_always option is refused (remote.lasting_grant_refused), signed or not.
    • A group's allow_chat from the Web is refused.
    • A proved Allow changes no mode and no policy.
  3. An unsigned Web Allow is still refused under approve-reads (remote.policy_not_asking). A Deny from any client counts.
  4. Unpairing the phone stops its Allows at once. verifies looks the approver up when the answer arrives, so a signature made before _acpmux/approvers_set removed the phone counts nothing.
    • The revocation race (review round 2): a revocation could land between the signature check and the nonce spend, so a signature checked just before the phone was removed still counted. ApproverSet::verifies_then now holds the approvers lock through the ECDSA check and the commit, and Person::approver_spends spends the nonce inside it. A revocation either lands first, and the signature counts nothing, or waits until the decision is spent.
    • Lock order (in a comment on verifies_then): the prompt's lock first, then the approvers lock, then the spent-nonce store. Nothing awaits while either lock is held; both are std mutexes taken and released inside one synchronous call.
    • Test: unit a_revocation_during_a_commit_waits_for_it_and_then_the_signature_counts_nothing shows the race itself. A commit blocks inside verifies_then while another thread revokes the phone; the revocation waits for the commit, and the same signature then counts nothing. It is red at 0159569 (the test with today's semantics, run 40359378490: the revocation went through mid-commit) and green at 2e7593c (run 40359378532). 7a718b7 is rustfmt only (fmt and clippy clean, run 40360085403).
  5. The signing key lives only on the phone. _acpmux/approvers_set takes only the 65-byte public key. The set lives in the daemon's memory, never in a file, and only the person's app may set or read it (person_required). So another agent on the Mac can't sign as the phone. The person's HMAC key stays in the app's and the daemon's memory (the keyed daemon is not dumpable).
    • An existing base hole, fixed by #25535: the app restores the approver set at each launch from paired-devices.json, which another process of the same user could rewrite with a key of its own. That hole is in base's app code (#25088), not in this PR. The review asked whether this PR makes it worse. The answer was yes for Web exposure: a key planted that way now also counts for a paired device's Allow. But the same attacker already had equal or broader approval power through the local socket on base. #25535 makes the file count only when it is the list the app saved last (a digest in the app's data-protection Keychain), so a rewritten or restored list enrolls no approver. It landed first, as 06d4a464f176819dc430a8b5f70a6810ecb1fabf.

Tests:

  • tests/web_mode_current.rs a_phones_signed_allow_from_a_paired_device_counts_once_never_lasts_and_stops_when_unpaired, under approve-reads from a Web connection, for points 1-4:
    • a signed allow_always is refused;
    • an expired signature is refused;
    • a signed allow counts once and is refused when sent again;
    • the previous prompt's signature is refused on the next prompt, with either nonce;
    • an unsigned allow is still refused as approve-reads, so the policy is unchanged;
    • a signature made before the phone was unpaired is refused;
    • a deny counts.
  • a_proved_allow_from_a_paired_device_changes_no_mode (point 2): a session whose mode does not ask. The person's proved allow counts once, the mode stays normal, and the next unproved Web allow is still refused.
  • The existing test now also checks that the person's proof counts once.
  • tests/durable_agents.rs an_adopted_prompt_gets_a_new_decision_nonce_that_a_relayed_allow_can_use (on base) covers replay after a restart: the same id comes back with a new nonce and run, the old proof is refused, and the new one counts.
  • tests/person_allow.rs a_phones_signed_allow_counts_once_and_stops_when_revoked (on base) covers another option, Mac, run or key over the unix socket.
  • These checks are enforced by the PR's code, so the new tests pass at the commit that adds them. At base every Web allow under approve-reads was refused, so their "counts once" steps fail there.

Runs:

  • At the head 7a718b7b38: fmt and clippy are clean (run 40360085403). At 2e7593c (the same code before rustfmt; run 40359378532), these pass: approvers 5/5, durable_agents 53, peer_integration 44, person_allow 25, web_mode_current 13, web_mode_drift 3, and hub_integration permission 25.
  • At cb1878d452d, before the race fix (run 40351450169), the whole acpmux suite passed. Lib was 1099/1100.
  • Reds that fail the same way on base:
    • lib agent_tools::tests::own_servers_run_under_the_daemon_prefix (run 40294994012);
    • hermetic_daemon_guard's two tests, which flag base's new tests/linux_sandbox_init.rs (base run 40353228261).
  • Webview at the head (run 40351450087): the protocol codegen writes no change, permission-ask-row and permission-groups-switch-keeps-last pass 7/7, and tsc is clean.
  • Before the second rebase, at 0d3bec58891: runs 40294544823, 40294993663 and 40294545098. Before the first: runs 40267204426 and 40265977717.

Review: a fresh Codex gpt-6-astra xhigh review at 7a718b7b3831f164f923de82ec3f0142b592a778 found landing terms 1, 1(a), 1(b), 1(c), 2, 3 and 4 hold, and said SHIP for this PR's own scope, with term 5's base hole set aside for #25535 (above). After the seal landed, the branch took a merge of feat-cmux-next (no conflicts), and a fresh Codex gpt-6-astra xhigh review of the merged head b04671c96a133f5153c9a0919bc8538046da2d13 found terms 1 to 5 hold and said SHIP. At that head: cargo fmt and clippy -D warnings are clean; lib 1236/1237 (the one failure, own_servers_run_under_the_daemon_prefix, fails on base too); web_mode_current, web_mode_drift, peer_integration, person_allow, full_access_managed and hub_integration pass; the webview permission suites pass 9/9 and tsc is clean.

UI: the gallery's new Panel variant allow-refused shows the pane state: Deny only, plus where to approve. For the before/after, see the PR's gallery-advisory run.

No capture: the only native-mapped file is the generated protocol types.ts (a new optional field); the visible change is the webview permission panel, shown by the gallery variant above.

Overlap with the #23027 recut (same file, webviews/.../permissions/client.ts): this PR's hunks there only carry allowRefused in the per-chat lastRead cache and in the refresh result. They neither read nor set loading, ready or error, and the reselect restore keeps base's loading: false and error: undefined, so they do not depend on the disconnected-error or loading state that recut changes. Whichever lands second rebases.

Original description

Leo, 2026-10-21: "this literally doesn't work".

What broke

Leo had a chat open that runs on his peer machine air-blue, under the approve-reads policy. The grouped ask showed Allow / Always / Deny. Every click came back with this error: "this session's permission settings do not ask before it acts (policy approve-reads); a paired device cannot prompt it or answer its permissions until the local user sets ask or deny-all". The chat stayed stuck.

Why it refused:

  • air-blue's acpmux served this Mac's daemon as a Web connection, the way it serves a paired device. #24105 fixes one way that happens: a reconnect before the remote writes its peer token.
  • web_control_verdict holds every Web permission answer to the asking rule, Deny included. A session whose policy doesn't ask therefore refuses every answer.
  • Even served as a Peer, a group allow sent through a peer link never counts. The peer's person rule (hub/person.rs) allows only from its own app, and a forwarding daemon is never that app. Allow and Always were drawn anyway, and those buttons could never work.

The fix

  1. A Deny always goes through. A Web answer that only narrows (a reject option, a group deny, or a cancel with no option) is no longer held to the asking rule (Hub::web_answer_check). It can only stop a tool call, and the person rule already lets any client deny.
  2. The person's own click goes through under approve-reads. This covers a Web allow that carries the person's decision proof (personDecision) for that exact pending prompt. It also covers an allow signed by an enrolled approver, such as the person's phone. Such an allow no longer gets the mode, policy and lasting-grant refusals. Nothing else changes:
    • The proof is checked again and its nonce is spent under the prompt's lock, as before.
    • A Web answer still allows once only.
    • A Claude session the Mac started (D13) and an agent outside the sandbox stay refused.
  3. No Allow is drawn that can't count.
    • _acpmux/permission_groups now returns allowRefused: "permission.person_required" to a reader that is not the person.
    • A daemon forwarding a peer chat's read always sets it, whatever the peer's build, because a forwarded group allow never counts there.
    • The pane then shows only Deny (which works) plus #23890's sentence: "Only the cmux app can approve this. Approve it in cmux on the Mac that runs this chat."
    • The allow shortcuts do nothing in that state.
    • No new strings.

Security reasoning

  • The asking rule exists so that a paired device, which holds only the dashboard token, can't steer a session that acts without asking.
  • A deny makes the session do less, so it needs no gate.
  • An allow with a decision proof is HMAC'd with the person key of that daemon run. An approver allow is signed by an enrolled approver. Either way the proof binds the exact session, prompt, option and one-time nonce. So it is the person's own click for one request: the same answer the person could give at that machine. The asking rule adds nothing there.
  • Nothing lets a non-person client allow anything it couldn't before. An allow with no proof keeps today's refusal: remote.policy_not_asking from a Web client, permission.person_required from a peer.

Still open

From Leo's Mac, an Allow for a chat on a peer Mac still can't count. That peer's acpmux only trusts its own app's key, and nothing proves this Mac's click to it. That needs the device-bound proof that the peer test names (cx-aocz). It is a new cross-machine trust grant, so it isn't built here. Until then the pane says where to approve and offers the Deny that works, instead of dead buttons. The single-ask card (PermissionCard) still learns this only after a click, through its "Remote approvals are off" notice. That is not changed here.

Tests

  • tests/web_mode_current.rs a_paired_device_answers_an_approve_reads_prompt_with_a_deny_or_the_persons_proof. On one daemon with an approve-reads session and a Web (paired device) connection:
    • the Web read says allowRefused and the person's read doesn't;
    • an allow with no proof is still refused with remote.policy_not_asking;
    • a deny counts (the agent hears no);
    • an allow with the person's decision proof counts (the agent hears yes).
  • tests/peer_integration.rs a_peer_chats_deny_goes_through_under_approve_reads_and_its_read_refuses_allows reproduces Leo's setup:
    • daemon B runs under approve-reads and serves A as Web;
    • A's app reads B's group through the peer and gets allowRefused;
    • A's group allow is refused;
    • A's group deny now goes through and B's agent hears it.
  • tests/web_mode_current.rs and tests/web_mode_drift.rs previously asserted that a Web group deny is refused. They now assert that no asking rule holds it.
  • Webviews:
    • permission-ask-row.test.tsx: an ask with allowRefused offers only Deny, says where to approve, and ignores the allow shortcuts.
    • permission-groups-switch-keeps-last.test.ts: the refused allows stay with their chat across a switch.

Red on the test-only commit and green at the head on the fleet.

Original lane: cosmic-gray

Agent session: agent session
šŸ¤– Generated with Claude Code