mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
Previously ACP dangerous-command approvals mixed an invalid ACP payload shape with partial Hermes option mapping, and the callback plumbing was shared across worker threads. This commit uses ACP tool-call updates, preserves Hermes once/session/always semantics, and scopes approval callbacks to the current worker thread. - Build permission requests with `update_tool_call` and unique `perm-check-*` ids in `acp_adapter/permissions.py` - Keep ACP option mapping explicit and fail closed on unknown outcomes or request failures - Set approval callbacks inside the ACP executor worker and read them from thread-local state in `tools/terminal_tool.py` - Replace duplicated ACP bridge coverage with focused tests in `tests/acp/test_permissions.py` and add a thread-local callback test
168 lines
5.7 KiB
Python
168 lines
5.7 KiB
Python
"""Tests for acp_adapter.permissions."""
|
|
|
|
import asyncio
|
|
import inspect
|
|
from concurrent.futures import Future
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from acp.schema import (
|
|
AllowedOutcome,
|
|
DeniedOutcome,
|
|
RequestPermissionResponse,
|
|
)
|
|
|
|
from acp_adapter.permissions import make_approval_callback
|
|
from tools.approval import prompt_dangerous_approval
|
|
|
|
|
|
def _make_response(outcome):
|
|
return RequestPermissionResponse(outcome=outcome)
|
|
|
|
|
|
def _invoke_callback(
|
|
outcome,
|
|
*,
|
|
allow_permanent=True,
|
|
timeout=60.0,
|
|
use_prompt_path=False,
|
|
):
|
|
loop = MagicMock(spec=asyncio.AbstractEventLoop)
|
|
request_permission = AsyncMock(name="request_permission")
|
|
future = MagicMock(spec=Future)
|
|
future.result.return_value = _make_response(outcome)
|
|
|
|
scheduled = {}
|
|
|
|
def _schedule(coro, passed_loop):
|
|
scheduled["coro"] = coro
|
|
scheduled["loop"] = passed_loop
|
|
return future
|
|
|
|
with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", side_effect=_schedule):
|
|
cb = make_approval_callback(request_permission, loop, session_id="s1", timeout=timeout)
|
|
if use_prompt_path:
|
|
result = prompt_dangerous_approval(
|
|
"rm -rf /",
|
|
"dangerous command",
|
|
allow_permanent=allow_permanent,
|
|
approval_callback=cb,
|
|
)
|
|
else:
|
|
result = cb(
|
|
"rm -rf /",
|
|
"dangerous command",
|
|
allow_permanent=allow_permanent,
|
|
)
|
|
|
|
scheduled["coro"].close()
|
|
_, kwargs = request_permission.call_args
|
|
return result, kwargs, scheduled, future, loop
|
|
|
|
|
|
class TestApprovalBridge:
|
|
def test_bridge_schedules_request_on_the_given_loop(self):
|
|
result, kwargs, scheduled, _, loop = _invoke_callback(
|
|
AllowedOutcome(option_id="allow_once", outcome="selected"),
|
|
)
|
|
|
|
tool_call = kwargs["tool_call"]
|
|
option_ids = [option.option_id for option in kwargs["options"]]
|
|
|
|
assert result == "once"
|
|
assert scheduled["loop"] is loop
|
|
assert inspect.iscoroutine(scheduled["coro"])
|
|
assert kwargs["session_id"] == "s1"
|
|
assert tool_call.session_update == "tool_call_update"
|
|
assert tool_call.tool_call_id.startswith("perm-check-")
|
|
assert tool_call.kind == "execute"
|
|
assert tool_call.status == "pending"
|
|
assert tool_call.title == "dangerous command"
|
|
assert tool_call.raw_input == {
|
|
"command": "rm -rf /",
|
|
"description": "dangerous command",
|
|
}
|
|
assert option_ids == ["allow_once", "allow_session", "allow_always", "deny"]
|
|
|
|
def test_tool_call_ids_are_unique(self):
|
|
_, first_kwargs, _, _, _ = _invoke_callback(
|
|
AllowedOutcome(option_id="allow_once", outcome="selected"),
|
|
)
|
|
_, second_kwargs, _, _, _ = _invoke_callback(
|
|
AllowedOutcome(option_id="allow_once", outcome="selected"),
|
|
)
|
|
|
|
assert first_kwargs["tool_call"].tool_call_id != second_kwargs["tool_call"].tool_call_id
|
|
|
|
def test_prompt_path_keeps_session_option_when_permanent_disabled(self):
|
|
result, kwargs, _, _, _ = _invoke_callback(
|
|
AllowedOutcome(option_id="allow_session", outcome="selected"),
|
|
allow_permanent=False,
|
|
use_prompt_path=True,
|
|
)
|
|
|
|
option_ids = [option.option_id for option in kwargs["options"]]
|
|
|
|
assert result == "session"
|
|
assert option_ids == ["allow_once", "allow_session", "deny"]
|
|
|
|
def test_allow_always_maps_correctly(self):
|
|
result, _, _, _, _ = _invoke_callback(
|
|
AllowedOutcome(option_id="allow_always", outcome="selected"),
|
|
use_prompt_path=True,
|
|
)
|
|
|
|
assert result == "always"
|
|
|
|
def test_denied_and_unknown_outcomes_deny(self):
|
|
denied_result, _, _, _, _ = _invoke_callback(DeniedOutcome(outcome="cancelled"))
|
|
unknown_result, _, _, _, _ = _invoke_callback(
|
|
AllowedOutcome(option_id="unexpected", outcome="selected"),
|
|
)
|
|
|
|
assert denied_result == "deny"
|
|
assert unknown_result == "deny"
|
|
|
|
def test_timeout_returns_deny_and_cancels_future(self):
|
|
loop = MagicMock(spec=asyncio.AbstractEventLoop)
|
|
request_permission = AsyncMock(name="request_permission")
|
|
future = MagicMock(spec=Future)
|
|
future.result.side_effect = TimeoutError("timed out")
|
|
|
|
scheduled = {}
|
|
|
|
def _schedule(coro, passed_loop):
|
|
scheduled["coro"] = coro
|
|
scheduled["loop"] = passed_loop
|
|
return future
|
|
|
|
with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", side_effect=_schedule):
|
|
cb = make_approval_callback(request_permission, loop, session_id="s1", timeout=0.01)
|
|
result = cb("rm -rf /", "dangerous command")
|
|
|
|
scheduled["coro"].close()
|
|
|
|
assert result == "deny"
|
|
assert scheduled["loop"] is loop
|
|
assert future.cancel.call_count == 1
|
|
|
|
def test_none_response_returns_deny(self):
|
|
"""When request_permission resolves to None, the callback returns 'deny'."""
|
|
loop = MagicMock(spec=asyncio.AbstractEventLoop)
|
|
request_permission = AsyncMock(name="request_permission")
|
|
future = MagicMock(spec=Future)
|
|
future.result.return_value = None
|
|
|
|
scheduled = {}
|
|
|
|
def _schedule(coro, passed_loop):
|
|
scheduled["coro"] = coro
|
|
scheduled["loop"] = passed_loop
|
|
return future
|
|
|
|
with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", side_effect=_schedule):
|
|
cb = make_approval_callback(request_permission, loop, session_id="s1", timeout=1.0)
|
|
result = cb("echo hi", "demo")
|
|
|
|
scheduled["coro"].close()
|
|
|
|
assert result == "deny"
|