fix(subagent): refuse a spawn whose approval prompt has no surface (#2381) - #8914
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of All claims verified: the Design-Verdict: CONCERNS Fail-fast is built on "nobody attached now = nobody can ever answer," but pending approvals re-sync to late-attaching dashboards — the wait was answerable. Watch
Suggestions
[DESIGN-REVIEWED] baa57c1 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All mechanical checks are done: consumer counts verified, the one candidate duplicate ( First-Principles-Verdict: PASS Every item traces to the reported 30-minute hang (#2381) or a named trust boundary, sits where the description says it sits, and the one tempting duplicate answers a different question. What this change shipsIntent: when a spawn's approval prompt can reach no human, refuse immediately instead of silently waiting out the reaper — a FIX.
Duplication check: Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] baa57c1 |
7792e05 to
42c9aba
Compare
Legitimate and reachable, and it defeated the fix in exactly the case the fix is for. The Slack branch is wrapped in its own Applied the suggested fix as stated. Red-before proven, not assumed: re-adding the Slack term to the new probe turns 4 of the 19 tests red, including |
Verified and accepted. Subtracted exactly as prescribed: the constant, the This also dissolves Design Review's spec-drift concern: with no new code, On the Watch item about the relay reader: the docstring claim was right but its reasoning was not stated, so I made it explicit. A relay reader consumes the SSE stream ( |
42c9aba to
25897c8
Compare
Correct, and the First Principles lane arrived at the same surface from the other side: that code had zero consumers and could not acquire one, because (Reposted as one record per finding — the previous comment covered this and the suggestion below it together, which the disposition rule rejects. The suggestion now has its own record.) |
The gate no longer names any surface. That keeps the prose from going stale when channel-side delivery lands: a Telegram raiser will supply its own surface clause and the rung list stays put. |
Legitimate, verified, and the same defect class as your round-1 finding — a configured connection is not a reached one, now in a second location. Applied the suggested fix as stated, with the filter placed where it belongs rather than re-derived in the gateway: Red-before proven, not assumed: reverting the probe to |
25897c8 to
6690b01
Compare
Legitimate, and the cause→mechanism→consequence chain is verifiable in the tree — Applied your suggestion as stated. The rung list now lives only in the operator-facing The information is not lost, it is re-addressed. Two tests pin both halves, and the security one is a ratchet rather than a behavioural check, since a leak reads exactly like a helpful message: Red-before proven, not assumed: putting the rung list back into This finding is also the third instance of this PR's own harvested pattern, one level up — the first two rounds counted a configured channel as a reached one; this one addressed the right information to the wrong party. I have added it to the Pattern harvest section. |
…2381) A spawn_run from a channel session (Telegram) on a headless install matched none of the four auto-approve rungs, so the gate raised an interactive approval prompt. The only surfaces that can render one are a Slack owner DM and an attached dashboard client; with neither, the prompt was broadcast to nobody. The run stayed registered at turn 0 with pid null and the caller learned nothing for ~30 minutes, until the reaper killed it. Waiting is only correct when a prompt actually reached a surface. When it reached none, the wait can only end one way, and the caller pays the full deadline to find that out. The check has to sit in the approval callback, not in the gate's cascade. Four non-human auto-approve shortcuts -- hooks.auto_approve_sources, the CLI --approval mode, the YOLO override, and slot trust -- are evaluated inside the callback and never reach the gate, so a gate-side probe would have to re-derive all four and would refuse spawns those rungs mean to allow. One of them, adding "subagent" to hooks.auto_approve_sources, is this issue's own documented workaround. So the callback raises SpawnApprovalUnreachable at the exact point it would otherwise have parked: the dashboard-only fallback, reached only after every shortcut was skipped and after the Slack branch either was not taken or fell through after failing to post. "Nobody received this prompt" is the only remaining reading there, which is what makes the check sound in that one place and nowhere else. The probe therefore asks one narrow question: is a DASHBOARD-USER socket open? Two near-misses are deliberately excluded, and they are the same mistake twice -- a configured connection is not a reached one. A Slack owner DM does not count, because reaching this branch means it was absent or its post raised. An app-token socket does not count either: it registers on /api/ws like any client, but the broadcast chokepoint sends it an owner-surface frame only if its manifest declared that event, so an open app UI is not somebody who can answer. dashboard_user_ws_count() lives on the WebSocket hub, beside the _ws_client_allowed filter whose first line it mirrors. The gate catches the signal above its generic handler and refuses immediately. The raiser names the missing surface; the gate adds the rest. Keeping that split is what stops the sentence going stale when channel-side delivery lands. The refusal has TWO audiences, and which text each gets is a security decision. The rung list is the OPERATOR's and goes only in the warning log: it names two config.json keys, and security.py records that config.json is writable by any auto-approved agent shell, so putting the how-to in info.error -- which travels to the calling agent as a completion event, i.e. automation input -- would hand the party this gate CONSTRAINS the recipe for removing it. The agent's text is terse and names no file and no key: ask the operator to open the dashboard, or to enable spawn auto-approval. Prose only, deliberately no error_code: the one reader of that field (POST /api/spawn) runs before this task does, so a code minted here would reach no caller, and an unread code is contract surface bought for nothing -- the note on error_code itself says so. For a machine, the audit row separates the two outcomes with reason: no_approval_surface while keeping outcome="rejected". Unchanged on purpose: a delivered-but-unanswered prompt still waits out its deadline; a human decline still reports the plain "spawn rejected"; and mid-run TOOL approvals keep parking, since they have no terminal path that could report a refusal. That last one is why the spawn gate gets its own callback instance rather than a flag on the shared one. Two decisions on #2381 are left to a maintainer and NOT taken here: whether an operator-scoped per-agent auto_approve_spawn rung is an acceptable shape, and whether pending-approval metadata (channel, surfaced) belongs in the tombstone. No new tombstone field is added. Refs #2381
6690b01 to
baa57c1
Compare
Problem / Motivation
A
spawn_runissued during a turn that originated on a channel (Telegram in the report) on a headless install hangs for ~30 minutes and is then killed.None of the four auto-approve rungs in
subagent_manager/admission.pymatch, so the gate raises an interactive approval prompt. Only two surfaces can render one: a Slack owner DM (Block Kit buttons) and an attached dashboard client (the approvals feed). With neither,DashboardState.request_approvalbroadcasts the prompt to nobody and waits out its timeout. The run sits registered atturn 0withpid: null, and the caller learns nothing until the reaper force-kills it.The reporter also hit it from the dashboard, when the session lost its slot registration — so this is not "Telegram has no trust affordance". It is "any spawn whose prompt reaches no surface waits out the deadline".
Why it matters
Sub-agent orchestration is unusable from a channel: every spawn hangs unless a human happens to be watching the dashboard. The 30-minute silent stall with no channel-side feedback is the expensive part — the reporter mis-diagnosed it twice from the surfaced message.
What changed (motivation → approach → change)
Symptom → a spawn approval that nobody received is still waited on.
Root cause → nothing distinguishes "the prompt is unanswered" from "the prompt reached no one". Waiting is only correct in the first case; in the second the wait can only end one way, and the caller pays the full deadline to find that out.
Why the check is in the callback, not in the gate's cascade. Four non-human auto-approve shortcuts live inside
_interactive_approvaland never reach the gate:hooks.auto_approve_sources, the CLI--approvalmode, the YOLO override, and slot trust. A gate-side probe would have to re-derive all four, and would refuse spawns those rungs mean to allow — one of them, adding"subagent"tohooks.auto_approve_sources, is this issue's own documented workaround.Why the check is at the dashboard-only fallback and nowhere else. That branch is reached only after every shortcut was skipped and after the Slack branch either was not taken or fell through after failing to post. "Nobody received this prompt" is the only remaining reading there.
What counts as a surface, and the two near-misses that do not. Both are the same mistake — a configured connection is not a reached one — and both were caught in review:
/api/wslike any client, but_ws_client_allowedshort-circuits only for a dashboard user; an app socket goes through the deny-by-default event-scope gate, so it receives theapprovalframe only if its manifest declared that event. An open app UI is not somebody who can answer. (GPT r2.)So the probe asks one narrow question: is a dashboard-user socket open?
WebSocketHub.dashboard_user_ws_count()lives beside_ws_client_allowed, whose first line it mirrors, rather than being re-derived in the gateway.The change, five files:
subagent.py— aSpawnApprovalUnreachablesignal aSpawnApprovalCallbackmay raise. Its message names the missing surface, because the raiser is the only party that knows what the surfaces are.dashboard/websocket_hub.py+dashboard/state.py—dashboard_user_ws_count(), which skips app tokens and closed-but-unpruned sockets.slack/gateway.py—_dashboard_client_attached(), and a separate callback instance for the spawn gate withraise_when_unreachable=Truethat raises at that one park point.subagent_manager/admission.py— the gate catches the signal above its genericexcept Exception(which previously flattened it into the same"spawn rejected"a human decline produces) and refuses immediately. The audit row keepsoutcome="rejected"and carriesreason: no_approval_surface, so an auditor can separate the two without a new outcome value.The refusal has two audiences, and which text each gets is a security decision. (Design r3.) The four-rung how-to is the operator's and goes only in the
logger.warning: it names twoconfig.jsonkeys, andsecurity.py:8369records that storing a control there "would leave it writable by any auto-approved agent shell".info.errortravels to the calling agent as a completion event — automation input — so putting the how-to there hands the party this gate constrains the recipe for removing it, which an unattended or prompt-injected agent can simply follow. The agent's text is terse and names no file and no key: ask the operator to open the dashboard, or to enable spawn auto-approval. Actionable for an agent means "who to ask", not "which key to flip".Prose only, deliberately no
error_code. The single reader of that field (POST /api/spawn,messaging.py:287) runs before this task does —api_spawnchecksinfo.done and info.errorwith noawaitafter the synchronousspawn()— so a code minted here would reach no caller, and an unread code is contract surface bought for nothing (error_code's own note says so). Round 1 shipped one; the First Principles lane caught it and it is now subtracted, which is also why no spec change is due.A relay reader is not a false positive: it consumes the SSE stream (
dashboard/remote_mirror) and never registers on/api/wsat all.Unchanged on purpose:
"spawn rejected".The refusal reaches the calling agent as a completion event (the same channel a declined spawn uses), within milliseconds instead of ~30 minutes.
Tests
test/test_spawn_approval_no_surface_2381.py, 26 tests in six groups:(); it releases the concurrency slot; it reaches the caller throughon_done; it auditsreason: no_approval_surface; and it writes a log line keyed to the run id.config.json,hooks.auto_approve_subagent_spawn,hooks.auto_approve_sourcesorapproval_mode="auto"appears ininfo.error. A ratchet rather than a behavioural check, because a leak reads exactly like a helpful message."spawn rejected"with an emptyerror_code; a delivered prompt still parks with_awaiting_approvalset and its slot held.dashboard_user_ws_countover hand-built sockets: app-only → 0, dashboard user → 1, both → 1, closed → 0.dashboard_state→ no surface; a broken count reads as attached; and the probe never consultsws_client_counteven when it would answer 7._interactive_approvalclosure raises with 0 clients and never awaitsrequest_approval; a failed Slack post still fails fast; it still prompts with 1 client; the non-raising instance parks under the identical posture; plus a source ratchet thaton_tool_approvalstays wired to the non-raising closure.Red-before, proven rather than argued, once per round:
origin/main3094693, a throwaway probe (deleted, not committed) showed that with no Slack and zero clients_interactive_approvalparks onrequest_approval— the hang itself — and that any callback exception becomeserror == "spawn rejected", so main cannot express "nobody could answer". The new test file cannot even import on main.DID NOT RAISE SpawnApprovalUnreachable.ws_client_count()turns 5 of 24 tests red, same marker test among them.info.errorturns the security ratchet red (1 of 26).Manual verification
N/A — the failure is entirely inside the approval callback and the spawn gate, and both are driven directly by the tests, including the real
_interactive_approvalclosure and the real socket counter rather than stand-ins.Local gates green:
black,isort,flake8,mypyon all five changed modules;pytest -k "subagent or spawn or slack or telegram or approval or gateway or websocket"→ 8747 passed, 0 failed. (Two setup errors intest_autonudge_reconciler.pyare a shared-venvpytest-asyncioevent_loopfixture removal, identical on the base. A wider round-3 pass of 12151 tests also showed onetest_sandbox_argv.pyparallelism flake — 17/17 green in isolation, and this diff touches no sandbox code.)Two decisions still open for maintainers
Neither is taken here, per @bolichen97's triage:
auto_approve_spawnrung an acceptable shape? (@cschnidr's proposal.) It would be a new per-agent security primitive —KiroCrewAgentConfigcarries no approval or trust field today — which feat: Run subagents under interactive approvals instead of requiring full trust #4751's operator comment says should be specified once together with Enforce no-nesting and read-only reviewer scope for spawn_run at the runtime, not just in prompts #4693. No new rung is added by this PR; the fix names the four that already exist.channel,surfaced)? No tombstone field is added. The reap epitaph from fix(subagent): report spawn-approval-parked reaps accurately, not as a missed deadline #7325 is untouched, and a fail-fast spawn never reaches the reaper at all.Residual work (why
Refs, notCloses)The issue's first suggested fix — delivering the spawn approval to the originating channel with approve/reject actions — is not in this PR. It is genuinely larger: Telegram already has an interactive approval keyboard (
telegram/renderer.py::on_prompt_choicewithTelegramApprovalDecidernonce arming), but it is driven by the provider's in-turn permission prompts, while a spawn approval goes throughon_spawn_approval→_interactive_approval→ dashboard/Slack only. Routing spawn approvals into each channel's renderer + decider is a separate reviewable change per channel.The exception's surface-clause split is designed for exactly that follow-up: a Telegram raiser supplies its own clause and the gate's rung list stays put.
So this PR fixes the unambiguous defect only — the ~30-minute hang with nobody who could answer — and leaves channel-side delivery, plus both design questions above, open on #2381.
Related Issues
Refs #2381
Pattern harvest
Rule candidate: review-prompt
Pattern: addressed is not delivered. The defect and all three review rounds are one shape at four depths — waiting out a human deadline without checking the prompt reached a surface (the bug); counting a Slack DM that was never posted to (GPT r1); counting an app-token socket the frame is not delivered to (GPT r2); and, one level up, delivering the right information to the wrong party — a bypass how-to addressed to the agent the gate constrains (Design r3). A prompt-level rule would ask, of any message a fix emits: who actually receives this, and is that who it is for?
Sibling instance already on the issue: #2854 (a mid-turn tool permission never delivered, same 30-minute cap, different gate).
Checklist
error_codeships. The cascade docstring inadmission.pywas corrected, since it listed four rungs and omitted the pre-existing parent-trust one that the new refusal names