Skip to content

acpmux: a peer counts an enrolled Mac's signed one-time Allow (peer-route approvals 1/4) - #26131

Merged
teamleaderleo merged 1 commit into
feat-cmux-nextfrom
lane/hb-peer-approvals-daemon
Oct 27, 2026
Merged

teamleaderleo merged 1 commit into
feat-cmux-nextfrom
lane/hb-peer-approvals-daemon

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Peer-route approvals, part 1 of 3: the daemon side.

The problem. A chat opened through a peer (acpmux new --host, cmux agent open-tab --host, --group with --host) shows its tool prompts on the Mac, but the Mac can never approve one from the card. The Allow reaches the peer through the Mac's own daemon, the peer's person gate wants the person's proof, and the app has no key bound for that machine. The card says "Remote approvals are off" and offers no way to turn them on.

The fix. An enrolled Mac's own P-256 key signs a one-time Allow, and the peer checks the signature against the key it has on file. The private key never leaves the Mac. Only the signed answer crosses the peer link.

This PR adds the peer's half:

  • hub/mac_approvers.rs reads the Macs that may allow from ACPMUX_HOME/mac-approvers.json, in the form {"macs":[{id, publicKey, name}]}.
    • No RPC writes that file. acpmux host approvals add writes it (part 2), so an agent that can only reach the daemon's socket cannot enroll a key.
    • The daemon reads it as private state: a symlink, or a file that others may write, enrolls no Mac.
  • The Mac's permission_respond carries _meta.acpmux = {decisionNonce, approverDecision: {approver, expiresAt, signature}}. This is the phone approvers' shape, under a domain of its own: acpmux-mac-approver-decision-v1.
  • The person gate's approver path checks the phone approvers or an enrolled Mac. The nonce is spent inside the Mac set's lock.
  • _acpmux/mac_approver_set {id, publicKey, name}: the app gives the daemon this Mac's own public key. Only the person may call it, and the daemon keeps the key in memory only. host enroll (part 2) reads it.
  • _acpmux/mac_approvers {reload?}: lists the enrolled Macs and this Mac's own key. It is refused for Web and peer origins.
  • The new methods are in spec/acpmux-protocol.json and the generated methods.ts.

Part 2 adds acpmux host approvals and the step in host enroll that enrolls the Mac's key. Part 3 makes the app sign and adds the card hint.

The five conditions from the brief

  1. The proof binds session, prompt, tool-call id and decision, with a short expiry, and is single-use. The Mac signs this message: the domain, then, each after a NUL, the daemon run (personInstance), the Mac id, the session, the permission id, toolCall.toolCallId, the option, the prompt's decisionNonce, expiresAt, and the digest of any answers.
    • expiresAt must fall between now and now + 300 s.
    • The nonce is spent in the person gate's spent-nonce store, inside commit.
    • Tests: a_macs_signature_counts_for_its_one_time_allow_and_every_change_fails changes each bound part in turn. In the integration test, a replay of the same signature on the next prompt is refused.
  2. A one-time allow only, never upgraded to always or session, and the mode never changes. may_allow accepts only an option offered exactly once whose kind is allow_once, on a plain tool ask or an agent question.
    • It never accepts switch_mode, a folder-trust card, or ExitPlanMode.
    • Tests: a_mac_never_allows_a_plan_a_mode_switch_or_a_folder_trust, plus the signed always case refused in the test above. The integration test checks that the peer's policy and modes are unchanged afterwards.
  3. An unsigned Allow is still refused, and a Deny from any client still counts. The person gate is unchanged for answers without a proof. In the integration test, an unsigned Allow from the person on A is refused, and a deny relayed from A counts.
  4. Unenrolling the Mac's key stops its Allows at once. host approvals rm rewrites the file, then calls _acpmux/mac_approvers {reload:true}.
    • The reload takes the set's lock, so it returns only after any answer being committed finishes. Every check after that refuses.
    • A hand edit counts on the next check, through the file's (len, mtime) stamp.
    • Tests: removing_a_mac_from_the_file_stops_its_signatures, a_reload_during_a_commit_waits_for_it_and_then_the_signature_counts_nothing, and the last leg of the integration test.
  5. The key stays where another agent on the Mac can't sign with it. Nothing in this PR holds a private key. The daemon sees only public keys. Part 3 keeps the Mac's key in the Secure Enclave under the app's keychain access.

