acpmux: a started subsession never asks less than the agent that started it

Merged
TE
16d ago
+1555-130

An agent could use the cmux-subsessions tools (hub/subsessions.rs, 020a352984a) to start a child agent that asks less than it does. The reviews of #21858 found these gaps in that design, and the daemon-built version had most of them. A child now never asks less than the session that starts it. A message never goes to a session that asks less than its sender, and family prompts stay under the remote floor. The checks are in a new hub/subsession_guard.rs.

Gaps

  1. Folder resolution. start_reserved only checked cwd.is_absolute() && cwd.is_dir() and passed the raw path on. There was no trust check, so any folder worked, symlink or .. included. Now the folder is resolved once with canonicalize, and a path that doesn't resolve is refused (subsessions.cwd_unresolved). The resolved folder is the one that's checked and the one session/new or make_worktree gets. After the child is created, its cwd is checked again, and a child that reports another folder is closed (subsessions.cwd_moved).
  2. Modes that don't ask. On another family, a caller without full access left the child in its harness's own start mode. For OpenCode that's build, which allows everything; for codex-acp it's agent. Modes now rank asks < does not ask < full access, using the reviewed lists: ASKING_MODES plus the person's webAskingModes, BRIDGE_ASKING_MODES, NON_ASKING_MODES, REFUSED_FAMILIES, FULL_ACCESS_MODES, and a command that skips the checks or the sandbox. A caller's unlisted mode counts as asking. A child's unlisted mode, or a child with no mode, counts as full access, unless it runs the caller's own mode on the caller's own family. Another family may call full autonomy anything (Goose auto, a custom ACP agent with no modes). A child ranked above its caller is moved to the caller's mode or an asking mode, or closed (subsessions.asks_less).
  3. Folder profiles and skip-flag profiles. These are refused before anything starts unless the caller runs in full access (subsessions.folder_profile, subsessions.asks_less). The caller's own harness is exempt, but only when it is the very profile the caller runs: the same name, folder and contents. Another folder's profile with the same id and command is not exempt. The skip-flag check reads --dangerously-skip-permissions, Codex's bypass flags, --yolo, -s/--sandbox/-c sandbox_mode= with danger-full-access (also attached, as in -sdanger-full-access), and Gemini CLI's and Qwen Code's -y and --approval-mode yolo or auto_edit.
  4. Refusals after session/new. The existing paths already closed the child: start_in_mode kills it (start_gate.rs), and so does a failed first prompt. Every new refusal goes through settle_subsession, whose error closes the child and names the close. The send half was missing, because subsession_send checked nothing. It now refuses a receiver that asks less than its sender, by mode or policy, including a child's parent (subsessions.receiver_asks_less). The check runs again when a family prompt dispatches: after it waited in the queue, and after the agent starts. If the receiver now asks less than the sender, or the sender is gone, the prompt is refused rather than run.
  5. Trust walk, with base's trust card (#22612). It's new here (see 1). Every folder up to / is read. The order is: the harness check, then the person's own nearest untrusted decision refuses with no card (subsessions.folder_untrusted), then base's "Trust folder?" card for a folder nobody decided, then the full walk. In the full walk, an untrusted level refuses unless a nearer folder has the person's own trusted decision in cmux, which ends the walk. Outside a Web turn, one of the person's auto-trust rules (a project root or their own organization, trust_auto.rs) on the folder itself also answers, as it does for a chat the person starts; a parent folder's rule does not, since it can leave out a nested repository under it. In a Web turn a rule is no answer. Outside the caller's folder, other trust the person did not decide counts only for a caller in full access: Claude Code's or Codex's trust (a home folder that covers ~/.aws), or another chat's agent-home folder. trust::person_decision reads the person's record alone. A record that can't be read refuses.
    • Changed with base's card: a folder nobody decided, inside or outside the caller's folder, now waits on the card (up to 30 minutes) when the trust gate is on, instead of failing subsessions.folder_unknown. Not now gives trust_declined, no answer trust_timed_out, a withdrawn card trust_cancelled. With the gate off, the caller's own folder is allowed when nothing farther up is untrusted, and other undecided folders fail folder_unknown as before.
    • Stricter than base alone: the person's own untrusted on a parent folder refuses a start in an undecided subfolder with no card.
    • The harness check now runs before the folder checks, so <folder>/.cmux/harnesses may be read in a folder not yet trusted; it only reads, and only to refuse.
  6. Another host. Absent. StartRequest has no host field, and server/subsessions.rs reads only prompt, name, harness, model, cwd and worktree. The methods are routed before peer forwarding, so a family lives on one daemon.

