acpmux: a session can answer its sub-sessions' questions (off by default), never their permission prompts

Merged
TE
16d ago
+63977-62695

A coordinator agent that starts sub-sessions could not answer their questions: an AskUserQuestion or Codex user-input request in a child waited for the person, because only the cmux app may answer a question. With this change a session answers the questions of the sessions under it, and only questions. Permission prompts and plan approvals still wait for the person.

What changes

  • New daemon method _acpmux/subsession/answer {sessionId, permissionId, answers} and a cmux-subsessions MCP tool, answer_question. The caller is proven by its caller token, like every sub-session method. It reaches the same sessions send_message and stop_session reach: descendants, never its own session or its parent, and never a chat the person started from a suggested task.
  • get_session and wait_for_sessions rows carry questions: each pending question's permissionId and items (prompt, options, multiSelect, allowsOther), masked like the rest of a row. wait_for_sessions already returned on a pending prompt.
  • Answers can be keyed by an item's id or its prompt and given as a string or a list. The daemon converts them to the asking harness's shape and checks them with the same check_answers a person's answer goes through.
  • A permission prompt, a plan approval (ExitPlanMode, x.ai/exit_plan_mode, cursor/create_plan), a lasting grant, or a question without normalized items is refused with subsessions.not_a_question. The message names the child and says the user allows it in cmux NEXT.
  • The child's permission_decision record gets answeredBy {session, name}, and the agent pane's question record shows "Answered by session ", so an agent-chosen name never reads as a person.
  • New setting, Settings > Agents > Sub-sessions > Let Sessions Answer Their Sub-sessions' Questions (agents.subsessions.answerQuestions). It is off by default, because it loosens the rule that only the person answers a question, and agents can't set it (user-only). While it is off, the tool is refused with subsessions.answers_off, the refusal names the child, and the agent is told to tell the user what the child asks. Questions still route to the person exactly as today.
  • Works with the waitingOn field that cmux/subsession-visibility adds to the same rows. That lane's text ("only the user answers it, in the app") stays true under the default. questions adds the question's id and items, which a parent needs to answer once the person turns the setting on. The two branches touch neighboring lines of subsession_mcp.rs and subsessions.rs (row fields and tool descriptions); whichever lands second takes both sides.

Threat analysis (risk:high: person policy)

  • What an answer can do. A question's answer is text the asking agent reads in its tool result. It allows no tool call, changes no mode, policy or rule, and starts nothing. The caller can already put any text in front of the same child with _acpmux/subsession/send, and it wrote the child's whole first prompt. So answering a question gives the caller nothing it doesn't already have.
  • Which prompts count. A prompt counts only when it was recorded as a question when it was asked (PendingPermission::ancestor_may_answer): Claude Code's AskUserQuestion by the tool name its adapter writes, or a Grok or Cursor ask acpmux builds itself, with an allow_once option. Its input's shape never decides: a tool call whose input holds questions (any MCP tool, ExitPlanMode), a writer's own _meta.acpmux.question and Codex user input (the adapter's shape is unverified) stay the person's. The setting is read once per answer, before the lock. The authoritative check in respond_permission runs again under the prompt's lock, whatever the request check saw (Answerer::ancestor, which the hub sets and no request field can). An allow_always option is never chosen, so the answer can't set a lasting harness grant. Deny-all and deny rules still refuse an allow (policy_changed).
  • Who may answer. The caller is proven only by its caller token on the unix socket (hub/person.rs (a)). Web and peer connections never have a caller. The target goes through subsession_write_target, the path send and stop use: it must be a descendant and not under a chat the person started from a suggested task.
  • answeredBy. The hub builds it. A client's answeredBy on permission_respond is still refused without the person's proof, as before.
  • Residuals. The asking agent reads the answer as its user's answer. It can't tell a parent's answer from a person's, just as it can't tell its parent-written first prompt from a person's. The person sees who answered in the transcript. A same-uid process that reads a session's caller token can answer that session's descendants' questions; it could already prompt them. An agent that edits cmux-next.json directly can turn the setting back on (the existing same-uid file-write residual); cmux settings set refuses it as user-only.
  • The person's path is unchanged. Proofs still never cover question answers.

Changelog

  • Added: a setting that lets a session answer the questions of the sub-sessions it started (off by default; permission requests always wait for you).