Tests (they fail before the fix and pass after)

They ran remotely through cmux-ci test (32 vCPU Linux).

Before: the Mac check is taken out (verifies_then returns false), which is what base does, since base has no Mac check.

test hub::mac_approvers::tests::removing_a_mac_from_the_file_stops_its_signatures ... FAILED
test hub::mac_approvers::tests::a_macs_signature_counts_for_its_one_time_allow_and_every_change_fails ... FAILED
test hub::mac_approvers::tests::a_macs_signed_answer_covers_exactly_its_answers ... FAILED
test hub::mac_approvers::tests::a_reload_during_a_commit_waits_for_it_and_then_the_signature_counts_nothing ... FAILED
test a_peer_counts_an_enrolled_macs_signed_one_time_allow_until_it_is_unenrolled ... FAILED

After:

test hub::mac_approvers::tests::a_mac_never_allows_a_plan_a_mode_switch_or_a_folder_trust ... ok
test hub::mac_approvers::tests::the_wire_payload_is_fixed ... ok
test hub::mac_approvers::tests::only_a_valid_key_is_this_machines_own ... ok
test hub::mac_approvers::tests::a_file_others_may_write_enrolls_no_mac ... ok
test hub::mac_approvers::tests::a_macs_signed_answer_covers_exactly_its_answers ... ok
test hub::mac_approvers::tests::removing_a_mac_from_the_file_stops_its_signatures ... ok
test hub::mac_approvers::tests::a_macs_signature_counts_for_its_one_time_allow_and_every_change_fails ... ok
test hub::mac_approvers::tests::a_reload_during_a_commit_waits_for_it_and_then_the_signature_counts_nothing ... ok
test a_peer_counts_an_enrolled_macs_signed_one_time_allow_until_it_is_unenrolled ... ok
peer_integration: 49 passed; person_allow: 25 passed; protocol_ir_is_current ok; clippy -D warnings clean

No capture: the only webview path is the generated protocol method list (methods.ts, two new method names); nothing on screen changes. Part 3 (the app) carries the before/after card captures.

Risk. This is a second way to prove the person on a peer. It is limited to one-time allows of plain tool asks and questions. It is off until a Mac is enrolled in a file that only this user can write.

Agent session: agent session

🤖 Generated with Claude Code

…oute approvals 1/3)

A chat the Mac's app reaches through its own daemon (--host) lives on a
peer, which refused every Allow from the card: the app holds no key of that
daemon's. The app now signs the Allow itself and only the signature crosses
the peer link; the peer checks it against the Mac keys enrolled in its
ACPMUX_HOME/mac-approvers.json (hub/mac_approvers.rs).

- The signature binds the daemon run, the Mac, the session, the permission,
  the tool call id, the option, the prompt's nonce, the expiry (at most 300 s)
  and the answers' digest; it counts once (the spent-nonce store).
- Only a one-time allow of a tool ask or a question: never a lasting allow,
  a plan, a mode switch or a folder trust. Unsigned allows stay refused;
  denies count from anyone.
- No RPC enrolls a key: only the file, which host approvals writes. A reload
  (_acpmux/mac_approvers {reload: true}) takes the set's lock, so removing a
  Mac waits for an answer being committed and then stops its signatures.
- _acpmux/mac_approver_set (the person only, memory only) gives this
  machine's own app key, for host enroll to enroll on a peer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Owned by an agent session · session

@github-actions github-actions Bot added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 27, 2026
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review e68e3d1: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr26131 -f ref=lane/hb-peer-approvals-daemon -f platform=macos -f backend_mode=local

