fix(acp): thread-safe interactive approval via contextvars

Concurrent ACP sessions run on a shared ThreadPoolExecutor (max_workers=4).
Each _run_agent mutated the process-global os.environ["HERMES_INTERACTIVE"]
and restored it in finally, so one session's restore could clobber another's
set mid-run — dropping the second session onto the non-interactive
auto-approve path, executing a dangerous command without the approval
callback firing (GHSA-96vc-wcxf-jjff).

Replace the env-var flag with a thread/task-local contextvar in
tools.approval. The two HERMES_INTERACTIVE read sites in approval.py now go
through _is_interactive_cli() (contextvar-first, env fallback for legacy
single-threaded CLI callers). The ACP executor sets the contextvar instead
of os.environ; the existing contextvars.copy_context() wrapper isolates each
session's write.

Co-authored-by: Hermes Agent <127238744+teknium1@users.noreply.github.com>
This commit is contained in:
georgex8001 2026-06-30 03:12:10 -07:00 committed by Teknium
parent f5eb4c307b
commit 62b9fb6623
3 changed files with 106 additions and 16 deletions

View file

@ -48,6 +48,47 @@ _approval_tool_call_id: contextvars.ContextVar[str] = contextvars.ContextVar(
default="",
)
# Interactive-CLI flag. Concurrent ACP sessions run on a shared
# ThreadPoolExecutor (acp_adapter/server.py), so mutating the process-global
# os.environ["HERMES_INTERACTIVE"] races: one session's restore in `finally`
# can clobber another session's set mid-run, dropping it onto the
# non-interactive auto-approve path so a dangerous command executes without
# the approval callback firing (GHSA-96vc-wcxf-jjff). A contextvar is
# thread/task-local, so each executor worker (or asyncio task) sees only its
# own value. None = unset → fall back to the env var for legacy
# single-threaded CLI callers that still export HERMES_INTERACTIVE.
_hermes_interactive_ctx: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
"hermes_interactive",
default=None,
)
def set_hermes_interactive_context(interactive: bool) -> contextvars.Token:
"""Bind interactive mode for the current context (thread or asyncio task).
Use this instead of mutating ``os.environ["HERMES_INTERACTIVE"]`` from
concurrent executor threads. When unset (default), interactive detection
falls back to the ``HERMES_INTERACTIVE`` env var for legacy callers.
"""
return _hermes_interactive_ctx.set("1" if interactive else "")
def reset_hermes_interactive_context(token: contextvars.Token) -> None:
"""Restore the prior value from :func:`set_hermes_interactive_context`."""
_hermes_interactive_ctx.reset(token)
def _is_interactive_cli() -> bool:
"""True when running an interactive CLI/ACP session.
Prefers the context-local flag (set by concurrent ACP sessions) and falls
back to the ``HERMES_INTERACTIVE`` env var for single-threaded callers.
"""
ctx_val = _hermes_interactive_ctx.get()
if ctx_val is not None:
return is_truthy_value(ctx_val)
return env_var_enabled("HERMES_INTERACTIVE")
def _fire_approval_hook(hook_name: str, **kwargs) -> None:
"""Invoke a plugin lifecycle hook for the approval system.
@ -1352,7 +1393,7 @@ def check_dangerous_command(command: str, env_type: str,
if is_approved(session_key, pattern_key):
return {"approved": True, "message": None}
is_cli = env_var_enabled("HERMES_INTERACTIVE")
is_cli = _is_interactive_cli()
is_gateway = _is_gateway_approval_context()
if not is_cli and not is_gateway:
@ -1612,7 +1653,7 @@ def check_all_command_guards(command: str, env_type: str,
if _command_matches_permanent_allowlist(command):
return {"approved": True, "message": None}
is_cli = env_var_enabled("HERMES_INTERACTIVE")
is_cli = _is_interactive_cli()
is_gateway = _is_gateway_approval_context()
is_ask = env_var_enabled("HERMES_EXEC_ASK")