395 lines
15 KiB
Python
395 lines
15 KiB
Python
"""Approval context: who is asking, from where, under which policy.
|
|
|
|
Session identity and observability contextvars, the interactive/gateway/cron/
|
|
unattended predicates, and the ``approvals.*`` config readers used by every
|
|
gate in :mod:`tools.approval` (which re-exports all of them). No approval
|
|
state or prompting lives here.
|
|
"""
|
|
|
|
import contextvars
|
|
import logging
|
|
import os
|
|
from typing import Optional
|
|
from hermes_cli.config import cfg_get
|
|
from utils import env_var_enabled, is_truthy_value
|
|
|
|
logger = logging.getLogger("tools.approval")
|
|
|
|
|
|
# Per-thread/per-task gateway session identity. Gateway runs agent turns
|
|
# concurrently in executor threads, so a process-global env var is racy; the
|
|
# env fallback stays for legacy single-threaded callers.
|
|
_approval_session_key: contextvars.ContextVar[str] = contextvars.ContextVar(
|
|
"approval_session_key", default="",
|
|
)
|
|
|
|
_approval_turn_id: contextvars.ContextVar[str] = contextvars.ContextVar(
|
|
"approval_turn_id", default="",
|
|
)
|
|
|
|
_approval_tool_call_id: contextvars.ContextVar[str] = contextvars.ContextVar(
|
|
"approval_tool_call_id", default="",
|
|
)
|
|
|
|
# Hermes session id (observability identity, distinct from the gateway routing
|
|
# session_key). Forwarded to approval hooks so observer plugins attach marks to
|
|
# the REAL session scope — without it they fall back to a synthetic "default"
|
|
# session whose scope never closes, so close-time exporters never ship them.
|
|
_approval_session_id: contextvars.ContextVar[str] = contextvars.ContextVar(
|
|
"approval_session_id", default="",
|
|
)
|
|
|
|
# Interactive-CLI flag. Concurrent ACP sessions share a ThreadPoolExecutor, so
|
|
# mutating os.environ["HERMES_INTERACTIVE"] races: one session's `finally`
|
|
# restore can clobber another's set mid-run, dropping it onto the
|
|
# non-interactive auto-approve path so a dangerous command runs without the
|
|
# approval callback firing (GHSA-96vc-wcxf-jjff). None = unset → fall back to
|
|
# the env var for legacy single-threaded CLI callers.
|
|
_hermes_interactive_ctx: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
"hermes_interactive", default=None,
|
|
)
|
|
|
|
|
|
def set_hermes_interactive_context(interactive: bool) -> contextvars.Token:
|
|
"""Bind interactive mode for the current context instead of mutating os.environ."""
|
|
return _hermes_interactive_ctx.set("1" if interactive else "")
|
|
|
|
|
|
def reset_hermes_interactive_context(token: contextvars.Token) -> None:
|
|
"""Restore the prior value from :func:`set_hermes_interactive_context`."""
|
|
_hermes_interactive_ctx.reset(token)
|
|
|
|
|
|
def _is_interactive_cli() -> bool:
|
|
"""True for an interactive CLI/ACP session (contextvar first, env fallback)."""
|
|
ctx_val = _hermes_interactive_ctx.get()
|
|
if ctx_val is not None:
|
|
return is_truthy_value(ctx_val)
|
|
return env_var_enabled("HERMES_INTERACTIVE")
|
|
|
|
|
|
def _fire_approval_hook(hook_name: str, **kwargs) -> None:
|
|
"""Invoke a plugin lifecycle hook (pre_approval_request / post_approval_response).
|
|
|
|
Lazy-imports the plugin manager (approval.py is imported long before plugins
|
|
are discovered). Never raises: approval flow is safety-critical, plugin
|
|
observability is not.
|
|
"""
|
|
try:
|
|
from hermes_cli.lifecycle import invoke_hook
|
|
except Exception:
|
|
return # plugin system unavailable (bare tool-only imports, minimal tests)
|
|
try:
|
|
kwargs.setdefault("turn_id", _approval_turn_id.get())
|
|
kwargs.setdefault("tool_call_id", _approval_tool_call_id.get())
|
|
_session_id = _approval_session_id.get()
|
|
if _session_id:
|
|
kwargs.setdefault("session_id", _session_id)
|
|
invoke_hook(hook_name, **kwargs)
|
|
except Exception as exc:
|
|
# invoke_hook() swallows per-callback errors; reaching here means the
|
|
# dispatch layer itself failed.
|
|
logger.debug("Approval hook %s dispatch failed: %s", hook_name, exc)
|
|
|
|
|
|
def set_current_session_key(session_key: str) -> contextvars.Token[str]:
|
|
"""Bind the active approval session key to the current context."""
|
|
return _approval_session_key.set(session_key or "")
|
|
|
|
|
|
def reset_current_session_key(token: contextvars.Token[str]) -> None:
|
|
"""Restore the prior approval session key context."""
|
|
_approval_session_key.reset(token)
|
|
|
|
|
|
def set_current_observability_context(
|
|
*,
|
|
turn_id: str = "",
|
|
tool_call_id: str = "",
|
|
session_id: str = "",
|
|
) -> tuple[
|
|
contextvars.Token[str], contextvars.Token[str], contextvars.Token[str]
|
|
]:
|
|
"""Bind active tool correlation IDs to approval hooks."""
|
|
return (
|
|
_approval_turn_id.set(turn_id or ""),
|
|
_approval_tool_call_id.set(tool_call_id or ""),
|
|
_approval_session_id.set(session_id or ""),
|
|
)
|
|
|
|
|
|
def reset_current_observability_context(
|
|
tokens: tuple[
|
|
contextvars.Token[str], contextvars.Token[str], contextvars.Token[str]
|
|
],
|
|
) -> None:
|
|
"""Restore prior approval hook correlation IDs."""
|
|
turn_token, tool_token, session_token = tokens
|
|
_approval_session_id.reset(session_token)
|
|
_approval_tool_call_id.reset(tool_token)
|
|
_approval_turn_id.reset(turn_token)
|
|
|
|
|
|
def get_current_session_key(default: str = "default") -> str:
|
|
"""Return the active session key: approval contextvar → session_context → os.environ."""
|
|
session_key = _approval_session_key.get()
|
|
if session_key:
|
|
return session_key
|
|
from gateway.session_context import get_session_env
|
|
return get_session_env("HERMES_SESSION_KEY", default)
|
|
|
|
|
|
def _session_env_flag(name: str) -> bool:
|
|
"""Truthy session-scoped env flag, contextvar-first so one cron/-q job cannot
|
|
taint unrelated gateway/API/TUI turns in the same process; process env is
|
|
the fallback for CLI tests and older entrypoints."""
|
|
try:
|
|
from gateway.session_context import get_session_env
|
|
|
|
return is_truthy_value(get_session_env(name, ""))
|
|
except Exception:
|
|
return env_var_enabled(name)
|
|
|
|
|
|
def _get_session_platform() -> str:
|
|
"""Return the current gateway platform from contextvars/env fallback."""
|
|
try:
|
|
from gateway.session_context import get_session_env
|
|
|
|
return get_session_env("HERMES_SESSION_PLATFORM", "") or ""
|
|
except Exception:
|
|
return os.getenv("HERMES_SESSION_PLATFORM", "") or ""
|
|
|
|
|
|
def _is_cron_approval_context() -> bool:
|
|
"""True when the current approval decision is running inside cron."""
|
|
return _session_env_flag("HERMES_CRON_SESSION")
|
|
|
|
|
|
#: Programmatic/unattended platforms: no human can answer a prompt and the
|
|
#: adapter has no ``send_exec_approval`` / ``/approve`` surface. Governed by
|
|
#: ``approvals.unattended_mode`` (default deny), mirroring ``cron_mode`` —
|
|
#: never an interactive round-trip that blocks for the full timeout with
|
|
#: nobody to answer (#37284, #87509).
|
|
_UNATTENDED_APPROVAL_PLATFORMS = frozenset({
|
|
"webhook",
|
|
"msgraph_webhook",
|
|
"api_server",
|
|
})
|
|
|
|
|
|
def _is_unattended_platform_approval_context() -> bool:
|
|
"""True when the session platform is a programmatic/unattended surface."""
|
|
return _get_session_platform() in _UNATTENDED_APPROVAL_PLATFORMS
|
|
|
|
|
|
def _is_single_query_approval_context() -> bool:
|
|
"""True for a single-query (-q) session.
|
|
|
|
``hermes chat -q`` exports ``HERMES_INTERACTIVE=1`` (so sudo password
|
|
prompts work) but nobody is waiting to answer approvals; without this
|
|
marker the gate would wait the full timeout for a human who never comes,
|
|
then fail closed and push the agent toward workarounds (e.g. execute_code).
|
|
``approvals.single_query_mode`` makes the path deterministic.
|
|
"""
|
|
return _session_env_flag("HERMES_SINGLE_QUERY_SESSION")
|
|
|
|
|
|
def _is_gateway_approval_context() -> bool:
|
|
"""True inside a gateway/API session that can answer an approval.
|
|
|
|
Legacy integrations set HERMES_GATEWAY_SESSION; concurrent paths bind
|
|
HERMES_SESSION_PLATFORM via contextvars. Cron is NEVER a gateway approval
|
|
context even when it originated from a platform (cron binds the platform
|
|
for delivery routing): falling through would submit a pending approval with
|
|
no listener and block the job indefinitely. Unattended platforms are
|
|
excluded for the same reason (#37284, #87509).
|
|
"""
|
|
from tools import approval as _a
|
|
if _a._is_cron_approval_context():
|
|
return False
|
|
if _is_unattended_platform_approval_context():
|
|
return False
|
|
if env_var_enabled("HERMES_GATEWAY_SESSION"):
|
|
return True
|
|
return bool(_get_session_platform())
|
|
|
|
|
|
def _resolve_cli_approval_callback(approval_callback=None):
|
|
"""Explicit callback, else the per-thread one from ``terminal_tool.set_approval_callback``."""
|
|
if approval_callback is not None:
|
|
return approval_callback
|
|
try:
|
|
from tools.terminal_tool import _get_approval_callback
|
|
return _get_approval_callback()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _should_fall_through_to_cli_approval(
|
|
*,
|
|
is_cli: bool,
|
|
approval_callback,
|
|
notify_cb,
|
|
) -> bool:
|
|
"""Prefer the CLI Dangerous Command panel over a silent pending approval.
|
|
|
|
``HERMES_EXEC_ASK`` (or a platform marker) can leak into an interactive CLI
|
|
process — historically via ``import gateway.run``. Without a gateway notify
|
|
listener the ask branch used to return ``pending_approval`` immediately and
|
|
skip the panel the user can actually answer.
|
|
"""
|
|
return bool(is_cli and approval_callback is not None and notify_cb is None)
|
|
|
|
|
|
def _normalize_approval_mode(mode) -> str:
|
|
"""Normalize approval mode values loaded from YAML/config.
|
|
|
|
YAML 1.1 parses a bare ``off`` as False, so ``mode: off`` arrives as a bool;
|
|
treat it as the intended string mode. Unknown strings (e.g. 'auto') warn and
|
|
fall back to 'manual' instead of silently failing every mode check.
|
|
"""
|
|
_VALID_MODES = ("manual", "smart", "off")
|
|
if isinstance(mode, bool):
|
|
return "off" if mode is False else "manual"
|
|
if isinstance(mode, str):
|
|
normalized = mode.strip().lower()
|
|
if not normalized:
|
|
return "manual"
|
|
if normalized in _VALID_MODES:
|
|
return normalized
|
|
logger.warning(
|
|
"Unknown approvals.mode %r — defaulting to 'manual'. "
|
|
"Valid values: %s",
|
|
mode,
|
|
", ".join(_VALID_MODES),
|
|
)
|
|
return "manual"
|
|
return "manual"
|
|
|
|
|
|
def _get_approval_config() -> dict:
|
|
"""Read the approvals config block.
|
|
|
|
Returns the LIVE config-cache sub-dict (load_config_readonly contract) —
|
|
callers must not mutate it or any nested structure.
|
|
"""
|
|
try:
|
|
from hermes_cli.config import load_config_readonly
|
|
config = load_config_readonly()
|
|
return config.get("approvals", {}) or {}
|
|
except Exception as e:
|
|
logger.warning("Failed to load approval config: %s", e)
|
|
return {}
|
|
|
|
|
|
def _get_approval_mode() -> str:
|
|
"""Return 'manual', 'smart', or 'off' (a hosted-room policy overrides config)."""
|
|
from tools import approval as _a
|
|
try:
|
|
from gateway.hosted_room_execution_policy import (
|
|
current_room_execution_policy,
|
|
)
|
|
|
|
room_policy = current_room_execution_policy()
|
|
if room_policy is not None:
|
|
return room_policy.approval_mode
|
|
except Exception:
|
|
pass
|
|
mode = _a._get_approval_config().get("mode", "manual")
|
|
return _a._normalize_approval_mode(mode)
|
|
|
|
|
|
def _get_approval_timeout() -> int:
|
|
"""Read ``approvals.timeout`` (default 300s: gateway push notifications may
|
|
not be seen for minutes; 60s failed closed before Telegram taps landed).
|
|
|
|
Clamped to ``agent.deadline.MAX_SAFE_TIMEOUT_S`` (~1 year): a larger value
|
|
overflows ``time_t`` inside ``Thread.join`` / ``Lock.acquire`` on macOS and
|
|
crashed every parallel tool batch (#83220). Clamping at the single
|
|
config-read site keeps every consumer platform-safe at once.
|
|
"""
|
|
from tools import approval as _a
|
|
try:
|
|
raw = int(_a._get_approval_config().get("timeout", 300))
|
|
except (ValueError, TypeError):
|
|
return 300
|
|
try:
|
|
from agent.deadline import MAX_SAFE_TIMEOUT_S
|
|
|
|
safe_cap = int(MAX_SAFE_TIMEOUT_S)
|
|
except Exception:
|
|
# Fail CLOSED: the raw value would re-open the overflow this prevents.
|
|
safe_cap = 365 * 24 * 3600
|
|
if raw > safe_cap:
|
|
logger.warning(
|
|
"approvals.timeout=%s exceeds the platform-safe maximum; "
|
|
"clamping to %ss",
|
|
raw,
|
|
safe_cap,
|
|
)
|
|
return safe_cap
|
|
return raw
|
|
|
|
|
|
def _binary_approval_mode(key: str) -> str:
|
|
"""Read ``approvals.<key>`` as 'approve' or 'deny' (default deny)."""
|
|
try:
|
|
from hermes_cli.config import load_config_readonly
|
|
config = load_config_readonly()
|
|
mode = str(cfg_get(config, "approvals", key, default="deny")).lower().strip()
|
|
if mode in {"approve", "off", "allow", "yes"}:
|
|
return "approve"
|
|
return "deny"
|
|
except Exception:
|
|
return "deny"
|
|
|
|
|
|
def _get_cron_approval_mode() -> str:
|
|
"""Read the cron approval mode from config. Returns 'deny' or 'approve'."""
|
|
return _binary_approval_mode("cron_mode")
|
|
|
|
|
|
def _get_single_query_approval_mode() -> str:
|
|
"""Read the single-query (-q) approval mode from config. Returns 'deny' or 'approve'."""
|
|
return _binary_approval_mode("single_query_mode")
|
|
|
|
|
|
def _get_unattended_approval_mode() -> str:
|
|
"""Approval mode for webhook / msgraph_webhook / api_server sessions; default
|
|
deny — an unattended session never silently runs a flagged action unless the
|
|
operator explicitly trusts it."""
|
|
return _binary_approval_mode("unattended_mode")
|
|
|
|
|
|
def _tirith_fail_open() -> bool:
|
|
"""``security.tirith_fail_open`` (default True; True when config is unreadable).
|
|
|
|
False means the operator opted into fail-closed: an un-importable scanner
|
|
must not silently grant access (#20733).
|
|
"""
|
|
try:
|
|
from hermes_cli.config import load_config_readonly as _load_cfg
|
|
_sec = (_load_cfg() or {}).get("security", {}) or {}
|
|
if _sec.get("tirith_enabled", True):
|
|
return bool(_sec.get("tirith_fail_open", True))
|
|
except Exception:
|
|
pass
|
|
return True
|
|
|
|
|
|
def _get_approval_transport_config() -> tuple[str, str | None]:
|
|
"""Return explicitly selected transport and fail-closed fallback mode."""
|
|
try:
|
|
from hermes_cli.config import load_config_readonly
|
|
|
|
config = load_config_readonly() or {}
|
|
approval_config = ((config.get("security") or {}).get("approval") or {})
|
|
selected = str(approval_config.get("transport") or "builtin").strip().lower()
|
|
fallback = str(approval_config.get("transport_fallback") or "").strip().lower()
|
|
except Exception:
|
|
# An unreadable/malformed selection must not silently materialize a
|
|
# prompt on a built-in surface the operator may not be watching.
|
|
return "config-error", None
|
|
return selected or "builtin", "builtin" if fallback == "builtin" else None
|