Also:

  • Remote floor. Fixed. send_family_prompt ran every family prompt as Control::Local, and the turn then cleared the receiver's last_turn_web. So an agent driven by a Web turn could start or prompt family members outside the floor, and clear a parent's between-turns floor. A family prompt now runs as a Web turn when its sender's current or last turn was one, or its receiver's last turn was. A family prompt never clears last_turn_web.
  • Unknown caller policy. Fixed. When the parent had no policy of its own, the child fell back to its harness's own default ("policy": "approve-all" won). It now always gets the caller's effective policy: its own, else the daemon default, and ask when it can't be parsed.
  • Masking. Fixed. The last reply is masked before its 4000-character cut, which the existing subsessions.rs test caught failing at base. The running turn's prompt, as the sub-session tools show it to an agent (_acpmux/subsession/get and the list), is masked before its 200-character cut, from a masked copy kept with the running turn (masked_preview, in memory only), so a secret across the cut leaves no head; the row's doing line reads the same masked text. A turn a remote host reattaches has only the cut text, and a turn that ends or changes between the row's two reads has no masked copy to match, so in those cases the cut text is masked: only a secret across the cut can leave a head there. What the owner sees keeps base's design (server/peer_text.rs): lastPrompt, the turn's prompt, turn_started and queued prompts stay as written for the owner, so the person can read and edit them, and a peer gets them masked. Reads are scoped to descendants and the caller's own parent; that was already true.
  • Unread marks and caps. Already correct. A send never attaches, so it doesn't clear the receiver's unread mark (hub/seen.rs). Waits are capped at 50 s and replies at 4000 characters.
  • Answers to a sub-session's questions (base #22740). answer_question runs the same receiver_check as a send (subsessions.receiver_asks_less), since an answer is text the child acts on. When the caller's or the child's turn is a Web turn, it answers as Web control, so a Web-driven caller can't answer a Mac-local Claude child or a child whose policy approves. New step in subsessions_questions.rs: the person narrows the parent to deny-all, the answer is refused and the question stays pending.
  • Agent messages that go in together (base #23015). A family prompt never joins another message's turn; each runs its own turn, where it is checked again (turns.rs reads only the head prompt's sender). This gives up #23015's batching for sub-session families: a parent whose children report with send_message gets one turn per report.
  • Children of an unknown family. As item 2 says, a child of another family whose mode no list names counts as full access, so an asking parent can no longer start a custom ACP agent with no listed modes as its child. Base's subsessions_delivery.rs declares its steer test harness (the fake agent with an env var) family: "fake" for that reason.

Evidence

Run on Linux, from cmux-tui/. The command was cargo test -p acpmux --no-fail-fast --test subsessions_safety --test subsessions --test subsessions_worktree, plus --lib -- subsession for the argv unit tests.

First round.

  • Red at cbef6fd9078b (tests only): all 7 first-round tests fail at the intended assertion. subsessions fails at the masked-cut assertion, as it does at base.
  • Green at 42278ea0e1f3.

Review round. One red-test commit per item, each followed by its fix:

ItemRedFix
1, unlisted or missing modee7b6dbc83fff22aa4fcfdfd3
2, remote floor (two tests)b18fb501e2cfce7ec1721018
3, person's own trust (~/.aws-style, agent-home)714a1b8299052f59636113ca
4, queued message checked again7f3116a120ba3f7e1d517d1f
5, preview masked before the cut3cdbf47455bf9d7eacebf858
6, argv forms (unit test)686ad1c598768343fd8e5b62
7, the very profileb0bdeb069b97fe5a41141cca
  • The red run was taken once, at b0bdeb069b97, after all seven test commits. All 8 new tests failed at their intended assertions: each expected refusal was accepted, the turn controls were ["local"] and ["web","local"], the queued message ran, the preview held AKIAQ7ZJ4XK2MD…, and the unit test failed. The other 7 tests passed.
  • Green at fe5a41141cca: subsessions_safety 14/14, subsessions 1/1, subsessions_worktree 1/1, and the 3 subsession unit tests.
  • Also run at fe5a41141cca: the whole acpmux lib, remote_floor, remote_guard, chats, start_mode and folder_harness. All pass except two that fail the same way at base 3cf0062f9d64, so this branch didn't cause them: protocol_registry_names_every_served_method and start_mode::a_web_chat_never_starts_in_full_access….
  • After the merge of feat-cmux-next fd28b58e345 (b3859fc5377): focused cmux-tui runs subsession, digest, prompt, owning_daemon_keeps_it_raw and a_parent_reaches_a_working_child, linked in a comment below. Earlier heads' focused runs: every subsessions_safety test green at 8a07b72cf6a; the only other reds (agent_message_pump, agent_message_launch_credential, subsession_trust's card test at line 300) fail the same way on base.
  • Not run: the full acpmux suite, scripts/verify-cmux-tui-hosted.sh, and the live Claude Code / Codex runs (subsessions_live.rs). No macOS run either.

