hermes - ✅(Solved) Fix TUI approval overlay freezes terminal — useInput handlers compete, keystrokes never reach ApprovalPrompt [2 pull requests, 5 comments, 5 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#13618Fetched 2026-04-22 08:05:19
View on GitHub
Comments
5
Participants
5
Timeline
14
Reactions
0
Timeline (top)
commented ×5cross-referenced ×3labeled ×3referenced ×2

When the Hermes TUI displays an approval overlay (dangerous command detected), the terminal freezes completely. The overlay renders visually with the options once / session / always / deny, but no keyboard input reaches the ApprovalPrompt component. Arrow keys, number keys 1-4, and Enter do nothing. The only way out is closing the terminal window entirely.

Root Cause

There is a conflict between two useInput hooks registered on Ink's EventEmitter without any exclusion mechanism:

  1. useInputHandlers (ui-tui/src/app/useInputHandlers.ts:172) — global handler at app level. When isBlocked is true (any overlay active), it ONLY handles:

    • pager (Enter/Space/Escape/q)
    • Ctrl+CcancelOverlayFromCtrlC() (sends approval.respond with deny via RPC)
    • Esc + picker → closes picker
    • Everything else: return — consumes the event and discards it
  2. ApprovalPrompt (ui-tui/src/components/prompts.tsx:17) — registers its own useInput to handle arrow keys, numbers 1-4, and Enter for the approval overlay.

Both hooks are registered on the same EventEmitter. useInputHandlers executes first (app-level), consumes keystrokes in the isBlocked block with return, and the keystrokes never reach ApprovalPrompt. Ink processes handlers in registration order with no propagation control.

Fix Action

Workaround

Set approvals.mode: smart or approvals.mode: off in ~/.hermes/config.yaml to bypass the approval overlay entirely.

PR fix notes

PR #13634: [security] fix(tui+cli): approval overlays accept blind keystrokes / thread-local callback bypass

Description (problem / solution / changelog)

Security: Approval Overlay Blind Keystroke Bypass + CLI Thread-Local Callback Invisible to Agent

Discovered while replicating #13618 (TUI approval overlay freezes terminal).

Bug 1: TUI — Blind Keystroke Approval (Security)

Severity: High — dangerous commands can execute without informed user consent.

When the Ink TUI displays an approval overlay (dangerous command detected), the overlay appears visually frozen — arrow keys, number keys, and Enter produce no visible feedback. However, Ink's EventEmitter delivers keystrokes to all registered useInput listeners, not just the first. The ApprovalPrompt component still receives and processes keystrokes even though useInputHandlers has already consumed the event.

This means a user pressing keys during the frozen state can:

  • Press 1 → silently approve the command once
  • Press 2 → approve ALL dangerous commands for the rest of the session (approve_session())
  • Press 3 → write to the permanent allowlist on disk (approve_permanent() + save_permanent_allowlist())
  • Press 4 → deny (the only safe option)

The user sees a frozen overlay and has no idea their keystrokes are being processed. Subsequent dangerous commands in the same session (or permanently, if always was selected) execute without any approval prompt.

Root cause: useInputHandlers (app-level) registers on the same inputEmitter as ApprovalPrompt (component-level). Ink fires ALL listeners. The return in useInputHandlers only exits that handler — it does not stop propagation.

Fix: In useInputHandlers, when overlay.approval || overlay.clarify || overlay.confirm is active, only intercept Ctrl+C (for deny/dismiss). All other keystrokes pass through so the overlay renders visual feedback and the user can make an informed selection.

Note: This fix makes the overlay responsive but does not change the EventEmitter fan-out behavior. A deeper hardening would be to consolidate overlay input handling into a single handler with explicit routing, but that is a larger refactor.

Bug 2: CLI — Thread-Local Callback Invisible to Agent Thread

Severity: Medium — approval prompt appears but cannot be interacted with, terminal unusable for 60s.

In the legacy CLI (prompt_toolkit), _callback_tls in tools/terminal_tool.py is threading.local(). set_approval_callback() is called in the main thread during run() initialization (cli.py:~9046), but the agent runs in a background thread (threading.Thread(target=run_agent)). The agent thread calls _get_approval_callback() → returns None (different thread-local slot) → falls back to prompt_dangerous_approval() with stdin input() → prompt_toolkit owns the terminal → user sees the approval text but cannot respond. Terminal is unusable until 60s timeout expires with default "deny".

Fix: Set callbacks inside run_agent() (the thread target function), matching the pattern already used by acp_adapter/server.py for ACP sessions. Clear callbacks to None in a finally block on thread exit to prevent stale references.

Files Changed

  • ui-tui/src/app/useInputHandlers.ts — skip keystroke consumption for approval/clarify/confirm overlays
  • cli.py — set terminal_tool callbacks inside agent thread, clear on exit

Testing

  • All 8 existing approval UI tests pass (tests/cli/test_cli_approval_ui.py)
  • TUI TypeScript compiles clean (tsc --noEmit)
  • Manual verification: Ink EventEmitter fires all registered listeners regardless of early return

Closes #13618

Changed files

  • cli.py (modified, +20/-0)
  • ui-tui/src/app/useInputHandlers.ts (modified, +11/-0)

PR #13697: fix: approval prompt freeze — CLI thread-local + TUI blind-keystroke

Description (problem / solution / changelog)

Restores dangerous-command approval in both CLI and TUI — approval prompts now accept keystrokes instead of freezing the terminal for 60s.

Salvage of #13634 by @Societus (rebase-merged for authorship preservation) + regression-guard tests.

Root cause

62348cff (#13525) moved _approval_callback / _sudo_password_callback to threading.local() to fix GHSA-qg5c-hvr5-hjgr (ACP race). CLI registers callbacks in the main thread but the agent runs in a daemon thread spawned by chat()threading.local doesn't propagate, so _get_approval_callback() returned None in the agent thread and fell back to input(), which deadlocks inside prompt_toolkit.

TUI had an adjacent bug: useInputHandlers consumed keystrokes via return when isBlocked, but Ink's EventEmitter still delivered them to ApprovalPrompt — user saw a frozen overlay while blind keystrokes could silently approve dangerous commands session-wide or write to the permanent allowlist.

Changes

FileChange
cli.pyRegister approval/sudo/secret callbacks inside run_agent() thread target; clear in finally
ui-tui/src/app/useInputHandlers.tsWhen approval/clarify/confirm overlay active, only intercept Ctrl+C — let arrow/number/Enter fall through
tests/cli/test_cli_approval_ui.pyTwo regression guards pinning the thread-local contract

Validation

  • tests/cli/test_cli_approval_ui.py tests/acp/test_approval_isolation.py tests/tools/test_command_guards.py — 35 passed
  • E2E: confirmed main-thread registration is invisible to child thread on current main, and child-thread registration is visible + cleared on finally
  • TUI: tsc --noEmit clean

Closes #13617, closes #13618.

Changed files

  • cli.py (modified, +20/-0)
  • tests/cli/test_cli_approval_ui.py (modified, +85/-0)
  • ui-tui/src/app/useInputHandlers.ts (modified, +11/-0)

Code Example

1. Agent executes command matching dangerous pattern
2. approval.py detects pattern, emits approval.request via gateway callback
3. TUI receives event → patchOverlayState({ approval: {...} })
4. isBlocked → true
5. ApprovalPrompt renders visually
6. User presses ↓ or 2 or Enter
7. useInputHandlers receives keystroke FIRST
8. isBlocked? true → not pager, not Ctrl+CRETURN (discards keystroke)
9. ApprovalPrompt NEVER receives the keystroke
10. Overlay stays active forever → terminal unusable

---

// useInputHandlers.ts, line ~175
if (isBlocked) {
  if (overlay.approval) {
    if (isCtrl(key, ch, 'c')) {
      cancelOverlayFromCtrlC()
    }
    return // Don't consume arrow/number/enter keys — let ApprovalPrompt handle them
  }
  // ... existing pager/confirm/clarity logic
}

---

// prompts.tsx
useInput((ch, key) => { ... }, { isActive: !!req })
RAW_BUFFERClick to expand / collapse

Bug: TUI Approval Overlay Freezes Terminal Input

Severity: High — terminal becomes completely unusable, requires closing the terminal window

Description

When the Hermes TUI displays an approval overlay (dangerous command detected), the terminal freezes completely. The overlay renders visually with the options once / session / always / deny, but no keyboard input reaches the ApprovalPrompt component. Arrow keys, number keys 1-4, and Enter do nothing. The only way out is closing the terminal window entirely.

Root Cause

There is a conflict between two useInput hooks registered on Ink's EventEmitter without any exclusion mechanism:

  1. useInputHandlers (ui-tui/src/app/useInputHandlers.ts:172) — global handler at app level. When isBlocked is true (any overlay active), it ONLY handles:

    • pager (Enter/Space/Escape/q)
    • Ctrl+CcancelOverlayFromCtrlC() (sends approval.respond with deny via RPC)
    • Esc + picker → closes picker
    • Everything else: return — consumes the event and discards it
  2. ApprovalPrompt (ui-tui/src/components/prompts.tsx:17) — registers its own useInput to handle arrow keys, numbers 1-4, and Enter for the approval overlay.

Both hooks are registered on the same EventEmitter. useInputHandlers executes first (app-level), consumes keystrokes in the isBlocked block with return, and the keystrokes never reach ApprovalPrompt. Ink processes handlers in registration order with no propagation control.

Frozen Input Flow

1. Agent executes command matching dangerous pattern
2. approval.py detects pattern, emits approval.request via gateway callback
3. TUI receives event → patchOverlayState({ approval: {...} })
4. isBlocked → true
5. ApprovalPrompt renders visually
6. User presses ↓ or 2 or Enter
7. useInputHandlers receives keystroke FIRST
8. isBlocked? true → not pager, not Ctrl+C → RETURN (discards keystroke)
9. ApprovalPrompt NEVER receives the keystroke
10. Overlay stays active forever → terminal unusable

Why Ctrl+C Also Fails Unreliably

cancelOverlayFromCtrlC() (useInputHandlers.ts:49) sends an RPC approval.respond with choice deny and calls patchOverlayState({ approval: null }). But if the agent thread is blocked in entry.event.wait() and the backend gateway doesn't process the response (e.g., dispatcher busy with a LONG_HANDLER), the RPC doesn't complete and the overlay doesn't close.

Additionally, once patchOverlayState({ approval: null }) executes, isBlocked returns to false, but the agent thread is still blocked waiting for approval. If the user types something new, the agent can't respond, creating an inconsistent state.

Proposed Fixes

Option A — In useInputHandlers, when isBlocked && overlay.approval, don't consume non-Ctrl+C keys. Let ApprovalPrompt handle them:

// useInputHandlers.ts, line ~175
if (isBlocked) {
  if (overlay.approval) {
    if (isCtrl(key, ch, 'c')) {
      cancelOverlayFromCtrlC()
    }
    return // Don't consume arrow/number/enter keys — let ApprovalPrompt handle them
  }
  // ... existing pager/confirm/clarity logic
}

Option B — Use Ink's isActive prop in ApprovalPrompt to only register its useInput when visible:

// prompts.tsx
useInput((ch, key) => { ... }, { isActive: !!req })

Option C (most robust) — Consolidate all overlay input handling into a single handler instead of multiple competing useInput hooks.

Steps to Reproduce

  1. Open Hermes TUI (hermes)
  2. Ask the agent to execute a command matching a dangerous pattern (e.g., git reset --hard, python3 -c "print('hi')", or any command from DANGEROUS_PATTERNS in tools/approval.py)
  3. Approval overlay appears
  4. Try pressing arrow keys, numbers 1-4, or Enter
  5. Nothing happens — terminal is frozen

Environment

  • Hermes Agent v0.10.0 (2026.4.16)
  • TUI mode (default Ink interface)
  • Ink v6.8.0
  • Linux (also likely affects macOS)
  • Konsole
  • zsh

Workaround

Set approvals.mode: smart or approvals.mode: off in ~/.hermes/config.yaml to bypass the approval overlay entirely.

extent analysis

TL;DR

Modify the useInputHandlers hook to not consume non-Ctrl+C keys when an approval overlay is active, allowing the ApprovalPrompt component to handle them.

Guidance

  • Identify the conflicting useInput hooks in useInputHandlers and ApprovalPrompt and understand how they interact when an approval overlay is active.
  • Consider implementing Option A from the proposed fixes, which involves modifying useInputHandlers to not consume non-Ctrl+C keys when isBlocked && overlay.approval.
  • Alternatively, explore Option B, which suggests using Ink's isActive prop in ApprovalPrompt to conditionally register its useInput handler.
  • For a more robust solution, consider Option C, consolidating all overlay input handling into a single handler.

Example

// useInputHandlers.ts, line ~175
if (isBlocked) {
  if (overlay.approval) {
    if (isCtrl(key, ch, 'c')) {
      cancelOverlayFromCtrlC()
    }
    return // Don't consume arrow/number/enter keys — let ApprovalPrompt handle them
  }
  // ... existing pager/confirm/clarity logic
}

Notes

The provided solutions assume that the issue is solely due to the conflict between the two useInput hooks. However, the root cause may be more complex, and additional debugging may be necessary. Additionally, the proposed fixes may have unintended consequences, such as introducing new bugs or affecting other parts of the application.

Recommendation

Apply Option A as a temporary workaround, as it is the simplest and most straightforward solution. However, consider implementing Option C in the long term, as it provides a more robust and maintainable solution.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

hermes - ✅(Solved) Fix TUI approval overlay freezes terminal — useInput handlers compete, keystrokes never reach ApprovalPrompt [2 pull requests, 5 comments, 5 participants]