Unclear: web / react-apps-check failed: no known signature; failed step: Check webview test shards.

CI failed on e68e3d14b8 (run 40487451275 attempt 1): 4 code, 1 unknown.

Job Verdict Why
web / react-apps-base-check (4) code a web test failed
web / react-apps-base-check (2) code a web test failed
web / react-apps-base-check (1) code a web test failed
web / react-apps-base-check (3) code a web test failed
web / react-apps-check unknown no known signature; failed step: Check webview test shards

Failures:

  • seen on other PRs layout.test.ts only page.css imports the page's sheets: failed (job)
  • seen on other PRs agent-pane-column-edges.test.ts every block in the chat column starts on the column's left edge: failed (job)
  • seen on other PRs agent-pane-dock-gutters.test.ts chromium > the gutter beside every part of the stack scrolls the transcript, and the part keeps the ...: failed (job)
  • seen on other PRs agent-pane-dock-gutters.test.ts chromium > a part that scrolls in a short pane scrolls under the wheel alone, and its scrollbar take...: failed (job)
  • seen on other PRs agent-pane-dock-gutters.test.ts webkit > the gutter beside every part of the stack scrolls the transcript, and the part keeps the po...: failed (job)
  • seen on other PRs agent-pane-question-records.test.ts answered live, the card goes and the record takes its place: failed (job)
  • seen on other PRs agent-pane-queue-steer.test.ts queued and steering prompts > the queue takes its own place in the stack: nothing is cut off or draw...: failed (job)
  • seen on other PRs agent-pane-short-pane-composer.test.ts chromium: in a 260 px pane a grown prompt keeps the card, its controls and the quiet row inside the ...: failed (job)
  • seen on other PRs categories.test.ts every page row has exactly one home: failed (job)
  • seen on other PRs agent-pane-mermaid.test.tsx a source both renderers refuse shows the source and why: failed (job)
  • ... 22 more in the job logs
Matched log lines
web / react-apps-base-check (4): (fail) only page.css imports the page's sheets [25.39ms]
web / react-apps-base-check (2): (fail) every page row has exactly one home [0.58ms]
web / react-apps-base-check (1): (fail) the summary card lists the chat's background tasks: running ones first, the finished folded [958.69ms]
web / react-apps-base-check (3): (fail) no orange or system accent and no opaque glass on 2293 gallery stages [490119.25ms]

Not re-run automatically: web / react-apps-base-check (4), web / react-apps-base-check (2), web / react-apps-base-check (1), web / react-apps-base-check (3), web / react-apps-check are not machine failures.

Written by scripts/ci/classify_failures.py (ci-failure-attribution.yml); signatures are its SIGNATURES table. A machine verdict is the runner's fault, not this PR's; yours means the failing file is one this PR changes, also red on main that main's latest full suite fails the same way, seen on other PRs that it failed on another pull request's run lately.

red: cmux-next checks (god files, concurrency, crash safety, l10n); no finished base run on 97e2f1b to compare; do not merge until green; see lane-rules
red: web / Web status; NEW: green on the base 97e2f1b; do not merge until green; see lane-rules
red: web / react-apps-base-check (1); no finished base run on 97e2f1b to compare; do not merge until green; see lane-rules
red: web / react-apps-base-check (2); no finished base run on 97e2f1b to compare; do not merge until green; see lane-rules
red: web / react-apps-base-check (3); no finished base run on 97e2f1b to compare; do not merge until green; see lane-rules
red: web / react-apps-base-check (4); no finished base run on 97e2f1b to compare; do not merge until green; see lane-rules
red: web / react-apps-check; no finished base run on 97e2f1b to compare; do not merge until green; see lane-rules

Gallery before/after for e68e3d1: 16 states rendered, 2 images differ, 2056 more touched states not rendered. Images are in the run's artifact, not on this public PR.

gh run download 40487451014 -R manaflow-ai/cmux -n gallery-advisory-26131-e68e3d14b8ad66a16d12531eebb4e69d4e92da14 (then read gallery-review.json)

