Skip to content

fix(subagents): a script's approval card reaches the person watching the parent (D10) - #285

Merged
Broccolito merged 3 commits into
mainfrom
fix/subagent-approval-unreachable
Sep 12, 2026
Merged

fix(subagents): a script's approval card reaches the person watching the parent (D10)#285
Broccolito merged 3 commits into
mainfrom
fix/subagent-approval-unreachable

Conversation

@Broccolito

@Broccolito Broccolito commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

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:

PendingUserActions::global().park(Some(&self.session.id), None, request)

self.session is the session whose turn dispatched the script. Inside a
subagent that is the child's session, so the card is published to the
child's ActionRequiredManager queue, drained by the child's loop, teed to the
child's bus — and nowhere else. PendingUserActions::resolve_in_session then
compared 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 the
parent_session_id chain, finds every ancestor is an agent, reports
AwaitingHuman { surfaced_in: <root> }, and handle_approval_tool_requests
publishes the identical card into that session's bus. Code Execution is the
shipped default and collapses the model's roster to code_execution__*, so in
practice 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 user PreToolUse hook
answering permissionDecision: "ask" for developer__shell so the trigger is
deterministic). One prompt: delegate a shell ls to a subagent and report back.

On main (biorouterd built from main, same tree, same sandbox):

time event
03:11:23 prompt sent in the parent chat
03:11:39 surfacing user-action cards into the reply stream, session_id: "20260912_10", cards: 1 — the card is raised, in the child's session
03:11:39 → 03:15:57 the parent's tab shows Working on Workspace Watch · Mode: all · Timeout S: 120 · Running the tool and 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 s
03:14:2x checked the child's tab as well, ~2m45s after the card was raised and while it was still parked: it too rendered no card, only "Thinking · 2m 54s … Still working."

Screenshot: 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 an UpdateConversation snapshot read
from storage — where the card is not. PendingUserActions::pending_cards_for_session
exists 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 /reply stream is live and
drains the session bus — is now offered the card.

After this change, the same scenario (this branch's biorouterd), with a
sensitive rm -rf as the child's call so the trigger is the security floor
rather than a hook:

time event
03:00:42 prompt sent in the parent chat
03:00:57 parent delegated, parked in workspace_watch
03:00:59.020 sensitive_ops: Sensitive file operation escalated to approval in Auto mode
03:00:59.023 card into the child's stream (unchanged)
03:00:59.024 surfaced a delegated script's approval card in the conversation that delegated the work
03:01:02 the card is on screen in the parent's chat — ~17 s after the prompt, ≤3 s after it was raised (4 s poll)
03:03:04 Allow Once clicked in the parent's chat
03:03:06 Ran Workspace Watch … 1 result ready — the child's parked call was released and the parent resumed
03:03:14 the parent reports the child's result; the card row reads "Shell is allowed once"

The 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_ops criterion 5) as the trigger. Repeating that exact scenario on
main's daemon, the model's script composed its path at runtime and
sensitive_ops returned result_count: 0 on every inspection round, so no ask
was 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 PreToolUse hook answering
permissionDecision: "ask" for developer__shell, which is an ordinary user
configuration, is mode-independent, and is classified HumanOnly by
approval_relay::HUMAN_ONLY_INSPECTORS. The card it raises goes through exactly
the same ScriptCallGate::ask_a_personpark path, which is the path under
test. 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.

  • Not "let the parent agent decide." approval_relay's decision 31 already
    says 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.
  • Not "refuse outright" (SD-8 one level down). SD-8's rule is "a control
    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.
  • This one is the shipped behaviour for a direct call, applied to the path
    that carries the traffic.
    It is a consistency fix, not a new policy.

approval_relay::surface_where_a_person_is_watching is the
pending_user_action counterpart of begin_delegated_approval's
AwaitingHuman half, and only that half. It lives beside it so the two cannot
pick different destinations (the root of the tree — every layer between is
itself an agent). It never calls DelegationPolicy, so decisions 30 and 31 keep
their single home and this path cannot produce a permission at all.

