acpmux: guarded transfer receive for Move to Cloud
Summary
Move to Cloud needs to hand a chat to a daemon on another machine without that machine's side getting the local user's powers. Today acpmux.transfer.v1 (#19359) trusts the bundle: the moved queue runs with the control each prompt was recorded under, and the chat keeps its preset, session env, policy, mode and folder. This PR adds a guarded receive to acpmux (cmuxterm-hq#1874 slice 1, PR 1a). Nothing calls it yet; the daemon verbs (1b) and the app pump (1c) come next. Unguarded moves are unchanged.
API
_acpmux/transfer_receive {manifest, guard: {control: "peer" | "web", roots: [abs, ...]}}. It's still served only on the local Unix socket (every WebSocket origin gets "Method not found").
roots: each one must canonicalize to an existing folder strictly under$HOME. A root can't be$HOMEitself, can't sit inside a hidden folder of$HOME, and can't hold~/.claude,~/.codexor acpmux's home. Unknown keys, othercontrolvalues, empty roots and a non-object guard are refused (transfer.guard).- The guard is stored in the receive's
guard.json.transfer_writeandtransfer_finishread it from there, so a later call can't drop it. The receive answer addsguarded: true.
What a guarded move enforces
At receive:
- Only these bundle paths are accepted:
session.json,manifest.json,events/*.ndjson,native/claude-project/.claude/projects/*/*.jsonlandnative/codex-rollout/.codex/sessions/**.jsonl. Anything else is refused (transfer.path), and at most 8 agent store files totalling at most 1 GiB are allowed (transfer.native). - Terminal agents are refused (
transfer.harness). - For
web, the families the Web never drives (codex, opencode) are refused (transfer.family).
At finish, hub/transfer_guard.rs guard_session rewrites session.json before the import:
- Folder:
cwd(the param, else the chat's own) must canonicalize to a folder under a root. It's written back canonical, so a symlink that points out of the roots is refused (transfer.cwd). - Family: recomputed from this daemon's profile. A
familyclaimed in the bundle is ignored. - Policy: a chat with no policy gets
ask, under either control. - Asking checks,
webonly: the policy must beaskordeny-alland the rules must not auto-approve (transfer.policy). The mode, and every config option that isn't a model or effort choice, must be listed as asking for the chat's family in this daemon's table, and the family must be one the Web drives (transfer.mode). A chat with no recorded mode is refused too when its family has asking modes: dispatch (remote_floor.rssession_asks_now) takes a mode-less chat's Web prompt on its policy alone, so the guard can't rely on it. A family with no asking modes may arrive mode-less, held to its asking policy. Anything else is refused, not downgraded. peerkeeps its policy, rules and mode. Peer is the to-cloud direction: the source is the user's own Mac, the target is their own sandbox VM, and a peer's prompts have no mode rule at dispatch. Every other check in this list applies under both controls.- Cleared fields:
preset,session_env,harness_roots,harness_argvandtagsare cleared, andremote_originis set. As a result the agent starts with no preset args, no cmux MCP servers and, for claude-stdio, inside the remote-chain sandbox. - Kept only when valid:
model_requeststays only if the profile declares that model, andclaude_profileonly if it's a configured profile. - Identity:
agent_session_idmust match[A-Za-z0-9_-]{8,128}(transfer.native).session.jsonmust name the chat the manifest named (transfer.schema), andfromis capped at 4 KiB (transfer.from).
During the import:
- The
queued,queue_updatedandturn_startedrecords are rewritten to the guard's control, andwebalso setstrustGate. So the moved queue is dispatched under the guard's rules, also after a restart (restore_queuereads these records back). Atransfer_guarded {control, roots}record is appended. - Agent store files are restored only for the exact session id:
<sid>.jsonlfor Claude,rollout-*-<sid>.jsonlfor Codex, and only for the session's own harness label. Any other file refuses the finish. - A guarded chat keeps its
agent_session_idonly with its own file from the bundle; with none, the id is cleared and its agent starts fresh with its acpmux history. So a remote chat can never resume an agent session that already lives on this machine. A target file that already exists refuses the finish (transfer.session), unless it belongs to this chat's own moved-away copy (same chat id and session id, folder under a root), whose file is replaced. - Each file is written to a part file beside its target and then linked into place (never over an existing file) or renamed over the moved-away copy's. A failed restore removes the part file and the files it created, and fails the finish (
transfer.native) with nothing imported. - A moved-away closed copy here is replaced only when its folder is under a root. Otherwise the finish fails with
transfer.exists.
Agent store modes: stripped, not refused
A resumed agent can take a permission mode back from its own store:
- Current Claude Code restores the last
permissionModea transcript records when you resume interactively. claude-agent-acp and-presumes may not, but that depends on the version. - Codex's rollout
turn_contextrecords carry the turn'sapproval_policyandsandbox_policy. Codex doesn't reliably restore them on resume today.
The guarded restore strips these instead of refusing the move:
- it removes every top-level
permissionModefield; - it drops records whose
typeorsubtypeisturn_contextor a permission-mode record; - it drops lines that aren't JSON objects.
The history the agent resumes stays intact, and the mode it runs in comes from acpmux.
Security reasoning
The caller (the 1b daemon verbs) builds the guard itself. This PR makes sure the bundle's contents can't widen it:
- the folder is canonical and under a root;
- under
web, the policy, rules and mode ask, checked on this daemon's own profile and table (underpeerthe chat keeps the user's own settings, as a peer prompt would); - every field that changes what spawns, or what the agent can reach, is cleared or validated;
- agent store files can't land outside the session's own file;
- queued prompts can't keep a
localcontrol from the source.
The guard is sticky, so a later finish without it is still held to it. Nothing becomes reachable from a WebSocket origin.
One limit, as designed: under web (bringing a chat back to the Mac), a chat whose mode isn't in the asking table is refused, not downgraded. Codex chats can't be brought back guarded until a Codex asking mode exists (follow-up); they are refused at receive (transfer.family). Under peer they move.
Testing
The new behavior tests are in cmux-tui/crates/acpmux/tests/hub_integration/transfer_guard.rs. Each one moves a chat between two in-process daemons over the RPC boundary:
- queued prompts under the guard's control, refused at dispatch under
webafter the person sets approve-all, also after a restart; - the unguarded move still runs the same prompt;
- a folder outside the roots, missing, relative or behind a symlink is refused, the source stays frozen, and an abort gives it back;
- no policy becomes
ask; underweb, approve-all, approve-edits and auto-approve rules are refused, and underpeerthe same chats move with their policy and rules; - a claimed family is ignored under both controls; a non-asking mode or option is refused under
weband kept underpeer; - a Codex-family chat with a non-asking mode and approve-all policy moves under
peerand its held prompt runs with peer control; underwebit's refused at receive (transfer.family), and a web-driven family with a non-asking mode or approve-all policy is refused at finish (transfer.mode/transfer.policy); - the agent's spawn env lacks the preset and session env keys, and its
mcpServersare empty; - agent store restore: exact id only, mode lines stripped; an agent session already here refused with its file kept; no file shipped clears the id; an unwritable folder fails the finish with no part file and nothing imported; a move back replaces its own moved-away copy's file; bad id, wrong label, stray paths and a ninth file refused;
- under
web, a mode-less chat of a family with asking modes is refused, and one of a family without them is taken; - guard validation, stickiness, the
fromcap, session id mismatch, and local socket only; - no replacing a moved-away copy outside the roots.
The fake agent takes FAKE_SESSION_PREFIX so its session ids are as long as a real agent's.
All runs are on the fleet with scripts/ci/cmux-tui-rust-check.sh on macOS:
- Red:
test transfer_guardon9a363c10a625(plus the compile fixaa4bf9fae679, stepf7bb2f433fb8cbd25bd51fe1): 8 failed, 1 passed (the unguarded control). The first assertion that fails isa guarded receive says so: {"receiveId":...,"sessionId":...}(left: Null, right: true): the receive ignored the guard. The restart test failed ona guarded chat with no policy asks(policy: null). - Red for the id check added after a self-review:
test transfer_guardon9e07b175d562(step16e841ea5f592b083c6ce5e8): 8 passed, 1 failed (a_guard_is_checked_kept_by_the_receive_and_taken_only_on_the_local_socket: the finish imported the other chat instead of refusing withtransfer.schema). - Red for the peer/web split:
test transfer_guardond0884ee09619(step907cf307fcb0efd9685e40a5): 7 passed, 3 failed. Each failure is a peer move refused withtransfer.policy: policy approve-all does not ask before it actsortransfer.mode: mode default does not ask before it acts for a fake chat here. - Red for the agent session rule, atomic restore and mode-less web chats:
test transfer_guardona68ab1359955(stepf4210ba425834908099434a5): 8 passed, 3 failed. The existing-file finish succeeded instead oftransfer.session, the move back kept the stalebefore the movetranscript, and a mode-less web chat was accepted. - Green:
test transfer_guardon5cc055f3d6ed(step11c8460707ea6797a7df0982): 11 passed, 0 failed. - Regression:
test transfer::(the existing transfer tests) on5cc055f3d6ed(step78102829618f8bb1839fa233): 2 passed, 0 failed. - Lint:
clippy(stepa18a756697a8c9b3af3fcd99) andfmt(step30df961e2bd66cffbf9509f8) on5cc055f3d6ed: both pass.scripts/cmux-next/crash_ratchet.pyreports no acpmux hits. Its only findings are SwiftCmuxNextControlones, and it reports the same findings on the base tree.
Not run: cargo check --target x86_64-pc-windows-gnu, because no Testbox was available. This PR relies on its Windows build lane for that. The full acpmux suite didn't run on the fleet; only the two filters above did.
Changelog
none (there's no user entry point yet; the daemon verbs and the app pump follow)
🤖 Generated with Claude Code