acpmux: a started subsession never asks less than the agent that started it
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
- Folder resolution.
start_reservedonly checkedcwd.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 withcanonicalize, and a path that doesn't resolve is refused (subsessions.cwd_unresolved). The resolved folder is the one that's checked and the onesession/newormake_worktreegets. After the child is created, its cwd is checked again, and a child that reports another folder is closed (subsessions.cwd_moved). - 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'sagent. Modes now rank asks < does not ask < full access, using the reviewed lists:ASKING_MODESplus the person'swebAskingModes,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 (Gooseauto, 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). - 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=withdanger-full-access(also attached, as in-sdanger-full-access), and Gemini CLI's and Qwen Code's-yand--approval-mode yoloorauto_edit. - Refusals after
session/new. The existing paths already closed the child:start_in_modekills it (start_gate.rs), and so does a failed first prompt. Every new refusal goes throughsettle_subsession, whose error closes the child and names the close. The send half was missing, becausesubsession_sendchecked 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. - 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 nearestuntrusteddecision 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, anuntrustedlevel refuses unless a nearer folder has the person's owntrusteddecision 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_decisionreads 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 givestrust_declined, no answertrust_timed_out, a withdrawn cardtrust_cancelled. With the gate off, the caller's own folder is allowed when nothing farther up is untrusted, and other undecided folders failfolder_unknownas before. - Stricter than base alone: the person's own
untrustedon 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/harnessesmay be read in a folder not yet trusted; it only reads, and only to refuse.
- 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
- Another host. Absent.
StartRequesthas no host field, andserver/subsessions.rsreads 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_promptran every family prompt asControl::Local, and the turn then cleared the receiver'slast_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 clearslast_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, andaskwhen it can't be parsed. - Masking. Fixed. The last reply is masked before its 4000-character cut, which the existing
subsessions.rstest caught failing at base. The running turn's prompt, as the sub-session tools show it to an agent (_acpmux/subsession/getand 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'sdoingline 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_startedand 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_questionruns the samereceiver_checkas 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 insubsessions_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.rsreads only the head prompt's sender). This gives up #23015's batching for sub-session families: a parent whose children report withsend_messagegets 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.rsdeclares itssteertest 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.
subsessionsfails 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:
| Item | Red | Fix |
|---|---|---|
| 1, unlisted or missing mode | e7b6dbc83fff | 22aa4fcfdfd3 |
| 2, remote floor (two tests) | b18fb501e2cf | ce7ec1721018 |
3, person's own trust (~/.aws-style, agent-home) | 714a1b829905 | 2f59636113ca |
| 4, queued message checked again | 7f3116a120ba | 3f7e1d517d1f |
| 5, preview masked before the cut | 3cdbf47455bf | 9d7eacebf858 |
| 6, argv forms (unit test) | 686ad1c59876 | 8343fd8e5b62 |
| 7, the very profile | b0bdeb069b97 | fe5a41141cca |
- 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 heldAKIAQ7ZJ4XK2MD…, and the unit test failed. The other 7 tests passed. - Green at fe5a41141cca:
subsessions_safety14/14,subsessions1/1,subsessions_worktree1/1, and the 3 subsession unit tests. - Also run at fe5a41141cca: the whole acpmux lib,
remote_floor,remote_guard,chats,start_modeandfolder_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_methodandstart_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_rawanda_parent_reaches_a_working_child, linked in a comment below. Earlier heads' focused runs: everysubsessions_safetytest 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
trustedrecord refuses, but missing anuntrustedrecord could let an inherited trust through for a full-access caller. Verify on a Mac. - The harness spawns before the cwd re-check.
session/newstarts 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_checkon each candidate before #23015's join, so family messages can go in together again. - Send now of a queued family message.
send_queued_nowsteers 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. activityandwaitingPrompt(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
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 owntrusteddecision 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.allowFullAccesswas turned off passes only the person's start level on. - The child always runs the caller's effective permission policy, every refusal after
session/newcloses the child, andsendrefuses 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.