acpmux: guarded transfer receive for Move to Cloud

Merged
TE
25d ago
+1439-22

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 $HOME itself, can't sit inside a hidden folder of $HOME, and can't hold ~/.claude, ~/.codex or acpmux's home. Unknown keys, other control values, empty roots and a non-object guard are refused (transfer.guard).
  • The guard is stored in the receive's guard.json. transfer_write and transfer_finish read it from there, so a later call can't drop it. The receive answer adds guarded: 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/*/*.jsonl and native/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 family claimed in the bundle is ignored.
  • Policy: a chat with no policy gets ask, under either control.
  • Asking checks, web only: the policy must be ask or deny-all and 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.rs session_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.
  • peer keeps 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_argv and tags are cleared, and remote_origin is 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_request stays only if the profile declares that model, and claude_profile only if it's a configured profile.
  • Identity: agent_session_id must match [A-Za-z0-9_-]{8,128} (transfer.native). session.json must name the chat the manifest named (transfer.schema), and from is capped at 4 KiB (transfer.from).

During the import:

  • The queued, queue_updated and turn_started records are rewritten to the guard's control, and web also sets trustGate. So the moved queue is dispatched under the guard's rules, also after a restart (restore_queue reads these records back). A transfer_guarded {control, roots} record is appended.
  • Agent store files are restored only for the exact session id: <sid>.jsonl for Claude, rollout-*-<sid>.jsonl for Codex, and only for the session's own harness label. Any other file refuses the finish.
  • A guarded chat keeps its agent_session_id only 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 permissionMode a transcript records when you resume interactively. claude-agent-acp and -p resumes may not, but that depends on the version.
  • Codex's rollout turn_context records carry the turn's approval_policy and sandbox_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 permissionMode field;
  • it drops records whose type or subtype is turn_context or 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 (under peer the 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 local control 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 web after 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; under web, approve-all, approve-edits and auto-approve rules are refused, and under peer the 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 web and kept under peer;
  • a Codex-family chat with a non-asking mode and approve-all policy moves under peer and its held prompt runs with peer control; under web it'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 mcpServers are 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 from cap, 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_guard on 9a363c10a625 (plus the compile fix aa4bf9fae679, step f7bb2f433fb8cbd25bd51fe1): 8 failed, 1 passed (the unguarded control). The first assertion that fails is a guarded receive says so: {"receiveId":...,"sessionId":...} (left: Null, right: true): the receive ignored the guard. The restart test failed on a guarded chat with no policy asks (policy: null).
  • Red for the id check added after a self-review: test transfer_guard on 9e07b175d562 (step 16e841ea5f592b083c6ce5e8): 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 with transfer.schema).
  • Red for the peer/web split: test transfer_guard on d0884ee09619 (step 907cf307fcb0efd9685e40a5): 7 passed, 3 failed. Each failure is a peer move refused with transfer.policy: policy approve-all does not ask before it acts or transfer.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_guard on a68ab1359955 (step f4210ba425834908099434a5): 8 passed, 3 failed. The existing-file finish succeeded instead of transfer.session, the move back kept the stale before the move transcript, and a mode-less web chat was accepted.
  • Green: test transfer_guard on 5cc055f3d6ed (step 11c8460707ea6797a7df0982): 11 passed, 0 failed.
  • Regression: test transfer:: (the existing transfer tests) on 5cc055f3d6ed (step 78102829618f8bb1839fa233): 2 passed, 0 failed.
  • Lint: clippy (step a18a756697a8c9b3af3fcd99) and fmt (step 30df961e2bd66cffbf9509f8) on 5cc055f3d6ed: both pass. scripts/cmux-next/crash_ratchet.py reports no acpmux hits. Its only findings are Swift CmuxNextControl ones, 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