Relay and access analysis

This adds no relay path, no allowlist entry and no v2 socket method. The _acpmux/subsession/* methods stay unix-socket only and act for the session that the caller token proves.

  • Local command or content execution: narrowed.
    • A child runs only in a folder that resolves and that the person trusts (by their own decision, by answering the trust card, or, outside a Web turn, by one of their auto-trust rules on that folder), inside the caller's own folder when nothing above it is untrusted, or, for a caller in full access only, on Claude Code's, Codex's or an agent-home folder's trust.
    • A child never runs a laxer mode, command or policy than its caller. A mode the lists don't know counts as full access.
    • A message never runs in a session that runs laxer than its sender, checked when it is sent and again when it dispatches.
    • A family prompt from, or to, a session under the remote floor is a Web turn.
  • Unowned objects: unchanged. A caller acts on its own descendants and its own parent, and only the person prompts, stops or closes a chat it started from a suggested task.
  • Local-state exposure: narrowed. Replies, and the running turn's prompt as the sub-session tools show it to an agent, are masked before they're cut. A refusal names only the folder the caller asked for and the level on its path that refused.

Follow-ups

  • Case and symlink variants on APFS. Trust lookups key the record by the exact canonical path. On case-insensitive APFS, a record written under another case, or through a link, may be missed. Missing a trusted record refuses, but missing an untrusted record could let an inherited trust through for a full-access caller. Verify on a Mac.
  • The harness spawns before the cwd re-check. session/new starts the agent before the child's cwd is compared with the checked folder. No prompt runs first, but the process does. Do the check before the spawn, or pass a pre-opened folder.
  • Family batching. Run family_dispatch_check on each candidate before #23015's join, so family messages can go in together again.
  • Send now of a queued family message. send_queued_now steers it into the running turn without the dispatch re-check; the send-time check still covers it, but a mode or policy change while it waited is missed.
  • activity and waitingPrompt (base fields) are cut before they're masked, so a secret across their cut can leave a head.
  • Rules on send. The send check compares mode and policy, not permissionRules. A child takes its parent's rules when it starts.

Changelog

Fixed: An agent's sub-session can no longer run in a mode, harness, folder or permission policy that asks less than the agent that started it. An agent can't message a session that asks less than it does, and family prompts stay under the remote floor.

🤖 Generated with Claude Code


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


Summary by cubic

Prevents cmux sub-session tools from starting a child that asks less than the agent that started it, and stops messages from reaching a session that asks less than their sender. Family prompts now also respect the remote floor.

What changed

  • The requested folder is resolved once (symlinks and .. are followed), then re-checked after the child starts; a child outside the caller's own folder needs the person's own trusted decision in cmux. Trust inherited from Claude Code, Codex, or another chat's agent-home folder counts only for a full-access caller, and a folder nobody answered the trust question for waits on a card in the parent's chat instead of failing outright.
  • Harness modes rank asks < does not ask < full access by the reviewed lists plus commands that skip permission checks or the sandbox; a child that asks less is moved to the caller's mode or an asking mode, or closed. Folder profiles and skip-flag profiles start only from a full-access caller, an unlisted or missing child mode ranks as full access unless it runs the caller's own mode on the caller's family, and a parent still in full access after sessions.allowFullAccess was turned off passes only the person's start level on.
  • The child always runs the caller's effective permission policy, every refusal after session/new closes the child, and send refuses a receiver that asks less than its sender; queued family messages are checked again when they dispatch, and a family send may queue an interrupt except toward its parent.
  • Answering a child's question is gated like a send and runs as a Web turn under the same conditions.
  • A family prompt runs as a Web turn when its sender's current or last turn, or its receiver's last turn, was one, and never clears a receiver's web floor. It never joins another turn, so it runs alone under the dispatch check.
  • Replies and the running turn's prompt are masked before their cut for the sub-session tools; the owner's own views keep raw text, and queued prompts stay whole for their owner, masked only for peers.