mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
fix(acp): wire approval callback + make it thread-local (#13525)
Two related ACP approval issues: GHSA-96vc-wcxf-jjff — ACP's _run_agent never set HERMES_INTERACTIVE (or any other flag recognized by tools.approval), so check_all_command_guards took the non-interactive auto-approve path and never consulted the ACP-supplied approval callback (conn.request_permission). Dangerous commands executed in ACP sessions without operator approval despite the callback being installed. Fix: set HERMES_INTERACTIVE=1 around the agent run so check_all_command_guards routes through prompt_dangerous_approval(approval_callback=...) — the correct shape for ACP's per-session request_permission call. HERMES_EXEC_ASK would have routed through the gateway-queue path instead, which requires a notify_cb registered in _gateway_notify_cbs (not applicable to ACP). GHSA-qg5c-hvr5-hjgr — _approval_callback and _sudo_password_callback were module-level globals in terminal_tool. Concurrent ACP sessions running in ThreadPoolExecutor threads each installed their own callback into the same slot, racing. Fix: store both callbacks in threading.local() so each thread has its own slot. CLI mode (single thread) is unaffected; gateway mode uses a separate queue-based approval path and was never touched. set_approval_callback is now called INSIDE _run_agent (the executor thread) rather than before dispatching — so the TLS write lands on the correct thread. Tests: 5 new in tests/acp/test_approval_isolation.py covering thread-local isolation of both callbacks and the HERMES_INTERACTIVE callback routing. Existing tests/acp/ (159 tests) and tests/tools/ approval-related tests continue to pass. Fixes GHSA-96vc-wcxf-jjff Fixes GHSA-qg5c-hvr5-hjgr
This commit is contained in:
parent
ba4357d13b
commit
62348cffbe
3 changed files with 236 additions and 20 deletions
|
|
@ -114,22 +114,44 @@ _cached_sudo_password: str = ""
|
|||
# Optional UI callbacks for interactive prompts. When set, these are called
|
||||
# instead of the default /dev/tty or input() readers. The CLI registers these
|
||||
# so prompts route through prompt_toolkit's event loop.
|
||||
# _sudo_password_callback() -> str (return password or "" to skip)
|
||||
# _approval_callback(command, description) -> str ("once"/"session"/"always"/"deny")
|
||||
_sudo_password_callback = None
|
||||
_approval_callback = None
|
||||
# Callback slots used by the approval prompt and sudo password prompt
|
||||
# routines. Stored in thread-local state so overlapping ACP sessions —
|
||||
# each running in its own ThreadPoolExecutor thread — don't stomp on
|
||||
# each other's callbacks. See GHSA-qg5c-hvr5-hjgr.
|
||||
#
|
||||
# CLI mode is single-threaded, so each thread (the only one) holds its
|
||||
# own callback exactly like before. Gateway mode resolves approvals via
|
||||
# the per-session queue in tools.approval, not through these callbacks,
|
||||
# so it's unaffected.
|
||||
import threading
|
||||
_callback_tls = threading.local()
|
||||
|
||||
|
||||
def _get_sudo_password_callback():
|
||||
return getattr(_callback_tls, "sudo_password", None)
|
||||
|
||||
|
||||
def _get_approval_callback():
|
||||
return getattr(_callback_tls, "approval", None)
|
||||
|
||||
|
||||
def set_sudo_password_callback(cb):
|
||||
"""Register a callback for sudo password prompts (used by CLI)."""
|
||||
global _sudo_password_callback
|
||||
_sudo_password_callback = cb
|
||||
"""Register a callback for sudo password prompts (used by CLI).
|
||||
|
||||
Per-thread scope — ACP sessions that run concurrently in a
|
||||
ThreadPoolExecutor each have their own callback slot.
|
||||
"""
|
||||
_callback_tls.sudo_password = cb
|
||||
|
||||
|
||||
def set_approval_callback(cb):
|
||||
"""Register a callback for dangerous command approval prompts (used by CLI)."""
|
||||
global _approval_callback
|
||||
_approval_callback = cb
|
||||
"""Register a callback for dangerous command approval prompts.
|
||||
|
||||
Per-thread scope — ACP sessions that run concurrently in a
|
||||
ThreadPoolExecutor each have their own callback slot. See
|
||||
GHSA-qg5c-hvr5-hjgr.
|
||||
"""
|
||||
_callback_tls.approval = cb
|
||||
|
||||
# =============================================================================
|
||||
# Dangerous Command Approval System
|
||||
|
|
@ -144,7 +166,7 @@ from tools.approval import (
|
|||
def _check_all_guards(command: str, env_type: str) -> dict:
|
||||
"""Delegate to consolidated guard (tirith + dangerous cmd) with CLI callback."""
|
||||
return _check_all_guards_impl(command, env_type,
|
||||
approval_callback=_approval_callback)
|
||||
approval_callback=_get_approval_callback())
|
||||
|
||||
|
||||
# Allowlist: characters that can legitimately appear in directory paths.
|
||||
|
|
@ -219,9 +241,10 @@ def _prompt_for_sudo_password(timeout_seconds: int = 45) -> str:
|
|||
import sys
|
||||
|
||||
# Use the registered callback when available (prompt_toolkit-compatible)
|
||||
if _sudo_password_callback is not None:
|
||||
_sudo_cb = _get_sudo_password_callback()
|
||||
if _sudo_cb is not None:
|
||||
try:
|
||||
return _sudo_password_callback() or ""
|
||||
return _sudo_cb() or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue