"""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.`` 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