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

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 023b1bff1 / #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.
This commit is contained in:
Teknium
2026-04-27 06:41:02 -07:00
committed by Teknium
parent 0046d170dc
commit 008860a23f
4 changed files with 163 additions and 0 deletions

View File

@@ -906,3 +906,62 @@ class TestChmodExecuteCombo:
cmd = "chmod +x script.sh"
dangerous, _, _ = detect_dangerous_command(cmd)
assert dangerous is False
class TestFailClosedUnderPromptToolkit:
"""Regression guard for #15216.
When prompt_toolkit owns the terminal and no approval callback is
registered on the calling thread, prompt_dangerous_approval() must
deny fast instead of falling through to the input() fallback -- which
deadlocks because the user's keystrokes go to prompt_toolkit's raw-mode
stdin capture, not to input().
"""
def test_denies_when_prompt_toolkit_active_and_no_callback(self):
import threading
import prompt_toolkit.application.current as ptc
orig = ptc.get_app_or_none
ptc.get_app_or_none = lambda: object() # pretend a pt app is running
result = []
try:
def run():
result.append(
prompt_dangerous_approval(
"rm -rf /",
"test danger",
timeout_seconds=30,
approval_callback=None,
)
)
t = threading.Thread(target=run, daemon=True)
t.start()
t.join(timeout=3)
assert not t.is_alive(), (
"prompt_dangerous_approval deadlocked under prompt_toolkit "
"with no callback -- fail-closed guard is broken"
)
assert result == ["deny"]
finally:
ptc.get_app_or_none = orig
def test_callback_path_still_wins_over_guard(self):
"""Guard must not short-circuit a valid callback."""
import prompt_toolkit.application.current as ptc
orig = ptc.get_app_or_none
ptc.get_app_or_none = lambda: object()
try:
def cb(command, description, **kwargs):
return "once"
result = prompt_dangerous_approval(
"rm -rf /",
"test danger",
approval_callback=cb,
)
assert result == "once"
finally:
ptc.get_app_or_none = orig