When an approval prompt expired without a response, every CLI-side path
collapsed the timeout into the same 'deny' choice as an explicit user
refusal, so the agent was told the user denied the action when the user
simply never answered. The gateway wait already distinguished the two
('timed out without user response... Silence is not consent.'); this
brings the CLI/TUI/ACP surfaces to parity.
- prompt_dangerous_approval(): input()-path expiry now returns a distinct
'timeout' choice (still fail-closed).
- cli.py _approval_callback + hermes_cli/callbacks.py approval_callback:
deadline expiry returns 'timeout' instead of 'deny'.
- check_all_command_guards / _run_approval_gate CLI tails: 'timeout' maps
to outcome='timeout' with a 'timed out without user response... Silence
is not consent.' BLOCKED message (matching the gateway wording);
explicit deny keeps outcome='denied' and gains user_consent=False for
shape parity.
- computer_use: 'timeout' verdict threads through the CLI adapter and
yields a 'prompt timed out — the user did not respond' error instead of
'denied by user'.
- ACP permissions bridge: FutureTimeout returns 'timeout' (other failures
still 'deny'); elicitation maps 'timeout' to 'cancel' like the gateway's
unresolved outcome; codex wire mapping documents deny/timeout→decline.
- write_approval already treats unknown choices as 'stage, not drop', so
a timeout now stages the memory write instead of silently refusing it.
Every timeout path remains fail-closed — the action never runs; only the
classification reported to the agent changes.
190 lines
6.2 KiB
Python
190 lines
6.2 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,
|
|
smart_denied=False,
|
|
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("agent.async_utils.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,
|
|
smart_denied=smart_denied,
|
|
approval_callback=cb,
|
|
)
|
|
else:
|
|
result = cb(
|
|
"rm -rf /",
|
|
"dangerous command",
|
|
allow_permanent=allow_permanent,
|
|
smart_denied=smart_denied,
|
|
)
|
|
|
|
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 "dangerous command" in tool_call.title
|
|
assert "rm -rf /" in tool_call.title
|
|
content_text = tool_call.content[0].content.text
|
|
assert "$ rm -rf /" in content_text
|
|
assert "dangerous command" in content_text
|
|
assert tool_call.raw_input == {
|
|
"command": "rm -rf /",
|
|
"description": "dangerous command",
|
|
}
|
|
assert option_ids == [
|
|
"allow_once",
|
|
"allow_session",
|
|
"allow_always",
|
|
"deny",
|
|
"deny_always",
|
|
]
|
|
|
|
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_allow_always_maps_correctly(self):
|
|
result, _, _, _, _ = _invoke_callback(
|
|
AllowedOutcome(option_id="allow_always", outcome="selected"),
|
|
use_prompt_path=True,
|
|
)
|
|
|
|
assert result == "always"
|
|
|
|
|
|
def test_timeout_returns_timeout_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("agent.async_utils.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()
|
|
|
|
# A no-response expiry is classified as "timeout" (still blocked,
|
|
# fail-closed) so the agent isn't told the user explicitly refused.
|
|
assert result == "timeout"
|
|
assert scheduled["loop"] is loop
|
|
assert future.cancel.call_count == 1
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scheduler-failure regression
|
|
# ---------------------------------------------------------------------------
|
|
|
|
import gc # noqa: E402
|
|
import warnings # noqa: E402
|
|
|
|
|
|
class TestSchedulerFailure:
|
|
def test_scheduler_failure_closes_permission_coroutine(self):
|
|
"""If run_coroutine_threadsafe raises, the coro is closed and we return 'deny'."""
|
|
loop = MagicMock(spec=asyncio.AbstractEventLoop)
|
|
created = {"coro": None}
|
|
|
|
async def _response_coro(**kwargs):
|
|
return _make_response(AllowedOutcome(option_id="allow_once", outcome="selected"))
|
|
|
|
def _request_permission(**kwargs):
|
|
created["coro"] = _response_coro(**kwargs)
|
|
return created["coro"]
|
|
|
|
with warnings.catch_warnings(record=True) as caught:
|
|
warnings.simplefilter("always")
|
|
with patch(
|
|
"agent.async_utils.asyncio.run_coroutine_threadsafe",
|
|
side_effect=RuntimeError("scheduler down"),
|
|
):
|
|
cb = make_approval_callback(_request_permission, loop, session_id="s1", timeout=0.01)
|
|
result = cb("rm -rf /", "dangerous")
|
|
gc.collect()
|
|
|
|
assert result == "deny"
|
|
assert created["coro"] is not None
|
|
assert created["coro"].cr_frame is None
|
|
runtime_warnings = [
|
|
w for w in caught
|
|
if issubclass(w.category, RuntimeWarning)
|
|
and "was never awaited" in str(w.message)
|
|
and "_response_coro" in str(w.message)
|
|
]
|
|
assert runtime_warnings == []
|