Verification

Focused command: ./scripts/verify-cmux-tui-hosted.sh --filter subsession_questions (dispatched directly as cmux-tui.yml focused runs, waited on with glaeda-gh).

  • Red on the test commit e01e53f2901a: run https://github.com/manaflow-ai/cmux/actions/runs/39520363474 (queued when this PR opened)
  • Green on 4a2c55c1be99 (filter subsession): run https://github.com/manaflow-ai/cmux/actions/runs/39528891590 (superseded by the review fix below)
  • Review fix (questions were told by input shape): red on the test commit a44e4f93b664 (filter subsession_questions): run https://github.com/manaflow-ai/cmux/actions/runs/39531718018; green on the head 2ef51e87dec2 (filter subsession, which also runs the new unit test): run https://github.com/manaflow-ai/cmux/actions/runs/39533435699 (the green run on 5f763f14de33 was superseded and cancelled). The new cases: a command tool and ExitPlanMode whose input holds questions, and an AskUserQuestion that offers only allow_always, are refused with subsessions.not_a_question and stay pending; a sibling and a child answering its parent are refused; a grandparent answers a grandchild. Added after re-review: an mcp__x__AskUserQuestion tool and an agent-supplied _meta.acpmux.question (no tool name, no questions input) are refused; Grok and Cursor asks are answerable; a unit test (subsession_answer_under_the_lock_only_a_recorded_question_counts) calls respond_permission with Answerer::ancestor directly, so the rule under the lock is exercised without the request check before it.
  • Adoption: a request_permission a restarted daemon re-adopts (reregister_permission) is judged like a fresh agent request (from_agent true), so a re-adopted AskUserQuestion stays answerable and nothing else shaped like a question becomes answerable. Not tested end to end across a daemon restart.

Observed / Not run

  • Observed: the two hosted runs above; python3 scripts/cmux-next/prepush-gate.py (no new violations); python3 scripts/cmux-next/gen-settings-sources.py regenerated the Swift descriptors; rustfmt --check on the changed Rust files.
  • Not run locally: the Swift build and tests (CI), bun run check and the webview unit test (CI; local bun runs are blocked on this Mac), the live harness suites (subsessions_live, which need real Claude Code and Codex). I wrote methods.ts by hand (two lines, in spec order) because the codegen is a bun run; CI's protocol check confirms it. settings-schema.json, docs/mdm/* and schemas/defaults/cmux-next.json are left to the base autofix, which regenerates them from Swift. The head commit 4a2c55c1be99 (default off) was made through the GitHub git-data API because local git on cosmic-gray was wedged (every git process stuck in uninterruptible IO), so the pre-push gate ran on 637cb12220c8 and not on it; that commit changes only a JSON default, its generated Swift copy, docs, a tool description and the test.
  • Not covered: Grok and Cursor asks are normalized the same way, but the new test uses the Claude-shaped fake question only.

Agent session: cosmic-gray

🤖 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

A session can now answer the questions its sub-sessions ask, under a new user-only setting that is off by default; permission requests, plan approvals, lasting grants, and the folder-trust card still wait for the person.

  • Adds _acpmux/subsession/answer and the answer_question cmux-subsessions tool; the caller is proven by its caller token and can only reach its descendants.
  • get_session and wait_for_sessions rows list a child's pending questions with their id, items, and an answerable flag.
  • A prompt counts as answerable only if it was recorded as a question when asked (ancestor_may_answer): Claude Code's AskUserQuestion or an acpmux-built Grok or Cursor ask, with a one-time allow; never a request's input shape. The rule is checked again under the prompt's lock.
  • A permission prompt, a plan approval, a lasting grant, a question by input shape alone (an MCP tool, ExitPlanMode, the writer's own _meta.acpmux.question), or the folder-trust card is refused (subsessions.not_a_question) and stays pending.
  • Answering gives the caller nothing it could not already send the child with _acpmux/subsession/send.
  • While the setting is off, the tool is refused (subsessions.answers_off); the decision record and transcript name the answering session, shown as "Answered by session <name>", so an agent-chosen name never reads as a person's.
  • New setting, Settings > Agents > Sub-sessions > Let Sessions Answer Their Sub-sessions' Questions (agents.subsessions.answerQuestions), off by default.