Skip to content

fix(approval): close prompt_toolkit deadlock vectors (#15216)#16574

Merged
teknium1 merged 3 commits into
mainfrom
hermes/hermes-f300416d
Apr 27, 2026
Merged

fix(approval): close prompt_toolkit deadlock vectors (#15216)#16574
teknium1 merged 3 commits into
mainfrom
hermes/hermes-f300416d

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Approval prompts no longer deadlock against prompt_toolkit. The fallback in tools/approval.py spawned a daemon thread calling input() whenever a thread reached it without an approval callback; under prompt_toolkit's raw-mode stdin capture that thread never sees Enter, producing an invisible 60s freeze (#15216).

Salvages @andrewhosf's #13734 (concurrent-tool-executor vector) and closes the two remaining vectors.

Changes

  • run_agent.py _execute_tool_calls_concurrent (from fix(agent): propagate approval callbacks to concurrent tool worker threads #13734, @andrewhosf): snapshot the parent thread's approval + sudo TLS callbacks and register them on each ThreadPoolExecutor worker; clear on exit.
  • run_agent.py _spawn_background_review: install a _bg_review_auto_deny callback on the bg-review thread (mirrors _subagent_auto_deny from 023b1bf / fix(delegate): resolve subagent approval prompts without deadlocking parent TUI #15491); clear in finally.
  • tools/approval.py prompt_dangerous_approval fallback: when prompt_toolkit.application.current.get_app_or_none() is not None AND no callback is registered, deny fast with a logged warning instead of spawning the deadlocking input() thread. Fail-closed so future threads that forget to install a callback degrade gracefully.
  • scripts/release.py: AUTHOR_MAP entry for @andrewhosf.
  • tests: regression guards in tests/run_agent/test_background_review.py (bg-review installs + clears an auto-deny callback) and tests/tools/test_approval.py::TestFailClosedUnderPromptToolkit (denies fast under pt + callback still wins over guard).

Validation

tests/tools/test_approval.py .................. 145 passed
tests/run_agent/test_background_review.py ....    (incl new)
tests/cli/test_cli_approval_ui.py .............
tests/tools/test_yolo_mode.py, test_delegate.py
tests/run_agent/test_concurrent_interrupt.py
tests/acp/test_approval_isolation.py, test_permissions.py
Total: 289 passed, 0 failed

E2E verification (real imports, no mocks):

  • Callback path returns the callback's choice unchanged.
  • With get_app_or_none() patched to return an app and callback=None, prompt_dangerous_approval returns "deny" within 5s instead of blocking on input().

Closes #15216. Closes #15194 (duplicate, also closed by reporter).
Credits @andrewhosf (concurrent-tool vector, #13734) and @stillmuchtolearn (background-review vector, diagnosis in #15216 comments).

andrewhosf and others added 3 commits April 27, 2026 06:38
…reads

When tools execute concurrently via ThreadPoolExecutor, worker threads
could not see the thread-local approval/sudo callbacks registered by
the CLI. This caused dangerous-command prompts to fall back to plain
input(), which deadlocks against prompt_toolkit's raw terminal mode.

Capture parent-thread callbacks before launching workers, register
them locally in each _run_tool thread, and clear them on exit.

Mirrors the existing fix pattern from cli.py run_agent() for the
main agent worker thread (GHSA-qg5c-hvr5-hjgr / #13617).
PR #13734 fixed the concurrent-tool-executor vector (ThreadPoolExecutor
workers didn't inherit the CLI's TLS approval callback). Two vectors
remained that could still land in the deadlocking input() fallback:

1. _spawn_background_review spawns a raw threading.Thread with no
   approval callback installed, so any dangerous-command guard the
   review agent trips falls back to input() -> deadlock against the
   parent's prompt_toolkit TUI (same class as delegate_task subagents,
   fixed in 023b1bf / #15491). Install a _bg_review_auto_deny
   callback at thread start, clear on finally.

2. prompt_dangerous_approval's fallback unconditionally spawned a
   daemon thread calling input() when approval_callback was None.
   That fallback can never succeed under prompt_toolkit because the
   user's Enter goes to pt's raw-mode stdin capture. Detect an active
   pt Application via get_app_or_none() and fail closed (deny + log)
   instead, so future threads that forget to install a callback
   degrade gracefully instead of hanging 60s invisibly.

Regression guards:
- tests/run_agent/test_background_review.py verifies the review
  worker thread sees a callable auto-deny callback mid-run and that
  the slot is cleared in the finally block.
- tests/tools/test_approval.py TestFailClosedUnderPromptToolkit
  verifies prompt_dangerous_approval returns 'deny' fast under a
  mocked pt Application, and that a real callback still wins over
  the guard.
Release-notes contributor attribution for the salvaged PR #13734 fix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants