Skip to content

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

Open
teknium1 wants to merge 1 commit intomainfrom
hermes/hermes-bb7c1b2e
Open

fix(acp): isolate per-session approval callback via ContextVar#13471
teknium1 wants to merge 1 commit intomainfrom
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant