mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +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
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from collections import defaultdict, deque
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Deque, Optional
|
||||
|
|
@ -554,15 +555,32 @@ class HermesACPAgent(acp.Agent):
|
|||
agent.step_callback = step_cb
|
||||
agent.message_callback = message_cb
|
||||
|
||||
if approval_cb:
|
||||
try:
|
||||
from tools import terminal_tool as _terminal_tool
|
||||
previous_approval_cb = getattr(_terminal_tool, "_approval_callback", None)
|
||||
_terminal_tool.set_approval_callback(approval_cb)
|
||||
except Exception:
|
||||
logger.debug("Could not set ACP approval callback", exc_info=True)
|
||||
# Approval callback is per-thread (thread-local, GHSA-qg5c-hvr5-hjgr).
|
||||
# Set it INSIDE _run_agent so the TLS write happens in the executor
|
||||
# thread — setting it here would write to the event-loop thread's TLS,
|
||||
# not the executor's. Also set HERMES_INTERACTIVE so approval.py
|
||||
# takes the CLI-interactive path (which calls the registered
|
||||
# callback via prompt_dangerous_approval) instead of the
|
||||
# non-interactive auto-approve branch (GHSA-96vc-wcxf-jjff).
|
||||
# ACP's conn.request_permission maps cleanly to the interactive
|
||||
# callback shape — not the gateway-queue HERMES_EXEC_ASK path,
|
||||
# which requires a notify_cb registered in _gateway_notify_cbs.
|
||||
previous_approval_cb = None
|
||||
previous_interactive = None
|
||||
|
||||
def _run_agent() -> dict:
|
||||
nonlocal previous_approval_cb, previous_interactive
|
||||
if approval_cb:
|
||||
try:
|
||||
from tools import terminal_tool as _terminal_tool
|
||||
previous_approval_cb = _terminal_tool._get_approval_callback()
|
||||
_terminal_tool.set_approval_callback(approval_cb)
|
||||
except Exception:
|
||||
logger.debug("Could not set ACP approval callback", exc_info=True)
|
||||
# Signal to tools.approval that we have an interactive callback
|
||||
# and the non-interactive auto-approve path must not fire.
|
||||
previous_interactive = os.environ.get("HERMES_INTERACTIVE")
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
try:
|
||||
result = agent.run_conversation(
|
||||
user_message=user_text,
|
||||
|
|
@ -574,6 +592,11 @@ class HermesACPAgent(acp.Agent):
|
|||
logger.exception("Agent error in session %s", session_id)
|
||||
return {"final_response": f"Error: {e}", "messages": state.history}
|
||||
finally:
|
||||
# Restore HERMES_INTERACTIVE.
|
||||
if previous_interactive is None:
|
||||
os.environ.pop("HERMES_INTERACTIVE", None)
|
||||
else:
|
||||
os.environ["HERMES_INTERACTIVE"] = previous_interactive
|
||||
if approval_cb:
|
||||
try:
|
||||
from tools import terminal_tool as _terminal_tool
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue