fix(subagents): a script's approval card reaches the person watching the parent (D10) - #285
Conversation
…the parent
D10. A card a Code Execution script raises inside a delegated child was
published to the child's own session and nowhere else. A person watching the
conversation that delegated the work saw the `subagent` tool call stop with no
card and no explanation; a child with no tab at all — `visible: false`, a
fan-out past the four-tab cap, a run started from a terminal — had no surface
anywhere, and the ask sat out its whole 3600 s time-to-live.
The same call made DIRECTLY by the child's model already escalates:
`approval_relay::begin_delegated_approval` reports `AwaitingHuman { surfaced_in }`
and `handle_approval_tool_requests` publishes the identical card into that
session's bus. In the shipped Code Execution default nearly every tool call is
a script's, so the escalation was missing from the path carrying almost all the
traffic.
`approval_relay::surface_where_a_person_is_watching` is the
`pending_user_action` counterpart of that one half, and deliberately only that
half: same destination (the root of the delegation tree), same "one ask, two
surfaces, one request id" shape, and NO ancestor agent consulted —
`DelegationPolicy` is not reachable from it, so decisions 30 and 31 keep their
single home and this path cannot produce a permission at all. Proof of user is
untouched: `resolve_matching` gates an allow on a proof-backed approval by the
authority of the answering request, never by the session it was posted from,
and `requires_user_proof_in_session` now answers for the escalation surface too
so the route still raises its own 403 rather than skipping the check.
`PendingUserAction::also_surface_in` publishes and records the extra surface as
one operation, because each half alone is its own bug: a recorded surface that
was never published is an approval nobody can see, and a published card that
was never recorded is a card the user clicks and watches do nothing.
|
Privacy check, since this publishes one conversation's card into another's stream. It cannot cross a tier boundary, and that is a property of the spawn rather than of this code: Two further limits on what travels: the escalation destination is computed from the session graph ( |
… not promise Recording the extra surface and publishing the card happen together; whether anyone is listening is the bus's business. Naming that keeps the comment a statement about this code rather than about the renderer.
Widening where a card may be answered is a decision about the delegation tree, so it belongs to the one function that knows the tree — which is itself crate-private. Nothing outside this crate needs it, and a second destination invented elsewhere is exactly what keeping the two together prevents.
Textually clean — zero conflicts. The "ctor conflict" the previous pass was resolving had already been settled in this branch's own earlier merges of #282 (ccdd9b2) and #280 (055cb08); what was left was an unpushed branch, not an unfinished resolution. GitHub reported DIRTY because the remote tip (24a6d65) was 23 commits behind the local branch. The one thing worth writing down is that #286 and this branch are the SAME rule at two layers, not two patches, and the difference between them is not a matter of taste: * A subject that resolves `BIOROUTER_PATH_ROOT` **live** and needs it held still takes `env_lock` and installs the RECORDED sandbox root (`pin_sandbox_path_root`) — the two call sites this branch corrects. * A subject that resolves it live where the live value IS the assertion takes `env_lock` with an EMPTY set — #286's `the_session_database_is_not _the_developers`. Pinning there would assert the value it had just written, which #286 shipped once and corrected. * A subject that is already FROZEN takes no lock at all and compares against the recorded root. `Config::global()` is a `OnceCell<Config>` and `SHARED_STORE_ROOT` a `LazyLock<PathBuf>` forced by the ctor (#282), so neither re-reads the variable and there is nothing for a lock to serialise — pinning them would be #286's vacuity in this crate. All three say the same thing: after main starts, the live variable answers "whichever of ~30 relocating tests holds it right now", so the only stable answer is one recorded before any test ran. `only_the_resolver_and_the_sandbox_read_the_path_root_variable` enforces that mechanically for `crates/biorouter/src`. Nothing in this branch was made redundant by #280, #282 or #286, so nothing was deleted. #280's five removed collision defences stay removed: verified that `seeded_target` carries no band counter and `reserve_child_session_ids` survives only in the doc comment recording its deletion.
D10 — an approval card raised inside a subagent never reached the person watching the parent
The mechanism, established before anything was changed
A tool call a Code Execution script makes is judged by
ScriptCallGate(#246). When an inspector says "ask a person", the gate does:
self.sessionis the session whose turn dispatched the script. Inside asubagent that is the child's session, so the card is published to the
child's
ActionRequiredManagerqueue, drained by the child's loop, teed to thechild's bus — and nowhere else.
PendingUserActions::resolve_in_sessionthencompared the posting session id against that entry's, so even a card a person
could see somewhere else could not have been answered from there.
The same call made directly by the child's model already escalates:
approval_relay::begin_delegated_approval(BR-71 Task 36b) climbs theparent_session_idchain, finds every ancestor is an agent, reportsAwaitingHuman { surfaced_in: <root> }, andhandle_approval_tool_requestspublishes the identical card into that session's bus. Code Execution is the
shipped default and collapses the model's roster to
code_execution__*, so inpractice nearly every tool call a subagent makes takes the script path — the
one path the escalation was missing from.
What the user saw, measured live
Driven through the real dev GUI (sandboxed config,
versa_azure/gpt-5.5-2026-04-24, Completely Autonomous mode, a userPreToolUsehookanswering
permissionDecision: "ask"fordeveloper__shellso the trigger isdeterministic). One prompt: delegate a shell
lsto a subagent and report back.On
main(biorouterdbuilt frommain, same tree, same sandbox):surfacing user-action cards into the reply stream, session_id: "20260912_10", cards: 1— the card is raised, in the child's sessionWorking on Workspace Watch · Mode: all · Timeout S: 120 · Running the tooland nothing else. Across 80 polls of the parent tab over those 4m18s, approval text present: 0. The 120 s lease expired at 03:13:43 and the parent simply opened another, and then a third — the stall loops, and the card's own time-to-live is 3600 sScreenshot: the parent's chat during the stall — six completed step rows, then a
spinner, no card, no explanation.
⚠ Worth naming, because it is worse than "the card is on the other tab": in this
run neither tab rendered it. An ephemeral card is deliberately never
persisted, and the observer stream a subagent tab runs on
(
GET /sessions/{id}/events) opens with anUpdateConversationsnapshot readfrom storage — where the card is not.
PendingUserActions::pending_cards_for_sessionexists for exactly that replay and has no production caller
(
git grep pending_cards_for_session— definition, tests, and nothing else).That is a second, separate gap and is NOT fixed here; it is called out so the
next person does not re-diagnose it. What this PR changes is that the
conversation a person actually has open — whose
POST /replystream is live anddrains the session bus — is now offered the card.
After this change, the same scenario (this branch's
biorouterd), with asensitive
rm -rfas the child's call so the trigger is the security floorrather than a hook:
workspace_watchsensitive_ops: Sensitive file operation escalated to approval in Auto modesurfaced a delegated script's approval card in the conversation that delegated the workRan Workspace Watch … 1 result ready— the child's parked call was released and the parent resumedThe two live runs used different triggers, and that is worth stating rather
than smoothing over. The "after" run was driven first, with the security floor
(
sensitive_opscriterion 5) as the trigger. Repeating that exact scenario onmain's daemon, the model's script composed its path at runtime andsensitive_opsreturnedresult_count: 0on every inspection round, so no askwas raised at all and nothing could be observed about where a card lands. Rather
than keep re-rolling a model-dependent trigger, the "before" run was re-driven
with a deterministic one — a user
PreToolUsehook answeringpermissionDecision: "ask"fordeveloper__shell, which is an ordinary userconfiguration, is mode-independent, and is classified
HumanOnlybyapproval_relay::HUMAN_ONLY_INSPECTORS. The card it raises goes through exactlythe same
ScriptCallGate::ask_a_person→parkpath, which is the path undertest. The code difference is covered by the failing-first tests below; the live
runs show what a user sees at each end.
The shape, and why this one
Three options were on the table. This is the second: surface it where a person
watching the parent can see and answer it, without handing the decision to the
parent agent.
approval_relay's decision 31 alreadysays a security-raised ask may be answered by a person only, at any depth, and
the script gate's module header says a script's ask always goes to a person.
Neither moves.
that can never work here says so". A card in a subagent is not in that
class — a person can answer it, in the child's tab, and does; the defect
was that the one place they were looking was not offered it. Refusing would
delete a working path.
that carries the traffic. It is a consistency fix, not a new policy.
approval_relay::surface_where_a_person_is_watchingis thepending_user_actioncounterpart ofbegin_delegated_approval'sAwaitingHumanhalf, and only that half. It lives beside it so the two cannotpick different destinations (the root of the tree — every layer between is
itself an agent). It never calls
DelegationPolicy, so decisions 30 and 31 keeptheir single home and this path cannot produce a permission at all.
What is not weakened
resolve_matchinggates an allow on a proof-backedapproval by the authority of the answering request, never by the session
it was posted from, so the escalation surface is refused exactly as the card's
home is.
requires_user_proof_in_sessionnow answers for that surface too, sothe HTTP route still raises its own explanatory 403 instead of skipping the
check and returning a bare refusal.
this code asked for. A bystander conversation that knows the request id still
resolves nothing (Headless runs hang ~30 min on 'sensitive system operation' permission prompts (protected-path writes), then fail with misleading
Error: not connected; prompt text corrupts--output-format json#40's rule, asserted).display path
POST /replyandGET /sessions/{id}/eventsboth drain — and isuser_only, so it is never persisted into that conversation and never read byits agent.
PendingUserAction::also_surface_in)because each half alone is its own bug: a recorded surface never published is
an approval nobody can see; a published card never recorded is a card the user
clicks and watches do nothing.
What this does not reach, said plainly
session_events::publishis best-effort: a session nobody is observing dropsthe frame. The conversation a person is watching while the delegation runs is
streaming
POST /reply(the parent's turn is open — that is what the stall is),and that loop drains the bus, which is why the measured run works. A chat whose
turn has ended — a
background: truespawn the parent stopped waiting on —is neither streaming
/replynor observing its own bus, so it would shownothing. That is no worse than not escalating and it grants nothing (a decision
must carry the request id, which only a rendered card supplies), but it is not
complete.
The completion already exists and is unwired:
PendingUserActions::pending_cards_for_sessionwas written as the replay for alate-joining observer and now returns the card for escalation surfaces too — it
just has no caller. Wiring it into
GET /sessions/{id}/events' opening snapshotis the follow-up, and it would fix the child's own tab at the same time (see the
⚠ above). Deliberately not in this PR: it is a second defect with its own
measurement, and bundling it would make this diff untestable as one claim.
Why only the script gate
Every other
PendingUserActions::parksite that a delegated child can reachparks with
requires_user_proof: true(platform_approval,skills_extension,extension_manager_extension,bug_report) — proof-backed authorizations thatare a separate, unmeasured decision. The coding-agent bridge's own
park(
requires_user_proof: false) runs in the session the coding agent is bound to,so its card is already in the right chat.
surface_where_a_person_is_watchingis a one-line adoption if any of those is later found to need it.
Tests — which covers which
origin/mainscript_call_gate::tests::a_subagents_script_ask_surfaces_where_the_person_watching_the_parent_isscript_call_gate::tests::the_person_watching_the_parent_can_answer_the_childs_cardscript_call_gate::tests::an_unrelated_conversation_still_cannot_answer_the_childs_cardscript_call_gate::tests::a_root_chats_script_ask_is_published_oncepending_user_action::decision_authority_tests::an_escalated_card_refuses_an_unproven_allow_exactly_as_its_home_does…::the_proof_requirement_is_reported_at_the_escalation_surface_too…::escalating_widens_the_answering_scope_by_exactly_one_session…::escalation_is_idempotent_and_never_targets_the_cards_own_home…::a_park_nobody_could_answer_is_not_escalatedFail-before, measured on this tree with only the tests added:
After the fix:
Gates:
cargo fmt --checkclean,./scripts/clippy-lint.sh→ "All baselineclippy checks passed". Every command run with
BIOROUTER_DISABLE_KEYRING=true.🤖 Generated with Claude Code