Skip to content

fix(acp): isolate per-session approval callback via ContextVar#13471

Closed
teknium1 wants to merge 1 commit into
mainfrom
hermes/hermes-bb7c1b2e
Closed

fix(acp): isolate per-session approval callback via ContextVar#13471
teknium1 wants to merge 1 commit into
mainfrom
hermes/hermes-bb7c1b2e

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Store the ACP approval callback in a ContextVar so concurrent sessions in one Hermes process each see their own, instead of sharing a module-global that later-arriving sessions overwrite.

Changes

  • tools/terminal_tool.py: _approval_callback / _sudo_password_callback are now ContextVars; set_approval_callback / set_sudo_password_callback still work unchanged for CLI callers (single context holds a single callback).
  • acp_adapter/server.py: drop the save/restore-global dance; wrap loop.run_in_executor(_executor, _run_agent) with contextvars.copy_context().run(...) so the per-session callback propagates into the worker thread. asyncio does not copy contextvars into executors on its own — empirically verified.
  • tests/acp/test_concurrent_approval_isolation.py: regression test that reproduces the original primitive (two overlapping "sessions," each asserts it observes its own callback) plus a guard on the run_in_executor contextvar contract the fix relies on.

Context

Reported in GHSA-qg5c-hvr5-hjgr by @xeloxa. Within a single OS user this was UX confusion (a dangerous-command prompt could land in the wrong editor tab) rather than a cross-principal boundary break, but the shared-state primitive is real concurrency sloppiness worth fixing. Same pattern we applied to gateway session vars in e8034e2f.

Validation

Before After
Two overlapping "sessions," A reads its callback after B sets its own A observes B's callback (confirmed in control test) A observes A's callback, B observes B's callback
tests/acp/test_concurrent_approval_isolation.py n/a 4 passed
tests/acp/ tests/cli/test_cli_approval_ui.py tests/tools/test_approval.py 284 passed 284 passed
tests/tools/ -k "terminal or approval" 294 passed 294 passed

Concurrent ACP sessions in one Hermes process previously shared
tools.terminal_tool._approval_callback as a module-global, so session B
overwriting the slot could route session A's dangerous-command prompt
through B's callback (and vice versa). Within a single OS user this was
UX confusion rather than a cross-principal boundary break, but the
shared state is genuine concurrency sloppiness worth fixing.

Store the callback (and the sibling sudo password callback) in
ContextVars. Each asyncio task gets its own copy, so per-session
set_approval_callback calls no longer stomp on each other. ACP's prompt
handler now wraps loop.run_in_executor in contextvars.copy_context().run
so the per-session callback survives the hop into the worker thread —
asyncio does not propagate contextvars across the executor boundary on
its own, and this was verified empirically.

Regression tests reproduce the original primitive (two overlapping
sessions, each asserts it observes its own callback) and document the
run_in_executor contextvar contract the ACP fix relies on.

Reported by @xeloxa in GHSA-qg5c-hvr5-hjgr.
@alt-glitch alt-glitch added type/security Security vulnerability or hardening P2 Medium — degraded but workaround exists comp/acp Agent Communication Protocol adapter tool/terminal Terminal execution and process management labels Apr 22, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Likely duplicate of #13525 (merged) — same root cause (global approval callback race between concurrent ACP sessions). #13525 used threading.local() instead of ContextVar.

@alt-glitch

Copy link
Copy Markdown
Collaborator

Likely duplicate of #13525 (merged).

@xeloxa

xeloxa commented Apr 22, 2026

Copy link
Copy Markdown

@alt-glitch @teknium1 Hi there! I noticed that you've added the 'security' and 'P2' tags here. Previously, my advisories were closed as 'non-security' and no CVE was assigned, so I was just wondering if this is the normal process now? If this is indeed considered a security issue, could I kindly ask you to make my advisories public? I completely understand if you have concerns about the project, but I would really appreciate it if you could review this. Thank you! :)

@teknium1

Copy link
Copy Markdown
Contributor Author

Redundant — the same GHSA-qg5c-hvr5-hjgr concurrent-callback isolation issue was already fixed on main via threading.local() in PR #13525 (commit 62348cf, Apr 21). Since ACP uses a ThreadPoolExecutor, thread-local and ContextVar are functionally equivalent here — each session gets its own slot. Closing rather than churning an already-fixed primitive.

@teknium1 teknium1 closed this Apr 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/acp Agent Communication Protocol adapter P2 Medium — degraded but workaround exists tool/terminal Terminal execution and process management type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants