Files
hermes-agent/acp_adapter/permissions.py
T

125 lines
5.3 KiB
Python

"""ACP permission bridging for Hermes dangerous-command approvals."""
from __future__ import annotations
import asyncio
import logging
from concurrent.futures import TimeoutError as FutureTimeout
from itertools import count
from typing import Callable
from acp.schema import AllowedOutcome, PermissionOption
logger = logging.getLogger(__name__)
# ACP permission option id -> Hermes approval result. Ids are stable across the
# ``allow_permanent=True`` and ``False`` paths even though the option list differs.
_OPTION_ID_TO_HERMES = {
"allow_once": "once",
"allow_session": "session",
"allow_always": "always",
"deny": "deny",
"deny_always": "deny",
}
_PERMISSION_REQUEST_IDS = count(1)
def _permission_option_supports_kind(kind: str) -> bool:
"""Return whether the installed ACP SDK accepts a permission option kind."""
try:
PermissionOption(option_id="__probe__", kind=kind, name="probe")
except Exception:
return False
return True
def _build_permission_options(
*, allow_permanent: bool, allow_session: bool = True, smart_denied: bool = False,
) -> list[PermissionOption]:
"""Return ACP options that match Hermes approval semantics."""
# A gate that re-asks every time (allow_session=False, e.g. protected
# agent-instruction writes) collapses to the same two options as a Smart
# DENY override — offering a scope Hermes discards would re-prompt every write.
once_only = smart_denied or not allow_session
options = [PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once")]
if not once_only:
# ACP has no session-scoped kind: closest persistent hint, Hermes semantics in the id.
options.append(PermissionOption(option_id="allow_session", kind="allow_always", name="Allow for session"))
if allow_permanent:
options.append(PermissionOption(option_id="allow_always", kind="allow_always", name="Allow always"))
options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny"))
if not once_only and _permission_option_supports_kind("reject_always"):
options.append(PermissionOption(option_id="deny_always", kind="reject_always", name="Deny always"))
return options
def _build_permission_tool_call(command: str, description: str):
"""Return the ``ToolCallUpdate`` (not ``ToolCallStart``) payload attached to a
permission request; unique ``perm-check-N`` ids keep concurrent requests apart."""
import acp as _acp
content_text = f"{description}\n$ {command}" if description else f"$ {command}"
return _acp.update_tool_call(
f"perm-check-{next(_PERMISSION_REQUEST_IDS)}",
title=f"{description}: {command}" if description else command,
kind="execute",
status="pending",
content=[_acp.tool_content(_acp.text_block(content_text))],
raw_input={"command": command, "description": description},
)
def _map_outcome_to_hermes(outcome: object, *, allowed_option_ids: set[str]) -> str:
"""Map an ACP permission outcome into Hermes approval strings."""
if not isinstance(outcome, AllowedOutcome):
return "deny"
if outcome.option_id not in allowed_option_ids:
logger.warning("Permission request returned unknown option_id: %s", outcome.option_id)
return "deny"
return _OPTION_ID_TO_HERMES.get(outcome.option_id, "deny")
def make_approval_callback(request_permission_fn: Callable, loop: asyncio.AbstractEventLoop,
session_id: str, timeout: float = 60.0) -> Callable[..., str]:
"""Return a Hermes approval callback (``command, description, **kw`` as used by
``tools.approval.prompt_dangerous_approval()``) that bridges to the ACP
connection's ``request_permission`` coroutine on ``loop``; auto-denies after ``timeout`` s."""
def _callback(command: str, description: str, *, allow_permanent: bool = True,
allow_session: bool = True, smart_denied: bool = False, **_: object) -> str:
from agent.async_utils import safe_schedule_threadsafe
options = _build_permission_options(
allow_permanent=allow_permanent, allow_session=allow_session, smart_denied=smart_denied,
)
coro = request_permission_fn(
session_id=session_id,
tool_call=_build_permission_tool_call(command, description),
options=options,
)
future = safe_schedule_threadsafe(
coro, loop, logger=logger, log_message="Permission request: failed to schedule on loop",
)
if future is None:
return "deny"
try:
response = future.result(timeout=timeout)
except FutureTimeout:
future.cancel()
logger.warning("Permission request timed out after %ss", timeout)
# Distinct from an explicit deny: tools.approval reports "timed out
# without user response" instead of a user denial.
return "timeout"
except Exception as exc:
future.cancel()
logger.warning("Permission request failed: %s", exc)
return "deny"
if response is None:
return "deny"
return _map_outcome_to_hermes(
response.outcome, allowed_option_ids={option.option_id for option in options},
)
return _callback