States
State Theme Status Diff Picked by
agent-pane.chat-width/standard Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.chat-width/standard Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.chat-width/wide Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.chat-width/wide Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.chat-width/wide-blocks Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.chat-width/wide-blocks Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.composer-floats/scrolled-to-end Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.composer-floats/scrolled-to-end Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.composer-floats/composer-grown Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.composer-floats/composer-grown Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.docked-chat/new-chat Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.docked-chat/new-chat Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.docked-chat/after-turn Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.docked-chat/after-turn Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.docked-chat/working Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.docked-chat/working Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.docked-chat/typing-in-dock Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.docked-chat/typing-in-dock Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.docked-chat/long-title Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.docked-chat/long-title Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.gutter-select/from-left-margin Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.gutter-select/from-left-margin Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.gutter-select/from-right-margin Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.gutter-select/from-right-margin Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.inspector/chat-menu Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.inspector/chat-menu Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.inspector/wire-log Apple System Colors Light changed 0.0255% imports methods.ts (2 hops)
agent-pane.inspector/wire-log Apple System Colors changed 0.0242% imports methods.ts (2 hops)
agent-pane.orchestration-lab/local-queue Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.orchestration-lab/local-queue Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.orchestration-lab/build-box Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.orchestration-lab/build-box Apple System Colors unchanged 0% imports methods.ts (2 hops)

Pick other states with a Gallery: entry.id, entry.id/variant line in the PR body.

@blacksmith-sh

blacksmith-sh Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Found 188 test failures on Blacksmith runners:

Failures

Test View Logs
main.DeveloperIdAppTests.test_download_prints_the_active_profile_only/
test_download_prints_the_active_profile_only
View Logs
src/pages/settings/categories.test.ts/categories.test View Logs
src/pages/settings/categories.test.ts/categories.test View Logs
src/pages/settings/layout.test.ts/layout.test View Logs
src/pages/settings/layout.test.ts/layout.test View Logs
stubs: /System/Library/Frameworks/Security.framework/Versions/A/Security has no stub/
Security has no stub
View Logs
stubs: CoreFoundation.tbd: install-name /System/Library/Frameworks/
CoreFoundation.framework/Versions/A/CoreFoundation, missing 1: ['_CFRetain']/
CoreFoundation, missing 1: ['_CFRetain']
View Logs
stubs: CoreFoundation.tbd: install-name /System/Library/Frameworks/
CoreFoundation.framework/Versions/A/CoreFoundation, missing 1: ['_log2']/
CoreFoundation, missing 1: ['_log2']
View Logs
test/accent-glass-invariants.test.ts/accent-glass-invariants.test View Logs
test/accent-glass-invariants.test.ts/accent-glass-invariants.test View Logs

...and 178 more test failures. View all on Blacksmith

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Bead cx-qgqngs created for this PR by the cmux coordinator.

@teamleaderleo teamleaderleo changed the title acpmux: a peer counts an enrolled Mac's signed one-time Allow (peer-route approvals 1/3) acpmux: a peer counts an enrolled Mac's signed one-time Allow (peer-route approvals 1/4) Oct 27, 2026
@teamleaderleo
teamleaderleo merged commit a31a818 into feat-cmux-next Oct 27, 2026
198 of 220 checks passed
@teamleaderleo
teamleaderleo deleted the lane/hb-peer-approvals-daemon branch October 27, 2026 11:07
@github-actions

Copy link
Copy Markdown
Contributor

Shipped in:

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

This branch was previously deployed

3 inactive deployments
artifacts — e68e3d14 Deployed Oct 27, 2026 by teamleaderleo via add the Windows daemon to the tree #63834
sentry-dsyms — e68e3d14 Deployed Oct 27, 2026 by teamleaderleo via upload cmux-tui dSYMs to Sentry #63834
ci-routing-agent — e68e3d14 Deployed Oct 27, 2026 by teamleaderleo via wait for same-key tree publisher #63834
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant