fix(approval): fail closed when no approval responder can exist (no_responder) - #87520
fix(approval): fail closed when no approval responder can exist (no_responder)#87520a1398394385 wants to merge 2 commits into
Conversation
The three no-notify_cb fallback branches (dangerous-command gate, aggregated command guard, execute_code guard) used to call submit_pending() and return pending_approval. approval._pending has no consumer anywhere (the TUI replay path reads _gateway_queues, a different store), so a headless escalation promised an interaction that could never happen — the task parked forever, and a kanban worker's heartbeat kept the wedge below the stale-claim reclaim (NousResearch#87183 incident, root-caused in NousResearch#87488). These branches now return a shared _no_responder_block_result(): approved=False, outcome=no_responder, user_consent=False, standard BLOCKED copy, so the LLM treats it as terminal and the run fails cleanly. No _pending entries are written. notify_cb, CLI callback, CLI fall-through, and timeout semantics are unchanged. Fixes NousResearch#87488
The pending fallback was encoded as expected behavior in five files; flip those contracts to the terminal block and add headless regressions: - test_cli_approval_exec_ask_leak: headless ask-leak without a CLI callback now fails closed; new dangerous-command variant; both assert approval._pending stays empty - test_approve_deny_commands: TestFallbackNoCallback asserts outcome=no_responder with no pending status or flag - test_approval: redaction assertions move onto the terminal shape - test_execute_code_approval_cluster: three flips, smart-DENY without a notifier fails closed, smart-deny payloads assert no _pending - test_modal_sandbox_fixes: two Docker host-bind approvals flipped
fix(approval): fail closed when no approval responder can exist (no_responder)
|
|
Thanks for looking at this. I went through each point; a couple of notes below, no code changes needed. On the consumer sweep (point 1): I re-reviewed the consuming code after reading your comment. The only production branch on One small thing on the "/approve could later resolve it" part, since it took me a while to untangle too: On the dropped On (Glad the unification reads well — point 3.) |
Fixes #87488
Summary
The three no-
notify_cbfallback branches intools/approval.py— the dangerous-command gate, the aggregated command guard, and theexecute_codeguard — used to callsubmit_pending()and returnstatus=pending_approval. That promise was unkeepable:approval._pendinghas no consumer anywhere in the codebase (the TUI replay path reads_gateway_queues, a different store; gateway/approveresolves only the gateway runner's own queue). A headless process escalating a dangerous operation got back "Asking the user for approval" from a user that mathematically cannot answer. The task parked forever, and in the kanban case the per-minute heartbeat kept the wedge below the stale-claim reclaim — the worker incident on #87183, root-caused in #87488.This PR makes those branches fail closed immediately via a shared
_no_responder_block_result()helper:approved=False,outcome=no_responder,user_consent=False, and the standard BLOCKED message shape ("do NOT retry / do NOT rephrase / do NOT attempt the same outcome via a different path") so the LLM treats it as terminal and the run fails cleanly instead of idling. No_pendingentries are written on these paths. Full root cause, forensics, and a deterministic API-level repro are in #87488.Why deny immediately rather than after
approvals.timeout: a queued entry with no consumer is unresolvable either way; the countdown only buys an occupied worker slot. Thenotify_cbpath's timeout semantics are untouched.Behavior changes
HERMES_EXEC_ASK=1, dangerous command (all three guards)outcome=no_respondere37a0321e/ #86043)execute_codenotify_cbapprovals.timeoutrequest_tool_approvalplugin escalation, headlessfail_closed_when_no_human)Edge note on the third row:
check_execute_code_guardhas no CLI fall-through today (that is #86270, still open), so an interactive-CLIexecute_codeask-leak previously hit the same unresolvable pending. It now denies immediately. Strictly better than hanging; once #86270 lands, that case paints the local panel instead and this branch becomes headless-only.Combination effects with sibling PRs
These four open PRs touch the same region; none of them subsumes this fix (per-PR analysis in the dedup comment on #87488):
approvals.noninteractive_mode: denyis also set. Entry-point scrubbing alone just relocates workers between the two broken rows; the durable fix for the remaining row is feat(approval): add approvals.noninteractive_mode for headless contexts #76428. Full safety for kanban = fix(kanban): drop approval context env vars from spawned workers #63189 + this PR + feat(approval): add approvals.noninteractive_mode for headless contexts #76428 (deny).approvals.noninteractive_mode) is the complementary knob for the fail-open sibling branch and is deliberately not attempted here. This PR adds no config surface; the knob discussion stays in feat(approval): add approvals.noninteractive_mode for headless contexts #76428.submit_pending/_await_gateway_decisionregion oftools/approval.py, so expect textual conflicts — whichever lands second rebases. Semantically they are compatible: the durable store wires persistence inside_await_gateway_decision(thenotify_cbpath), which this PR does not touch.execute_codeguard) converts this PR's interactive-CLI edge case from immediate-deny to local-panel. Minor textual overlap incheck_execute_code_guard; theno_responderbranch remains correct and headless-only once it lands.Residual:
submit_pending()is now dead codeAfter this PR,
submit_pending()has zero call sites._pendingsurvives only as: the module-level dict declaration, the pop inclear_session(), and the not-growth assertions in the new tests. It was kept deliberately: removing it means touchingclear_session()and module state for no behavioral gain, and #41427 rewrites this exact region — a follow-up deleting it should be sequenced against whichever of #41427 / this PR lands second. Happy to do that cleanup as a separate tiny PR if maintainers prefer.Tests
The pending fallback was encoded as expected behavior in five files; those contracts are flipped to the terminal block, plus new headless regressions:
tests/tools/test_cli_approval_exec_ask_leak.py— flippedtest_pending_approval_still_used_without_cli_callback→test_headless_ask_mode_without_cli_callback_fails_closed; newtest_dangerous_command_headless_without_cli_callback_fails_closed(both assertapproval._pendingstays empty)tests/gateway/test_approve_deny_commands.py—TestFallbackNoCallbackassertsoutcome=no_responder, nopending_approvalstatus, noapproval_pendingflagtests/tools/test_approval.py—TestApprovalPromptRedactionkeeps its redaction assertions on the terminal block shapetests/tools/test_execute_code_approval_cluster.py— three flips, including the smart-DENY-no-notifier cases now failing closed and the two smart-deny payload tests asserting no_pendingrecordtests/tools/test_modal_sandbox_fixes.py— two Docker host-bind approval tests flippedVerification (baseline
c6dfdcbf8):The 2 failures reproduce identically on clean
origin/mainwith the same command (git stash→ rerun → same 2): one is ordering pollution (test_smart_approval_does_not_allowlist_the_pattern_for_sessionpasses in isolation), one was introduced upstream byc6dfdcbf8(test_nonrecursive_verification_artifact_cleanup_is_not_dangerous). Zero new failures from this change.