What is not weakened

  • Proof of user. 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, so the escalation surface is refused exactly as the card's
    home is. requires_user_proof_in_session now answers for that surface too, so
    the HTTP route still raises its own explanatory 403 instead of skipping the
    check and returning a bare refusal.
  • Scope. The answering scope widens by exactly one session, and only one
    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).
  • The parent model. The card goes onto the session bus — the ephemeral
    display path POST /reply and GET /sessions/{id}/events both drain — and is
    user_only, so it is never persisted into that conversation and never read by
    its agent.
  • Publish and record are one operation (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::publish is best-effort: a session nobody is observing drops
the 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: true spawn the parent stopped waiting on —
is neither streaming /reply nor observing its own bus, so it would show
nothing. 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_session was written as the replay for a
late-joining observer and now returns the card for escalation surfaces too — it
just has no caller. Wiring it into GET /sessions/{id}/events' opening snapshot
is 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::park site that a delegated child can reach
parks with requires_user_proof: true (platform_approval, skills_extension,
extension_manager_extension, bug_report) — proof-backed authorizations that
are 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_watching
is a one-line adoption if any of those is later found to need it.

Tests — which covers which

test fails on origin/main covers
script_call_gate::tests::a_subagents_script_ask_surfaces_where_the_person_watching_the_parent_is "a script's approval card inside a subagent never reached the conversation the person is watching" the card reaches the parent's bus, same request id, same arguments, no prompt, and is not agent-visible
script_call_gate::tests::the_person_watching_the_parent_can_answer_the_childs_card "the escalated card must reach the parent first" Allow clicked in the parent's chat releases the child's parked call and the script proceeds
script_call_gate::tests::an_unrelated_conversation_still_cannot_answer_the_childs_card passes before and after (guard) the scope widens by one session, not to anyone who knows the id
script_call_gate::tests::a_root_chats_script_ask_is_published_once passes before and after (guard) a root chat gains no second card
pending_user_action::decision_authority_tests::an_escalated_card_refuses_an_unproven_allow_exactly_as_its_home_does new proof of user is not weakened by escalating
…::the_proof_requirement_is_reported_at_the_escalation_surface_too new the route keeps its explanatory 403
…::escalating_widens_the_answering_scope_by_exactly_one_session new #40's rule
…::escalation_is_idempotent_and_never_targets_the_cards_own_home new no duplicate card
…::a_park_nobody_could_answer_is_not_escalated new a declined park publishes nothing

Fail-before, measured on this tree with only the tests added:

test result: FAILED. 2 passed; 2 failed; 0 ignored; 0 measured; 4016 filtered out; finished in 61.55s

After the fix:

cargo test -p biorouter --lib -- script_call_gate pending_user_action approval_relay
test result: ok. 67 passed; 0 failed; 0 ignored; 0 measured; 3958 filtered out; finished in 6.67s

cargo test -p biorouter --lib -- script_call_gate pending_user_action approval_relay privacy:: subagent
test result: ok. 510 passed; 0 failed; 0 ignored; 0 measured; 3515 filtered out; finished in 13.01s

cargo test -p biorouter --lib                       (the whole crate)
test result: ok. 4023 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 64.63s

cargo test -p biorouter-server --lib -- routes::action_required routes::apps
test result: ok. 151 passed; 0 failed; 0 ignored; 0 measured; 514 filtered out; finished in 7.20s

cargo test -p biorouter --test privacy_capability --test privacy_toggle --test workspace_crossing_disclosure
test result: ok. 4 passed  (privacy_capability)
test result: ok. 4 passed  (privacy_toggle)
test result: ok. 2 passed  (workspace_crossing_disclosure)

Gates: cargo fmt --check clean, ./scripts/clippy-lint.sh"All baseline
clippy checks passed"
. Every command run with BIOROUTER_DISABLE_KEYRING=true.

🤖 Generated with Claude Code

…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.
@Broccolito

Copy link
Copy Markdown
Collaborator Author

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: resolve_child refuses Public parent + Ask::Private (PrivacyRefusal::PrivateChildOfPublicParent, R4) and Private parent + Ask::Public (PrivacyRefusal::PublicChildOfPrivateParent, DR-19), and inherits otherwise — subagent_tool::tests::the_spawn_matrix_holds pins all four permitted cells and both refusals. So a child's classification equals its ancestors', and the card's tool name and arguments are already inside the tier the root chat is at.

Two further limits on what travels: the escalation destination is computed from the session graph (ancestor_chain, depth-bounded, cycle-guarded), never from anything a model writes; and the card is published, never persisted, so it leaves no row in the receiving conversation.

… 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.
@Broccolito
Broccolito merged commit 57311a8 into main Sep 12, 2026
16 checks passed
@Broccolito
Broccolito deleted the fix/subagent-approval-unreachable branch September 12, 2026 11:42
Broccolito added a commit that referenced this pull request Sep 12, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant