2625 lines
106 KiB
Python
2625 lines
106 KiB
Python
"""Dangerous command approval -- gate flow, prompting, and per-session state.
|
|
|
|
Single source of truth for the dangerous command system:
|
|
- Pattern detection lives in :mod:`tools.approval_detection` (re-exported here)
|
|
- Per-session approval state (thread-safe, keyed by session_key)
|
|
- Approval prompting (CLI interactive + gateway async, see
|
|
:mod:`tools.approval_gateway_wait`) and plugin transports
|
|
- Smart approval via auxiliary LLM (:mod:`tools.approval_smart`)
|
|
- Human-wait accounting (:mod:`tools.approval_human_wait`)
|
|
- Permanent allowlist persistence (config.yaml)
|
|
"""
|
|
|
|
import contextvars
|
|
import fnmatch
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import re
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from typing import Optional
|
|
from hermes_cli.config import cfg_get
|
|
|
|
from tools.interrupt import is_interrupted
|
|
from utils import env_var_enabled, is_truthy_value
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Frozen at import: reading os.environ per call would let any skill running in
|
|
# the process set this and bypass every approval check (prompt-injection
|
|
# escalation path).
|
|
_YOLO_MODE_FROZEN: bool = is_truthy_value(os.getenv("HERMES_YOLO_MODE", ""))
|
|
|
|
# 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 _prepare_smart_approval_observer(
|
|
*,
|
|
command: str,
|
|
description: str,
|
|
pattern_key: str,
|
|
pattern_keys: list[str],
|
|
session_key: str,
|
|
) -> dict | None:
|
|
"""Redact and emit the pre-decision smart approval observer hook.
|
|
|
|
Redaction is observer-payload preparation, not approval policy: if it fails,
|
|
skip observability rather than leak raw data or block the LLM decision.
|
|
"""
|
|
try:
|
|
from agent.redact import redact_sensitive_text
|
|
|
|
hook_command = redact_sensitive_text(command, force=True)
|
|
hook_description = redact_sensitive_text(description, force=True)
|
|
except Exception as exc:
|
|
logger.debug("Smart approval hook redaction failed: %s", exc)
|
|
return None
|
|
|
|
payload = {
|
|
"command": hook_command,
|
|
"description": hook_description,
|
|
"pattern_key": pattern_key,
|
|
"pattern_keys": list(pattern_keys),
|
|
"session_key": session_key,
|
|
"surface": "smart",
|
|
}
|
|
_fire_approval_hook("pre_approval_request", **payload)
|
|
return payload
|
|
|
|
|
|
def _observe_smart_approval_verdict(payload: dict | None, verdict: str) -> None:
|
|
"""Emit a smart verdict after the auxiliary LLM decision, if safe."""
|
|
if payload is None or verdict not in {"approve", "deny"}:
|
|
return
|
|
_fire_approval_hook(
|
|
"post_approval_response",
|
|
**payload,
|
|
choice=f"smart_{verdict}",
|
|
decided_by="aux_llm",
|
|
)
|
|
|
|
|
|
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).
|
|
"""
|
|
if _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)
|
|
|
|
from tools.approval_detection import ( # noqa: F401 -- re-exported for callers/tests
|
|
_CREDENTIAL_FILES,
|
|
_SYSTEM_CONFIG_PATH,
|
|
_COMMAND_TAIL,
|
|
HARDLINE_PATTERNS,
|
|
_check_sudo_stdin_guard,
|
|
detect_hardline_command,
|
|
DANGEROUS_PATTERNS,
|
|
_approval_key_aliases,
|
|
_normalize_command_for_detection,
|
|
_rewrite_resolved_user_home,
|
|
_rewrite_resolved_hermes_home,
|
|
_MAX_SEPARATOR_FREE_COMMAND_CHARS,
|
|
_PARSER_LIMIT_DESCRIPTION,
|
|
_MALFORMED_EXEC_DESCRIPTION,
|
|
_bash_exec_payload,
|
|
_read_shell_word,
|
|
_deobfuscate_shell_word_for_detection,
|
|
_iter_shell_command_starts,
|
|
_command_detection_variants,
|
|
detect_dangerous_command,
|
|
)
|
|
from tools.approval_human_wait import ( # noqa: F401 -- re-exported for callers/tests
|
|
_HumanWaitState,
|
|
_human_wait_lock,
|
|
_human_wait_states,
|
|
_HUMAN_WAIT_MAX_SESSIONS,
|
|
HUMAN_WAIT_MARGIN_S,
|
|
human_wait_ceiling,
|
|
_clamped_window_seconds,
|
|
_human_wait_state,
|
|
human_wait_window,
|
|
human_wait_seconds,
|
|
)
|
|
from tools.approval_smart import ( # noqa: F401 -- re-exported for callers/tests
|
|
_strip_shell_comments,
|
|
_strip_line_comment,
|
|
_get_smart_policy,
|
|
_smart_approve,
|
|
)
|
|
from tools.approval_gateway_wait import ( # noqa: F401 -- re-exported for callers/tests
|
|
_ApprovalEntry,
|
|
_await_coalesced_leader,
|
|
_await_gateway_decision,
|
|
)
|
|
|
|
|
|
def _match_user_deny_rule(command: str) -> str | None:
|
|
"""Return the matching ``approvals.deny`` glob, or None.
|
|
|
|
User-defined fnmatch globs that block unconditionally — like the hardline
|
|
floor, a match fires BEFORE the yolo / mode=off bypass ("never let the
|
|
agent run this, even under yolo"). Case-insensitive, run over the same
|
|
normalized/deobfuscated variants the dangerous-pattern detector uses so
|
|
quoting tricks (``r\\m``, ``git st""atus``) can't sidestep a rule.
|
|
"""
|
|
try:
|
|
deny_patterns = _get_approval_config().get("deny") or []
|
|
except Exception:
|
|
return None
|
|
globs = [p.strip() for p in deny_patterns
|
|
if isinstance(p, str) and p.strip()]
|
|
if not globs:
|
|
return None
|
|
for command_variant in _command_detection_variants(command):
|
|
candidate = command_variant.lower().strip()
|
|
for pattern in globs:
|
|
if fnmatch.fnmatchcase(candidate, pattern.lower()):
|
|
return pattern
|
|
return None
|
|
|
|
|
|
def _user_deny_block_result(pattern: str) -> dict:
|
|
"""Build the standard block result for an ``approvals.deny`` match."""
|
|
return {
|
|
"approved": False,
|
|
"user_deny": True,
|
|
"message": (
|
|
f"BLOCKED: this command matches the user-defined deny rule "
|
|
f"'{pattern}' (approvals.deny in config.yaml). It cannot be "
|
|
"executed via the agent — not even with --yolo, /yolo, or "
|
|
"approvals.mode=off. Do NOT retry or rephrase this command; "
|
|
"the user has explicitly forbidden it."
|
|
),
|
|
}
|
|
|
|
|
|
def _save_blocked_payload(command: str) -> Optional[str]:
|
|
"""Persist a parser-limit-blocked command as a runnable script.
|
|
|
|
The parser-limit block fires on payload SIZE/shape, not the operation —
|
|
usually a legitimate script the model inlined. Saving it makes recovery one
|
|
turn (`bash <file>`) instead of two, and is strictly safer than the
|
|
hint-only path: the file goes through the normal execution pipeline
|
|
(including the referenced-script content guard) and nothing runs here.
|
|
Returns the path, or None on any failure (hint falls back to write_file).
|
|
"""
|
|
try:
|
|
from hermes_constants import get_hermes_home
|
|
script_dir = get_hermes_home() / "cache" / "blocked-scripts"
|
|
script_dir.mkdir(parents=True, exist_ok=True)
|
|
# Opportunistic cleanup: blocked payloads older than 7 days.
|
|
cutoff = time.time() - 7 * 86400
|
|
for old in script_dir.glob("blocked-*.sh"):
|
|
try:
|
|
if old.stat().st_mtime < cutoff:
|
|
old.unlink()
|
|
except OSError:
|
|
pass
|
|
path = script_dir / f"blocked-{int(time.time())}-{uuid.uuid4().hex[:8]}.sh"
|
|
path.write_text(
|
|
"#!/bin/bash\n"
|
|
"# Auto-saved by Hermes: this command exceeded the inline command\n"
|
|
"# parser limit and was blocked from direct execution. Review it,\n"
|
|
"# then run it via: bash " + str(path) + "\n"
|
|
+ command
|
|
+ ("\n" if not command.endswith("\n") else ""),
|
|
encoding="utf-8", errors="replace",
|
|
)
|
|
return str(path)
|
|
except Exception:
|
|
logger.debug("failed to save blocked payload", exc_info=True)
|
|
return None
|
|
|
|
|
|
def _hardline_block_result(description: str, command: str = "") -> dict:
|
|
"""Build the standard block result for a hardline match."""
|
|
message = (
|
|
f"BLOCKED (hardline): {description}. "
|
|
"This command is on the unconditional blocklist and cannot "
|
|
"be executed via the agent — not even with --yolo, /yolo, "
|
|
"approvals.mode=off, or cron approve mode. If you genuinely "
|
|
"need to run it, run it yourself in a terminal outside the "
|
|
"agent."
|
|
)
|
|
# The parser-limit block is almost always a giant inline payload, not a
|
|
# forbidden operation, and is typically followed by blind rephrase
|
|
# retries — point at the saved script (or the write_file recipe).
|
|
if description in (_PARSER_LIMIT_DESCRIPTION, _MALFORMED_EXEC_DESCRIPTION):
|
|
saved = _save_blocked_payload(command) if command else None
|
|
if saved:
|
|
message += (
|
|
" RECOVERY: this block fires on oversized/unparseable inline "
|
|
"command payloads (heredocs, giant one-liners), not on the "
|
|
f"operation itself. Your command was saved to {saved} — "
|
|
f"review it, then run: terminal(command=\"bash {saved}\"). "
|
|
"Do not retry inline."
|
|
)
|
|
else:
|
|
message += (
|
|
" RECOVERY: this block fires on oversized/unparseable inline "
|
|
"command payloads (heredocs, giant one-liners), not on the "
|
|
"operation itself. Write the script to a file with write_file, "
|
|
"then run it: terminal(command=\"bash /path/script.sh\") or "
|
|
"\"python3 /path/script.py\". Do not retry inline."
|
|
)
|
|
return {
|
|
"approved": False,
|
|
"hardline": True,
|
|
"message": message,
|
|
}
|
|
|
|
|
|
def _sudo_stdin_block_result(description: str) -> dict:
|
|
"""Build the standard block result for sudo stdin guard."""
|
|
return {
|
|
"approved": False,
|
|
"message": (
|
|
f"BLOCKED: {description}. "
|
|
"Do not pipe passwords to 'sudo -S' — this is a brute-force "
|
|
"attack vector. Set SUDO_PASSWORD in your .env file if the "
|
|
"agent needs passwordless sudo, or run the sudo command "
|
|
"manually in your own terminal."
|
|
),
|
|
}
|
|
|
|
|
|
# =========================================================================
|
|
# Per-session approval state (thread-safe)
|
|
# =========================================================================
|
|
|
|
_lock = threading.Lock()
|
|
_pending: dict[str, dict] = {}
|
|
_session_approved: dict[str, set] = {}
|
|
_session_yolo: set[str] = set()
|
|
_permanent_approved: set = set()
|
|
|
|
# =========================================================================
|
|
# Consecutive-denial circuit breaker for smart approvals
|
|
# =========================================================================
|
|
# Nothing stops the model from retrying variants of a smart-denied command —
|
|
# each retry burns another guardian LLM call. After
|
|
# ``approvals.denial_breaker_threshold`` consecutive guardian DENY verdicts in
|
|
# one session (default 3; 0 disables) the deny message escalates to a
|
|
# hard-stop instruction; any approval resets the tally. Only TOOL RESULT text
|
|
# changes — no history surgery, no interrupts — so it is prompt-cache-invariant.
|
|
_denial_tally: dict[str, int] = {}
|
|
# Small cap so an army of short-lived session keys cannot grow it without
|
|
# bound; oldest (least recently denied) entries are evicted.
|
|
_DENIAL_TALLY_MAX_SESSIONS = 256
|
|
|
|
|
|
def _get_denial_breaker_threshold() -> int:
|
|
"""``approvals.denial_breaker_threshold``: default 3; 0 or negative disables."""
|
|
try:
|
|
return int(_get_approval_config().get("denial_breaker_threshold", 3))
|
|
except (ValueError, TypeError):
|
|
return 3
|
|
|
|
|
|
def _record_denial(session_key: str) -> int:
|
|
"""Increment and return the session's consecutive guardian-denial count.
|
|
|
|
Pop-and-reinsert keeps actively-denying sessions at the most-recent end
|
|
so insertion-ordered eviction drops genuinely idle keys.
|
|
"""
|
|
with _lock:
|
|
count = _denial_tally.pop(session_key, 0) + 1
|
|
_denial_tally[session_key] = count
|
|
while len(_denial_tally) > _DENIAL_TALLY_MAX_SESSIONS:
|
|
_denial_tally.pop(next(iter(_denial_tally)))
|
|
return count
|
|
|
|
|
|
def _reset_denials(session_key: str) -> None:
|
|
"""Clear the session's consecutive-denial tally (an approval happened)."""
|
|
with _lock:
|
|
_denial_tally.pop(session_key, None)
|
|
|
|
|
|
def _denial_breaker_addendum(session_key: str) -> str:
|
|
"""Escalated hard-stop text once the breaker has tripped, else ''.
|
|
|
|
Read-only: callers increment via :func:`_record_denial` on the guardian
|
|
DENY verdict. The result is appended verbatim to the deny message.
|
|
"""
|
|
with _lock:
|
|
count = _denial_tally.get(session_key, 0)
|
|
threshold = _get_denial_breaker_threshold()
|
|
if threshold <= 0 or count < threshold:
|
|
return ""
|
|
logger.warning(
|
|
"Smart-approval circuit breaker tripped for session %s: "
|
|
"%d consecutive denials (threshold %d)",
|
|
session_key, count, threshold,
|
|
)
|
|
return (
|
|
f" CIRCUIT BREAKER: {count} consecutive commands were blocked by "
|
|
"the security reviewer. STOP attempting variations of this "
|
|
"operation. Report the blocked operation to the user and either "
|
|
"ask them to run it manually or use /approve."
|
|
)
|
|
|
|
# =========================================================================
|
|
# Gateway approval queue (the blocking wait loop lives in approval_gateway_wait)
|
|
# =========================================================================
|
|
|
|
_gateway_queues: dict[str, list] = {} # session_key → [_ApprovalEntry, …]
|
|
_gateway_notify_cbs: dict[str, object] = {} # session_key → callable(approval_data)
|
|
|
|
|
|
def register_gateway_notify(session_key: str, cb) -> None:
|
|
"""Register ``cb(approval_data: dict) -> None`` for sending approval requests.
|
|
|
|
The callback bridges sync→async: it runs in the agent thread and must
|
|
schedule the actual send on the event loop.
|
|
"""
|
|
with _lock:
|
|
_gateway_notify_cbs[session_key] = cb
|
|
|
|
|
|
def unregister_gateway_notify(session_key: str) -> None:
|
|
"""Unregister the callback and wake ALL blocked threads for this session so
|
|
they don't hang forever (agent run finished or interrupted)."""
|
|
with _lock:
|
|
_gateway_notify_cbs.pop(session_key, None)
|
|
entries = _gateway_queues.pop(session_key, [])
|
|
for entry in entries:
|
|
entry.event.set()
|
|
|
|
|
|
def resolve_gateway_approval(session_key: str, choice: str,
|
|
resolve_all: bool = False,
|
|
reason: Optional[str] = None,
|
|
request_id: Optional[str] = None) -> int:
|
|
"""Unblock waiting agent thread(s) from the gateway's /approve or /deny handler.
|
|
|
|
*resolve_all* resolves every pending approval (``/approve all``); otherwise
|
|
the oldest (FIFO) or the one matching *request_id*. *reason* is the free
|
|
text from ``/deny <reason>``, relayed to the agent in the BLOCKED message.
|
|
Returns the number resolved (0 = nothing pending).
|
|
"""
|
|
with _lock:
|
|
queue = _gateway_queues.get(session_key)
|
|
if not queue:
|
|
return 0
|
|
if request_id:
|
|
targets = [entry for entry in queue if entry.data.get("request_id") == request_id]
|
|
if not targets:
|
|
return 0
|
|
queue[:] = [entry for entry in queue if entry not in targets]
|
|
elif resolve_all:
|
|
targets = list(queue)
|
|
queue.clear()
|
|
else:
|
|
targets = [queue.pop(0)]
|
|
if not queue:
|
|
_gateway_queues.pop(session_key, None)
|
|
|
|
for entry in targets:
|
|
entry.result = choice
|
|
if reason:
|
|
entry.reason = reason
|
|
entry.event.set()
|
|
return len(targets)
|
|
|
|
|
|
def list_gateway_approvals(session_key: str) -> list[dict]:
|
|
"""Return replay-safe snapshots of unresolved approvals for one session."""
|
|
with _lock:
|
|
return [dict(entry.data) for entry in _gateway_queues.get(session_key, [])]
|
|
|
|
|
|
def ack_gateway_approval(session_key: str, request_id: str) -> bool:
|
|
"""Record that a client received a particular pending approval request."""
|
|
with _lock:
|
|
for entry in _gateway_queues.get(session_key, []):
|
|
if entry.data.get("request_id") == request_id:
|
|
entry.acknowledged = True
|
|
return True
|
|
return False
|
|
|
|
|
|
def has_blocking_approval(session_key: str) -> bool:
|
|
"""Check if a session has one or more blocking gateway approvals waiting."""
|
|
with _lock:
|
|
return bool(_gateway_queues.get(session_key))
|
|
|
|
|
|
def get_pending_gateway_approval(session_key: str) -> dict | None:
|
|
"""Copy of the oldest unresolved gateway approval, for reconnecting clients
|
|
to restore a prompt. Read-only snapshot — the queue stays authoritative."""
|
|
if not session_key:
|
|
return None
|
|
with _lock:
|
|
queue = _gateway_queues.get(session_key)
|
|
if not queue:
|
|
return None
|
|
return dict(queue[0].data)
|
|
|
|
|
|
def submit_pending(session_key: str, approval: dict):
|
|
"""Store a pending approval request for a session."""
|
|
with _lock:
|
|
_pending[session_key] = approval
|
|
|
|
|
|
def approve_session(session_key: str, pattern_key: str):
|
|
"""Approve a pattern for this session only."""
|
|
with _lock:
|
|
_session_approved.setdefault(session_key, set()).add(pattern_key)
|
|
|
|
|
|
def _release_permission_mode_dependents(session_key: str) -> None:
|
|
"""Drop resources whose immutable mode derives from Hermes YOLO.
|
|
|
|
Lazy import so approval-only sessions never load computer-use. Releasing on
|
|
both edges makes enabling YOLO replace a standard backend and disabling it
|
|
revoke a private unrestricted daemon immediately.
|
|
"""
|
|
try:
|
|
from tools.computer_use import release_computer_use_session
|
|
|
|
release_computer_use_session(session_key)
|
|
except Exception:
|
|
logger.debug(
|
|
"Failed to release permission-mode dependent resources for %s",
|
|
session_key,
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
def enable_session_yolo(session_key: str) -> None:
|
|
"""Enable YOLO bypass for a single session key."""
|
|
if not session_key:
|
|
return
|
|
with _lock:
|
|
_session_yolo.add(session_key)
|
|
_release_permission_mode_dependents(session_key)
|
|
|
|
|
|
def disable_session_yolo(session_key: str) -> None:
|
|
"""Disable YOLO bypass for a single session key."""
|
|
if not session_key:
|
|
return
|
|
with _lock:
|
|
_session_yolo.discard(session_key)
|
|
_release_permission_mode_dependents(session_key)
|
|
|
|
|
|
def clear_session(session_key: str) -> None:
|
|
"""Remove all approval and yolo state for a given session."""
|
|
if not session_key:
|
|
return
|
|
with _lock:
|
|
_session_approved.pop(session_key, None)
|
|
_session_yolo.discard(session_key)
|
|
_pending.pop(session_key, None)
|
|
entries = _gateway_queues.pop(session_key, [])
|
|
for entry in entries:
|
|
# Cancel blocked waits now so the old run unwinds instead of idling
|
|
# until timeout.
|
|
entry.result = "deny"
|
|
entry.event.set()
|
|
_release_permission_mode_dependents(session_key)
|
|
# Session-persistent code kernels (local and remote) share this owner key
|
|
# and die at the same boundary so a finished conversation cannot leak a
|
|
# live interpreter.
|
|
try:
|
|
from tools.code_kernel import shutdown_kernels_for_owner
|
|
|
|
shutdown_kernels_for_owner(session_key)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
from tools.code_kernel_remote import shutdown_remote_kernels_for_owner
|
|
|
|
shutdown_remote_kernels_for_owner(session_key)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def is_session_yolo_enabled(session_key: str) -> bool:
|
|
"""Return True when YOLO bypass is enabled for a specific session."""
|
|
if not session_key:
|
|
return False
|
|
with _lock:
|
|
return session_key in _session_yolo
|
|
|
|
|
|
def is_current_session_yolo_enabled() -> bool:
|
|
"""Return True when the active approval session has YOLO bypass enabled."""
|
|
return is_session_yolo_enabled(get_current_session_key(default=""))
|
|
|
|
|
|
def is_approved(session_key: str, pattern_key: str) -> bool:
|
|
"""Check if a pattern is approved (session-scoped or permanent).
|
|
|
|
Accepts the canonical key and the legacy regex-derived key so existing
|
|
command_allowlist entries keep working after key migrations.
|
|
"""
|
|
aliases = _approval_key_aliases(pattern_key)
|
|
with _lock:
|
|
if any(alias in _permanent_approved for alias in aliases):
|
|
return True
|
|
session_approvals = _session_approved.get(session_key, set())
|
|
return any(alias in session_approvals for alias in aliases)
|
|
|
|
|
|
def approve_permanent(pattern_key: str):
|
|
"""Add a pattern to the permanent allowlist."""
|
|
with _lock:
|
|
_permanent_approved.add(pattern_key)
|
|
|
|
|
|
def load_permanent(patterns: set):
|
|
"""Bulk-load permanent allowlist entries from config."""
|
|
with _lock:
|
|
_permanent_approved.update(patterns)
|
|
|
|
|
|
def _persist_choice(session_key: str, choice: str, warnings: list[tuple]) -> None:
|
|
"""Persist a human ``session``/``always`` choice for each ``(key, _, is_tirith)``.
|
|
|
|
Tirith findings are session-max by design: no broad permanent allowlisting
|
|
of content-level security findings, so ``always`` downgrades them to session.
|
|
``once`` (or any other choice) persists nothing.
|
|
"""
|
|
for key, _, is_tirith in warnings:
|
|
if choice == "session" or (choice == "always" and is_tirith):
|
|
approve_session(session_key, key)
|
|
elif choice == "always":
|
|
approve_session(session_key, key)
|
|
approve_permanent(key)
|
|
save_permanent_allowlist(_permanent_approved)
|
|
|
|
|
|
# Shell control characters that make a command compound when they appear
|
|
# OUTSIDE quotes. Inside quotes they are literal to the outer shell — but they
|
|
# become executable again if an option like `-c`/`-e`/`--eval` (or a git
|
|
# `-c alias.x=!...`) hands the quoted argument to another interpreter, so quoted
|
|
# control chars only disqualify a command when such an option is present.
|
|
_SHELL_CONTROL_CHARS = frozenset("\n\r;&|<>`$()")
|
|
_REINTERPRETED_ARGUMENT_RE = re.compile(
|
|
r"(?:^|[ \t])(?:-[^-\s]*[ce]|--(?:command|eval))(?:[= \t]|$)"
|
|
)
|
|
|
|
|
|
def _has_allowlist_shell_operator(command: str) -> bool:
|
|
"""Return True when a command is too compound for the allowlist shortcut.
|
|
|
|
Quote-aware: metacharacters inside quotes or behind a backslash are literal
|
|
arguments (``cargo bench -- '^a(b|c)$'``), not shell syntax. Still
|
|
disqualifying: ``$`` or backtick inside DOUBLE quotes (expansion stays
|
|
active), and any quoted/escaped control character when the command also
|
|
carries a ``-c``/``-e``/``--command``/``--eval``-style option that would
|
|
hand the quoted text to another interpreter.
|
|
"""
|
|
command = command or ""
|
|
quote = None # None | "'" | '"'
|
|
has_reinterpretable = False
|
|
i = 0
|
|
n = len(command)
|
|
while i < n:
|
|
ch = command[i]
|
|
if quote == "'":
|
|
if ch == "'":
|
|
quote = None
|
|
elif ch in _SHELL_CONTROL_CHARS:
|
|
has_reinterpretable = True
|
|
i += 1
|
|
continue
|
|
if ch == "\\":
|
|
nxt = command[i + 1] if i + 1 < n else ""
|
|
if nxt in _SHELL_CONTROL_CHARS:
|
|
has_reinterpretable = True
|
|
i += 2
|
|
continue
|
|
if quote == '"':
|
|
if ch == '"':
|
|
quote = None
|
|
elif ch in ("`", "$"):
|
|
return True # expansion is active inside double quotes
|
|
elif ch in _SHELL_CONTROL_CHARS:
|
|
has_reinterpretable = True
|
|
i += 1
|
|
continue
|
|
if ch in ("'", '"'):
|
|
quote = ch
|
|
i += 1
|
|
continue
|
|
if ch == "$":
|
|
# Unquoted $ is only compound when it opens a substitution
|
|
# ("$HOME" stays simple, matching the historical `\$\(` behavior).
|
|
if i + 1 < n and command[i + 1] == "(":
|
|
return True
|
|
i += 1
|
|
continue
|
|
if ch in _SHELL_CONTROL_CHARS and ch not in "()":
|
|
return True
|
|
i += 1
|
|
# An unterminated quote means we can't reason about the command shape.
|
|
if quote is not None:
|
|
return True
|
|
return has_reinterpretable and bool(_REINTERPRETED_ARGUMENT_RE.search(command))
|
|
|
|
|
|
def _command_matches_permanent_allowlist(command: str) -> bool:
|
|
"""True when command_allowlist holds this exact command text or a matching glob.
|
|
|
|
Permanent approvals historically store dangerous-pattern keys such as
|
|
``recursive delete``; manual entries are command text, possibly with
|
|
shell-style wildcards like ``podman *``.
|
|
"""
|
|
command = (command or "").strip()
|
|
if not command or _has_allowlist_shell_operator(command):
|
|
return False
|
|
|
|
with _lock:
|
|
patterns = tuple(_permanent_approved)
|
|
|
|
for pattern in patterns:
|
|
if not isinstance(pattern, str):
|
|
continue
|
|
pattern = pattern.strip()
|
|
if not pattern:
|
|
continue
|
|
if command == pattern:
|
|
return True
|
|
if any(ch in pattern for ch in "*?[") and fnmatch.fnmatchcase(command, pattern):
|
|
return True
|
|
return False
|
|
|
|
|
|
# =========================================================================
|
|
# Config persistence for permanent allowlist
|
|
# =========================================================================
|
|
|
|
def load_permanent_allowlist() -> set:
|
|
"""Load ``command_allowlist`` from config and sync it into the approval state
|
|
so is_approved() honors 'always' choices from previous sessions."""
|
|
try:
|
|
from hermes_cli.config import load_config_readonly
|
|
config = load_config_readonly()
|
|
patterns = set(config.get("command_allowlist", []) or [])
|
|
if patterns:
|
|
load_permanent(patterns)
|
|
return patterns
|
|
except Exception as e:
|
|
logger.warning("Failed to load permanent allowlist: %s", e)
|
|
return set()
|
|
|
|
|
|
def save_permanent_allowlist(patterns: set):
|
|
"""Save permanently allowed command patterns to config."""
|
|
try:
|
|
from hermes_cli.config import load_config, save_config
|
|
config = load_config()
|
|
config["command_allowlist"] = list(patterns)
|
|
save_config(config)
|
|
except Exception as e:
|
|
logger.warning("Could not save allowlist: %s", e)
|
|
|
|
|
|
# =========================================================================
|
|
# Approval prompting (CLI)
|
|
# =========================================================================
|
|
|
|
def prompt_dangerous_approval(command: str, description: str,
|
|
timeout_seconds: int | None = None,
|
|
allow_permanent: bool = True,
|
|
approval_callback=None,
|
|
*, allow_session: bool = True,
|
|
smart_denied: bool = False) -> str:
|
|
"""Prompt the user to approve a dangerous command (CLI only).
|
|
|
|
Args:
|
|
allow_permanent: When False, hide [a]lways (tirith warnings present:
|
|
broad permanent allowlisting is wrong for content-level findings).
|
|
allow_session: When False, hide [s]ession too — the caller grants one
|
|
operation and re-asks next time (the protected agent-instruction
|
|
gate in ``tools/file_tools.py``). Offering a scope the caller
|
|
discards makes every later write re-prompt and reads as broken.
|
|
smart_denied: Owner override of a Smart DENY: offer only once/deny.
|
|
approval_callback: CLI prompt_toolkit callback,
|
|
``(command, description, *, allow_permanent=True,
|
|
allow_session=True, smart_denied=False) -> str``. Legacy
|
|
signatures keep working while both keywords hold their defaults.
|
|
|
|
Returns: 'once', 'session', 'always', 'deny', or 'timeout'. 'timeout'
|
|
means no user response — still blocked (fail-closed), but callers
|
|
report "no response" rather than an explicit denial.
|
|
"""
|
|
if timeout_seconds is None:
|
|
timeout_seconds = _get_approval_timeout()
|
|
|
|
# Everything below is a human prompt (callback panel or input() fallback,
|
|
# both bounded by the approval deadline): record it as human-wait time so
|
|
# the concurrent batch deadline excludes it (#79719).
|
|
with human_wait_window():
|
|
return _prompt_dangerous_approval_inner(
|
|
command,
|
|
description,
|
|
timeout_seconds,
|
|
allow_permanent,
|
|
approval_callback,
|
|
allow_session=allow_session,
|
|
smart_denied=smart_denied,
|
|
)
|
|
|
|
|
|
_CLI_CHOICE_ALIASES = {
|
|
"o": "once", "once": "once",
|
|
"s": "session", "session": "session",
|
|
"a": "always", "always": "always",
|
|
}
|
|
_CLI_CHOICE_I18N = {
|
|
"once": "approval.allowed_once",
|
|
"session": "approval.allowed_session",
|
|
"always": "approval.allowed_always",
|
|
"deny": "approval.denied",
|
|
}
|
|
|
|
|
|
def _prompt_dangerous_approval_inner(command: str, description: str,
|
|
timeout_seconds: int,
|
|
allow_permanent: bool = True,
|
|
approval_callback=None,
|
|
*, allow_session: bool = True,
|
|
smart_denied: bool = False) -> str:
|
|
# Redact before any user-visible rendering; the original `command` is
|
|
# still what executes after approval. Same redactor as memory/log
|
|
# sanitization so tokens mask consistently across surfaces.
|
|
from agent.redact import redact_sensitive_text
|
|
display_command = redact_sensitive_text(command)
|
|
display_description = redact_sensitive_text(description)
|
|
|
|
# Smart DENY and a session-less gate both reduce the menu to once/deny.
|
|
once_only = smart_denied or not allow_session
|
|
|
|
if approval_callback is not None:
|
|
try:
|
|
callback_kwargs = {"allow_permanent": allow_permanent}
|
|
if not allow_session:
|
|
callback_kwargs["allow_session"] = False
|
|
if smart_denied:
|
|
callback_kwargs["smart_denied"] = True
|
|
return approval_callback(
|
|
display_command, display_description, **callback_kwargs
|
|
)
|
|
except Exception as e:
|
|
logger.error("Approval callback failed: %s", e, exc_info=True)
|
|
return "deny"
|
|
|
|
# Fail-closed guard: when prompt_toolkit owns the terminal and no callback
|
|
# is registered on this thread, the input() fallback would spawn a daemon
|
|
# thread whose read never sees Enter (keystrokes go to prompt_toolkit) —
|
|
# an invisible deadlock (#15216). Deny loudly instead; threads needing
|
|
# interactive approval must install a callback via
|
|
# tools.terminal_tool.set_approval_callback() first.
|
|
try:
|
|
from prompt_toolkit.application.current import get_app_or_none
|
|
if get_app_or_none() is not None:
|
|
logger.warning(
|
|
"Dangerous-command approval requested on a thread with no "
|
|
"approval callback while prompt_toolkit is active; denying "
|
|
"to avoid stdin deadlock. command=%r description=%r",
|
|
command, description,
|
|
)
|
|
return "deny"
|
|
except Exception:
|
|
pass # prompt_toolkit absent or detection failed: legacy input() path is safe
|
|
|
|
os.environ["HERMES_SPINNER_PAUSE"] = "1"
|
|
try:
|
|
from agent.i18n import t
|
|
if once_only:
|
|
prompt = t("approval.prompt_smart_deny")
|
|
else:
|
|
prompt = t("approval.prompt_long") if allow_permanent else t("approval.prompt_short")
|
|
while True:
|
|
print()
|
|
print(f" {t('approval.dangerous_header', description=display_description)}")
|
|
print(f" {display_command}")
|
|
print()
|
|
if once_only:
|
|
print(t("approval.choose_smart_deny"))
|
|
elif allow_permanent:
|
|
print(t("approval.choose_long"))
|
|
else:
|
|
print(t("approval.choose_short"))
|
|
print()
|
|
sys.stdout.flush()
|
|
|
|
result = {"choice": ""}
|
|
|
|
def get_input():
|
|
try:
|
|
result["choice"] = input(prompt).strip().lower()
|
|
except (EOFError, OSError):
|
|
result["choice"] = ""
|
|
|
|
thread = threading.Thread(target=get_input, daemon=True)
|
|
thread.start()
|
|
thread.join(timeout=timeout_seconds)
|
|
|
|
if thread.is_alive():
|
|
print("\n" + t("approval.timeout"))
|
|
return "timeout" # distinct from deny: the user never answered
|
|
|
|
choice = result["choice"]
|
|
if once_only:
|
|
choice_map = {
|
|
**{
|
|
value: "once"
|
|
for value in t("approval.smart_deny_once_inputs").split(",")
|
|
},
|
|
**{
|
|
value: "deny"
|
|
for value in t("approval.smart_deny_deny_inputs").split(",")
|
|
},
|
|
}
|
|
decision = choice_map.get(choice, "deny")
|
|
else:
|
|
decision = _CLI_CHOICE_ALIASES.get(choice, "deny")
|
|
if decision == "always" and not allow_permanent:
|
|
decision = "session"
|
|
print(t(_CLI_CHOICE_I18N[decision]))
|
|
return decision
|
|
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\n" + t("approval.cancelled"))
|
|
return "deny"
|
|
finally:
|
|
if "HERMES_SPINNER_PAUSE" in os.environ:
|
|
del os.environ["HERMES_SPINNER_PAUSE"]
|
|
print()
|
|
sys.stdout.flush()
|
|
|
|
|
|
# =========================================================================
|
|
# Config readers
|
|
# =========================================================================
|
|
|
|
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)."""
|
|
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 = _get_approval_config().get("mode", "manual")
|
|
return _normalize_approval_mode(mode)
|
|
|
|
|
|
def is_approval_bypass_active_for_session(session_key: str) -> bool:
|
|
"""Canonical three-source bypass check: process ``--yolo`` (frozen at
|
|
import), the session-scoped gateway ``/yolo`` toggle, ``approvals.mode: off``.
|
|
|
|
Pure bypass sub-expression only — hardline blocklist / permanent allowlist
|
|
are the caller's job.
|
|
"""
|
|
return (
|
|
_YOLO_MODE_FROZEN
|
|
or is_session_yolo_enabled(session_key)
|
|
or _get_approval_mode() == "off"
|
|
)
|
|
|
|
|
|
def is_approval_bypass_active() -> bool:
|
|
"""Return whether the current approval context has bypass enabled."""
|
|
return is_approval_bypass_active_for_session(
|
|
get_current_session_key(default="")
|
|
)
|
|
|
|
|
|
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.
|
|
"""
|
|
try:
|
|
raw = int(_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
|
|
|
|
|
|
# =========================================================================
|
|
# Result builders and decision helpers shared by the gates
|
|
# =========================================================================
|
|
|
|
_APPROVED = {"approved": True, "message": None}
|
|
|
|
|
|
def _approved() -> dict:
|
|
return dict(_APPROVED)
|
|
|
|
|
|
def _denied(message: str, *, pattern_key: str, description: str,
|
|
outcome: str, **extra) -> dict:
|
|
"""Standard non-consent result: the agent must not retry or rephrase."""
|
|
result = {
|
|
"approved": False,
|
|
"message": message,
|
|
"pattern_key": pattern_key,
|
|
"description": description,
|
|
"outcome": outcome,
|
|
"user_consent": False,
|
|
}
|
|
result.update(extra)
|
|
return result
|
|
|
|
|
|
def _blocked(message: str, *, pattern_key: str, description: str) -> dict:
|
|
"""Non-interactive block (cron / -q / unattended / no-human): no consent keys."""
|
|
return {
|
|
"approved": False,
|
|
"message": message,
|
|
"pattern_key": pattern_key,
|
|
"description": description,
|
|
}
|
|
|
|
|
|
def _user_approved(session_key: str, description: str) -> dict:
|
|
"""A human approval (incl. ESCALATE-then-approve or a smart-DENY owner
|
|
override) resets the consecutive-denial tally."""
|
|
_reset_denials(session_key)
|
|
return {"approved": True, "message": None,
|
|
"user_approved": True, "description": description}
|
|
|
|
|
|
def _gateway_refusal(decision: dict):
|
|
"""``(reason, reason_addendum, timeout_addendum, outcome, deny_reason)`` when
|
|
the gateway decision is not an approval, else None.
|
|
|
|
Consent contract: silence is NOT consent, and an explicit deny is a hard
|
|
halt — both produce a BLOCKED outcome. ``/deny <reason>`` free text is
|
|
relayed verbatim so the agent can adapt rather than only hearing "denied".
|
|
"""
|
|
resolved, choice = decision["resolved"], decision["choice"]
|
|
deny_reason = decision.get("reason")
|
|
if resolved and choice is not None and choice != "deny":
|
|
return None
|
|
if not resolved:
|
|
return ("timed out without user response", "", " Silence is not consent.",
|
|
"timeout", deny_reason)
|
|
reason_addendum = f' Reason given by the user: "{deny_reason}".' if deny_reason else ""
|
|
return ("denied by user", reason_addendum, "", "denied", deny_reason)
|
|
|
|
|
|
def _gateway_notify_cb(session_key: str):
|
|
with _lock:
|
|
return _gateway_notify_cbs.get(session_key)
|
|
|
|
|
|
def _gateway_approval_data(display_command: str, display_description: str,
|
|
pattern_key: str, pattern_keys: list[str], *,
|
|
allow_permanent: bool, smart_denied: bool) -> dict:
|
|
"""Payload the gateway renders to Discord/Slack/etc. (screenshottable — the
|
|
caller passes REDACTED copies; the raw command still executes after
|
|
approval and persistence keys off pattern_key, so redaction is display-only).
|
|
|
|
Smart DENY overrides are one-operation decisions, so the UI must not offer
|
|
a permanent scope. Session approval is safe for every non-Smart-DENY prompt
|
|
— including pure-tirith ones, where the persistence layer already caps
|
|
scope at session; adapters render the session tier independently.
|
|
"""
|
|
data = {
|
|
"command": display_command,
|
|
"pattern_key": pattern_key,
|
|
"pattern_keys": pattern_keys,
|
|
"description": display_description,
|
|
"allow_permanent": allow_permanent and not smart_denied,
|
|
"allow_session": not smart_denied,
|
|
}
|
|
if smart_denied:
|
|
data["smart_denied"] = True
|
|
return data
|
|
|
|
|
|
def _pending_result(session_key: str, *, display_command: str,
|
|
display_description: str, pattern_key: str,
|
|
pattern_keys: list[str], body: str, noun: str,
|
|
smart_denied: bool) -> dict:
|
|
"""Queue a pending approval (no gateway notifier registered) and return the
|
|
backward-compatible ``pending_approval`` result."""
|
|
pending_data = {
|
|
"command": display_command,
|
|
"pattern_key": pattern_key,
|
|
"pattern_keys": pattern_keys,
|
|
"description": display_description,
|
|
}
|
|
if smart_denied:
|
|
pending_data.update(smart_denied=True, allow_permanent=False)
|
|
submit_pending(session_key, pending_data)
|
|
result = {
|
|
"approved": False,
|
|
"pattern_key": pattern_key,
|
|
"status": "pending_approval",
|
|
"approval_pending": True,
|
|
"command": display_command,
|
|
"description": display_description,
|
|
"message": (
|
|
f"⚠️ {display_description}. Asking the user for approval.\n\n{body}\n\n"
|
|
f"STOP: do NOT re-run, rephrase, or re-issue this {noun} — each "
|
|
"variant sends the user ANOTHER approval card. Wait for the "
|
|
"user's decision; if this turn must end, report that approval "
|
|
"is pending."
|
|
),
|
|
}
|
|
if smart_denied:
|
|
result.update(smart_denied=True, allow_permanent=False)
|
|
return result
|
|
|
|
|
|
def _prompt_cli_with_hooks(command: str, description: str, pattern_key: str,
|
|
pattern_keys: list[str], session_key: str,
|
|
**prompt_kwargs) -> str:
|
|
"""CLI prompt wrapped in the pre/post approval plugin hooks."""
|
|
hook_kwargs = dict(
|
|
command=command, description=description, pattern_key=pattern_key,
|
|
pattern_keys=list(pattern_keys), session_key=session_key, surface="cli",
|
|
)
|
|
_fire_approval_hook("pre_approval_request", **hook_kwargs)
|
|
choice = prompt_dangerous_approval(command, description, **prompt_kwargs)
|
|
_fire_approval_hook("post_approval_response", **hook_kwargs, choice=choice)
|
|
return choice
|
|
|
|
|
|
def _smart_verdict(command: str, description: str, pattern_key: str,
|
|
pattern_keys: list[str], session_key: str) -> str:
|
|
"""Run the guardian LLM with observer hooks; 'approve' | 'deny' | 'escalate'."""
|
|
observer_payload = _prepare_smart_approval_observer(
|
|
command=command,
|
|
description=description,
|
|
pattern_key=pattern_key,
|
|
pattern_keys=pattern_keys,
|
|
session_key=session_key,
|
|
)
|
|
verdict = _smart_approve(command, description)
|
|
_observe_smart_approval_verdict(observer_payload, verdict)
|
|
return verdict
|
|
|
|
|
|
def _run_approval_gate(
|
|
*,
|
|
pattern_key: str,
|
|
description: str,
|
|
display_target: str,
|
|
approval_callback=None,
|
|
cron_deny_message: str,
|
|
single_query_deny_message: str,
|
|
unattended_deny_message: str = "",
|
|
autoapprove_log_prefix: str,
|
|
fail_closed_when_no_human: bool = False,
|
|
no_human_block_message: str = "",
|
|
) -> dict:
|
|
"""Shared human-approval gate for a flagged action (command or tool).
|
|
|
|
Single decision core for :func:`check_dangerous_command` and
|
|
:func:`request_tool_approval`, so the fail-closed / cron / gateway /
|
|
persist policy cannot drift between them. Order: yolo bypass →
|
|
session-cache short-circuit → interactive/gateway/cron branch → prompt →
|
|
persistence. Input-shape-specific checks (hardline, command allowlist,
|
|
pattern detection) are the caller's job BEFORE this gate.
|
|
|
|
``fail_closed_when_no_human``: a non-interactive, non-gateway, non-cron
|
|
context BLOCKS instead of auto-approving. The dangerous-command path keeps
|
|
its historical fail-open default; plugin escalation opts in so a
|
|
plugin-flagged action never runs ungated without a human.
|
|
"""
|
|
# Hardline blocks are handled by the caller BEFORE this gate, so yolo here
|
|
# only skips the recoverable approval layer.
|
|
if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled():
|
|
return _approved()
|
|
|
|
session_key = get_current_session_key()
|
|
if is_approved(session_key, pattern_key):
|
|
return _approved()
|
|
|
|
approval_callback = _resolve_cli_approval_callback(approval_callback)
|
|
|
|
is_cli = _is_interactive_cli()
|
|
is_gateway = _is_gateway_approval_context()
|
|
|
|
# Single-query (-q) exports HERMES_INTERACTIVE=1 but nobody answers
|
|
# prompts: treat as deterministic non-interactive under single_query_mode.
|
|
if _is_single_query_approval_context():
|
|
is_cli = False
|
|
is_gateway = False
|
|
|
|
if not is_cli and not is_gateway:
|
|
if _is_single_query_approval_context():
|
|
if _get_single_query_approval_mode() == "deny":
|
|
return _blocked(single_query_deny_message,
|
|
pattern_key=pattern_key, description=description)
|
|
# Must return here rather than fall through: the fail_closed
|
|
# branch below would otherwise block the very action
|
|
# single_query_mode: approve just authorized.
|
|
logger.warning(
|
|
"%s (pattern: %s): %s — single-query auto-approve "
|
|
"(approvals.single_query_mode: approve).",
|
|
autoapprove_log_prefix, pattern_key, description,
|
|
)
|
|
return _approved()
|
|
if _is_cron_approval_context():
|
|
if _get_cron_approval_mode() == "deny":
|
|
return _blocked(cron_deny_message,
|
|
pattern_key=pattern_key, description=description)
|
|
# cron_mode: approve — fall through to auto-approve below.
|
|
elif _is_unattended_platform_approval_context():
|
|
# Resolves instantly — never a pending approval nobody can answer.
|
|
if _get_unattended_approval_mode() == "deny":
|
|
return _blocked(
|
|
unattended_deny_message or (
|
|
f"BLOCKED: approval required ({description}) but this "
|
|
"session runs on an unattended platform "
|
|
f"({_get_session_platform()}) with no user present to "
|
|
"approve it. Find an alternative approach that avoids "
|
|
"this action. To allow flagged actions on unattended "
|
|
"platforms, set approvals.unattended_mode: approve in "
|
|
"config.yaml."
|
|
),
|
|
pattern_key=pattern_key, description=description,
|
|
)
|
|
elif fail_closed_when_no_human:
|
|
logger.warning(
|
|
"%s (pattern: %s): %s — no interactive user/gateway present; "
|
|
"BLOCKED (fail-closed). Set HERMES_INTERACTIVE or "
|
|
"HERMES_GATEWAY_SESSION to answer the prompt.",
|
|
autoapprove_log_prefix, pattern_key, description,
|
|
)
|
|
return _blocked(
|
|
no_human_block_message or (
|
|
f"BLOCKED: approval required ({description}) but no "
|
|
"interactive user or gateway is present to approve it."
|
|
),
|
|
pattern_key=pattern_key, description=description,
|
|
)
|
|
logger.warning(
|
|
"%s (pattern: %s): %s — set HERMES_INTERACTIVE or "
|
|
"HERMES_GATEWAY_SESSION to require approval.",
|
|
autoapprove_log_prefix, pattern_key, description,
|
|
)
|
|
return _approved()
|
|
|
|
if is_gateway or env_var_enabled("HERMES_EXEC_ASK"):
|
|
# Interactive gateway round-trip: blocks the agent thread until the
|
|
# user answers; the agent gets a definitive approved/BLOCKED outcome.
|
|
notify_cb = _gateway_notify_cb(session_key)
|
|
|
|
if notify_cb is not None:
|
|
from agent.redact import redact_sensitive_text
|
|
approval_data = {
|
|
"command": redact_sensitive_text(display_target),
|
|
"pattern_key": pattern_key,
|
|
"pattern_keys": [pattern_key],
|
|
"description": redact_sensitive_text(description),
|
|
"allow_permanent": True,
|
|
"allow_session": True,
|
|
}
|
|
decision = _await_gateway_decision(
|
|
session_key, notify_cb, approval_data, surface="gateway"
|
|
)
|
|
if decision.get("notify_failed"):
|
|
return _denied(
|
|
"BLOCKED: Failed to send approval request to user. Do NOT retry.",
|
|
pattern_key=pattern_key, description=description,
|
|
outcome="notify_failed",
|
|
)
|
|
refusal = _gateway_refusal(decision)
|
|
if refusal is not None:
|
|
reason, reason_addendum, timeout_addendum, outcome, deny_reason = refusal
|
|
return _denied(
|
|
f"BLOCKED: Action {reason}.{reason_addendum} The user "
|
|
f"has NOT consented to this action. Do NOT retry it, "
|
|
f"do NOT rephrase it, and do NOT attempt the same "
|
|
f"outcome via a different path.{timeout_addendum}",
|
|
pattern_key=pattern_key, description=description,
|
|
outcome=outcome, deny_reason=deny_reason,
|
|
)
|
|
_persist_choice(session_key, decision["choice"], [(pattern_key, None, False)])
|
|
return _approved()
|
|
|
|
# No notify callback: an interactive CLI with a panel callback should
|
|
# still prompt locally instead of queuing a pending approval nobody
|
|
# can see (HERMES_EXEC_ASK / platform-marker leaks into CLI).
|
|
if not _should_fall_through_to_cli_approval(
|
|
is_cli=is_cli,
|
|
approval_callback=approval_callback,
|
|
notify_cb=notify_cb,
|
|
):
|
|
# e.g. API server without an attached chat: queue for /approve
|
|
# /deny review, agent sees approval_required.
|
|
submit_pending(session_key, {
|
|
"command": display_target,
|
|
"pattern_key": pattern_key,
|
|
"description": description,
|
|
})
|
|
return {
|
|
"approved": False,
|
|
"pattern_key": pattern_key,
|
|
"status": "approval_required",
|
|
"command": display_target,
|
|
"description": description,
|
|
"message": (
|
|
f"⚠️ This action is potentially dangerous ({description}). "
|
|
f"Asking the user for approval.\n\n**Target:**\n```\n{display_target}\n```"
|
|
),
|
|
}
|
|
|
|
choice = _prompt_cli_with_hooks(
|
|
display_target, description, pattern_key, [pattern_key], session_key,
|
|
approval_callback=approval_callback,
|
|
)
|
|
|
|
if choice == "timeout":
|
|
return _denied(
|
|
"BLOCKED: Action timed out without user response. The user "
|
|
"has NOT consented to this action. Do NOT retry it, do NOT "
|
|
"rephrase it, and do NOT attempt the same outcome via a "
|
|
"different path. Silence is not consent.",
|
|
pattern_key=pattern_key, description=description, outcome="timeout",
|
|
)
|
|
if choice == "deny":
|
|
return _denied(
|
|
f"BLOCKED: User denied this potentially dangerous action "
|
|
f"(matched '{description}'). Do NOT retry — the user has "
|
|
"explicitly rejected it.",
|
|
pattern_key=pattern_key, description=description, outcome="denied",
|
|
)
|
|
_persist_choice(session_key, choice, [(pattern_key, None, False)])
|
|
return _approved()
|
|
|
|
|
|
def _should_skip_container_guards(env_type: str, has_host_access: bool = False) -> bool:
|
|
"""True when the backend is isolated enough to skip dangerous-command prompts.
|
|
|
|
Docker is the exception once host paths are bind-mounted: ``rm -rf
|
|
/workspace`` then reaches host files, so it goes through normal approval.
|
|
"""
|
|
if env_type == "docker":
|
|
return not has_host_access
|
|
return env_type in ("singularity", "modal", "daytona", "vercel_sandbox")
|
|
|
|
|
|
def check_dangerous_command(command: str, env_type: str,
|
|
approval_callback=None,
|
|
has_host_access: bool = False) -> dict:
|
|
"""Detect a dangerous command and handle approval (terminal_tool entry point).
|
|
|
|
``has_host_access``: a Docker sandbox bind-mounts host paths, so its
|
|
commands can reach the host and must not skip approval.
|
|
Returns ``{"approved": True/False, "message": str or None, ...}``.
|
|
"""
|
|
if _should_skip_container_guards(env_type, has_host_access=has_host_access):
|
|
return _approved()
|
|
|
|
# Hardline floor: no-recovery commands (rm -rf /, mkfs, dd to raw device,
|
|
# shutdown, fork bomb, kill -1) are blocked BEFORE the yolo bypass. Yolo
|
|
# trusts the agent with your files, not to wipe the disk.
|
|
is_hardline, hardline_desc = detect_hardline_command(command)
|
|
if is_hardline:
|
|
logger.warning("Hardline block: %s (command: %s)", hardline_desc, command[:200])
|
|
return _hardline_block_result(hardline_desc, command)
|
|
|
|
# approvals.deny also fires BEFORE yolo — "never, even under yolo".
|
|
deny_pattern = _match_user_deny_rule(command)
|
|
if deny_pattern is not None:
|
|
logger.warning("User deny rule %r blocked command: %s",
|
|
deny_pattern, command[:200])
|
|
return _user_deny_block_result(deny_pattern)
|
|
|
|
# Gateway /yolo is session-scoped; CLI --yolo is process-scoped.
|
|
if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled():
|
|
return _approved()
|
|
|
|
if _command_matches_permanent_allowlist(command):
|
|
return _approved()
|
|
|
|
is_dangerous, pattern_key, description = detect_dangerous_command(command)
|
|
if not is_dangerous:
|
|
return _approved()
|
|
|
|
return _run_approval_gate(
|
|
pattern_key=pattern_key,
|
|
description=description,
|
|
display_target=command,
|
|
approval_callback=approval_callback,
|
|
cron_deny_message=(
|
|
f"BLOCKED: Command flagged as dangerous ({description}) "
|
|
"but cron jobs run without a user present to approve it. "
|
|
"Find an alternative approach that avoids this command. "
|
|
"To allow dangerous commands in cron jobs, set "
|
|
"approvals.cron_mode: approve in config.yaml."
|
|
),
|
|
single_query_deny_message=(
|
|
f"BLOCKED: Command flagged as dangerous ({description}) but "
|
|
"single-query mode (-q) runs without a user present to approve "
|
|
"it. Find an alternative approach that avoids this command. "
|
|
"To allow dangerous commands in single-query mode, set "
|
|
"approvals.single_query_mode: approve in config.yaml."
|
|
),
|
|
autoapprove_log_prefix=(
|
|
"AUTO-APPROVED dangerous command in non-interactive non-gateway context"
|
|
),
|
|
)
|
|
|
|
|
|
def request_tool_approval(
|
|
tool_name: str,
|
|
reason: str,
|
|
*,
|
|
rule_key: str = "",
|
|
approval_callback=None,
|
|
) -> dict:
|
|
"""Escalate an arbitrary tool call to the human-approval gate.
|
|
|
|
Entry point for a plugin ``pre_tool_call`` hook returning
|
|
``{"action": "approve", "message": ...}``: it asks the SAME human gate as
|
|
Tier-2 dangerous shell patterns (session/permanent allowlist, CLI prompt,
|
|
gateway pending, once/session/always/deny, timeout fail-closed), so the LLM
|
|
cannot skip it. Cron honors ``approvals.cron_mode``; any OTHER
|
|
non-interactive non-gateway context fails CLOSED.
|
|
|
|
``rule_key`` controls the ``[a]lways`` allowlist grain. When empty, the key
|
|
is ``tool_name`` + a hash of ``reason`` so DISTINCT reasons on the same tool
|
|
persist independently ("write to ~/.ssh" does not auto-approve a later
|
|
"send email" rule). Returns the ``check_dangerous_command`` result shape.
|
|
"""
|
|
description = reason or f"Plugin requires approval for {tool_name}"
|
|
if rule_key:
|
|
key_suffix = rule_key
|
|
else:
|
|
_reason_hash = hashlib.sha256(description.encode("utf-8")).hexdigest()[:12]
|
|
key_suffix = f"{tool_name}:{_reason_hash}"
|
|
# Namespaced so plugin-rule approvals share the allowlist machinery without
|
|
# ever colliding with a real command pattern key.
|
|
pattern_key = f"plugin_rule:{key_suffix}"
|
|
# Synthetic label for the display/allowlist layer; it never executes.
|
|
display_target = f"<{tool_name}> (plugin approval rule)"
|
|
|
|
return _run_approval_gate(
|
|
pattern_key=pattern_key,
|
|
description=description,
|
|
display_target=display_target,
|
|
approval_callback=approval_callback,
|
|
cron_deny_message=(
|
|
f"BLOCKED: Tool '{tool_name}' requires approval ({description}) "
|
|
"but cron jobs run without a user present to approve it. Find an "
|
|
"alternative approach. To allow flagged actions in cron jobs, set "
|
|
"approvals.cron_mode: approve in config.yaml."
|
|
),
|
|
single_query_deny_message=(
|
|
f"BLOCKED: Tool '{tool_name}' requires approval ({description}) "
|
|
"but single-query mode (-q) runs without a user present to "
|
|
"approve it. Find an alternative approach. To allow flagged "
|
|
"actions in single-query mode, set "
|
|
"approvals.single_query_mode: approve in config.yaml."
|
|
),
|
|
autoapprove_log_prefix=(
|
|
f"plugin-escalated tool call '{tool_name}' in "
|
|
"non-interactive non-gateway context"
|
|
),
|
|
fail_closed_when_no_human=True,
|
|
no_human_block_message=(
|
|
f"BLOCKED: Tool '{tool_name}' requires approval ({description}) "
|
|
"but no interactive user or gateway is present to approve it. "
|
|
"A plugin flagged this action for human confirmation."
|
|
),
|
|
)
|
|
|
|
|
|
# =========================================================================
|
|
# Combined pre-exec guard (tirith + dangerous command detection)
|
|
# =========================================================================
|
|
|
|
def _format_tirith_description(tirith_result: dict) -> str:
|
|
"""Human-readable severity/title/description summary of tirith findings."""
|
|
findings = tirith_result.get("findings") or []
|
|
parts = []
|
|
for f in findings:
|
|
severity = f.get("severity", "")
|
|
title = f.get("title", "")
|
|
desc = f.get("description", "")
|
|
if title and desc:
|
|
parts.append(f"[{severity}] {title}: {desc}" if severity else f"{title}: {desc}")
|
|
elif title:
|
|
parts.append(f"[{severity}] {title}" if severity else title)
|
|
if not parts:
|
|
summary = tirith_result.get("summary") or "security issue detected"
|
|
return f"Security scan: {summary}"
|
|
return "Security scan — " + "; ".join(parts)
|
|
|
|
|
|
def get_plugin_manager():
|
|
"""Lazy plugin-manager seam used by tests and early tool-only imports."""
|
|
from hermes_cli.plugins import discover_plugins, get_plugin_manager as _get_manager
|
|
|
|
# Approval can be imported before model_tools (which triggers discovery);
|
|
# make an explicitly selected transport available on the first approval
|
|
# instead of treating the undiscovered registry as unavailable.
|
|
discover_plugins()
|
|
return _get_manager()
|
|
|
|
|
|
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
|
|
|
|
|
|
def _present_with_selected_transport(
|
|
*,
|
|
command: str,
|
|
description: str,
|
|
pattern_key: str,
|
|
pattern_keys: list[str],
|
|
session_key: str,
|
|
surface: str,
|
|
allow_session: bool,
|
|
allow_permanent: bool,
|
|
) -> dict:
|
|
"""Present through an explicitly selected plugin transport, if any.
|
|
|
|
A selected transport replaces every built-in prompt surface; detection,
|
|
allowed scopes, persistence, timeout, and final authorization stay
|
|
host-owned. A failed transport reaches a built-in surface only under the
|
|
explicit ``transport_fallback: builtin`` opt-in.
|
|
"""
|
|
name, fallback = _get_approval_transport_config()
|
|
if name == "builtin":
|
|
return {"selected": False}
|
|
|
|
try:
|
|
registered = get_plugin_manager().get_approval_transport(name)
|
|
except Exception:
|
|
# Plugin/discovery exception text may contain plugin-owned secrets.
|
|
logger.warning("Could not resolve selected approval transport %r", name)
|
|
registered = None
|
|
if registered is None:
|
|
logger.warning("Selected approval transport %r is unavailable", name)
|
|
return {
|
|
"selected": True,
|
|
"choice": "deny",
|
|
"failure": "unavailable",
|
|
"fallback": fallback,
|
|
"name": name,
|
|
}
|
|
|
|
try:
|
|
from agent.redact import redact_sensitive_text
|
|
from hermes_cli.approval_transport import ApprovalRequest, invoke_approval_transport
|
|
|
|
timeout_seconds = _get_approval_timeout()
|
|
request = ApprovalRequest.create(
|
|
command=redact_sensitive_text(command, force=True),
|
|
description=redact_sensitive_text(description, force=True),
|
|
pattern_key=pattern_key,
|
|
pattern_keys=tuple(pattern_keys),
|
|
session_key=session_key,
|
|
surface=surface,
|
|
allow_session=allow_session,
|
|
allow_permanent=allow_permanent,
|
|
timeout_seconds=timeout_seconds,
|
|
)
|
|
except Exception:
|
|
# Never fall back to raw text if redaction or request construction
|
|
# fails: fail closed without calling the plugin or leaking the
|
|
# unredacted payload to logs/hooks.
|
|
logger.warning("Could not build redacted plugin approval request")
|
|
return {
|
|
"selected": True,
|
|
"choice": "deny",
|
|
"failure": "error",
|
|
"fallback": None,
|
|
"name": name,
|
|
}
|
|
hook_kwargs = dict(
|
|
command=request.command,
|
|
description=request.description,
|
|
pattern_key=pattern_key,
|
|
pattern_keys=list(pattern_keys),
|
|
session_key=session_key,
|
|
surface=f"transport:{name}",
|
|
request_id=request.request_id,
|
|
request_digest=request.digest,
|
|
)
|
|
_fire_approval_hook("pre_approval_request", **hook_kwargs)
|
|
try:
|
|
from tools.environments.base import touch_activity_if_due
|
|
except Exception: # pragma: no cover - minimal tool-only environments
|
|
touch_activity_if_due = None
|
|
now = time.monotonic()
|
|
activity_state = {"last_touch": now, "start": now}
|
|
|
|
def _poll() -> None:
|
|
if touch_activity_if_due is not None:
|
|
touch_activity_if_due(activity_state, "waiting for plugin approval transport")
|
|
|
|
with human_wait_window(session_key):
|
|
result = invoke_approval_transport(
|
|
registered.present,
|
|
request,
|
|
timeout_seconds=timeout_seconds,
|
|
on_poll=_poll,
|
|
is_interrupted=is_interrupted,
|
|
)
|
|
hook_choice = result.choice if result.failure is None else f"transport_{result.failure}"
|
|
_fire_approval_hook("post_approval_response", **hook_kwargs, choice=hook_choice)
|
|
return {
|
|
"selected": True,
|
|
"choice": result.choice,
|
|
"failure": result.failure,
|
|
"fallback": fallback,
|
|
"name": name,
|
|
}
|
|
|
|
|
|
def _transport_denied_result(
|
|
*, pattern_key: str, description: str, failure: str
|
|
) -> dict:
|
|
breaker_addendum = _denial_breaker_addendum(get_current_session_key())
|
|
return _denied(
|
|
f"BLOCKED: Selected approval transport failed ({failure}); the user "
|
|
"has NOT consented to this action. Do NOT retry this command or "
|
|
"attempt the same outcome through another route."
|
|
f"{breaker_addendum}",
|
|
pattern_key=pattern_key, description=description,
|
|
outcome=f"transport_{failure}",
|
|
)
|
|
|
|
|
|
def _transport_choice(attempt: dict, *, pattern_key: str, description: str):
|
|
"""Interpret a ``_present_with_selected_transport`` attempt.
|
|
|
|
Returns ``(choice, denied_result)``: both None when the built-in surfaces
|
|
should run (no transport selected, or a failure with the explicit builtin
|
|
fallback); a denied result for any other failure; else the user's choice.
|
|
"""
|
|
if not attempt.get("selected"):
|
|
return None, None
|
|
failure = attempt.get("failure")
|
|
if failure and attempt.get("fallback") == "builtin":
|
|
logger.warning(
|
|
"Approval transport %r failed (%s); using explicit builtin fallback",
|
|
attempt.get("name"),
|
|
failure,
|
|
)
|
|
return None, None
|
|
if failure:
|
|
return None, _transport_denied_result(
|
|
pattern_key=pattern_key, description=description, failure=failure,
|
|
)
|
|
return attempt.get("choice"), None
|
|
|
|
|
|
# Non-interactive contexts with nobody to answer a prompt, in evaluation order:
|
|
# (context predicate, mode getter, config key, "why nobody can approve" clause,
|
|
# whether the dangerous-pattern block carries pattern_key/description).
|
|
def _unattended_contexts() -> list[tuple]:
|
|
contexts = []
|
|
if _is_single_query_approval_context():
|
|
contexts.append((
|
|
_get_single_query_approval_mode, "single_query_mode",
|
|
"single-query mode (-q) runs without a user present to approve it",
|
|
"in single-query mode", True,
|
|
))
|
|
if _is_cron_approval_context():
|
|
contexts.append((
|
|
_get_cron_approval_mode, "cron_mode",
|
|
"cron jobs run without a user present to approve it",
|
|
"in cron jobs", False,
|
|
))
|
|
elif _is_unattended_platform_approval_context():
|
|
contexts.append((
|
|
_get_unattended_approval_mode, "unattended_mode",
|
|
"this session runs on an unattended platform "
|
|
f"({_get_session_platform()}) with no user present to approve it",
|
|
"on unattended platforms", False,
|
|
))
|
|
return contexts
|
|
|
|
|
|
def _unattended_deny(command: str, mode_getter, cfg_key: str, clause: str,
|
|
scope: str, with_keys: bool) -> dict | None:
|
|
"""Deny-mode handling for one unattended context (cron / -q / webhook).
|
|
|
|
Pattern detection first, then tirith so content-level threats (homograph
|
|
URLs, pipe-to-interpreter, terminal injection) are caught even when the
|
|
pattern detector misses. An un-importable tirith honours
|
|
``security.tirith_fail_open``: fail-closed means block, since nobody can
|
|
approve (#20733). Returns None to allow.
|
|
"""
|
|
if mode_getter() != "deny":
|
|
return None
|
|
allow_hint = (f"Find an alternative approach that avoids this command. To allow "
|
|
f"dangerous commands {scope}, set approvals.{cfg_key}: approve in config.yaml.")
|
|
is_dangerous, _pk, description = detect_dangerous_command(command)
|
|
if is_dangerous:
|
|
result = {
|
|
"approved": False,
|
|
"message": f"BLOCKED: Command flagged as dangerous ({description}) but {clause}. {allow_hint}",
|
|
}
|
|
if with_keys:
|
|
result.update(pattern_key=_pk, description=description)
|
|
return result
|
|
try:
|
|
from tools.tirith_security import check_command_security
|
|
tirith = check_command_security(command)
|
|
except ImportError:
|
|
if _tirith_fail_open():
|
|
return None
|
|
return {
|
|
"approved": False,
|
|
"message": (
|
|
"BLOCKED: the Tirith security scanner could not be "
|
|
"imported and security.tirith_fail_open is false, "
|
|
f"so this command cannot be silently allowed — and {clause}. "
|
|
f"Find an alternative approach, install tirith, or set "
|
|
f"approvals.{cfg_key}: approve in config.yaml."
|
|
),
|
|
}
|
|
if tirith.get("action") in ("block", "warn"):
|
|
return {
|
|
"approved": False,
|
|
"message": f"BLOCKED: {_format_tirith_description(tirith)} but {clause}. {allow_hint}",
|
|
}
|
|
return None
|
|
|
|
|
|
def _tirith_scan(command: str) -> dict:
|
|
"""Tirith result for the interactive flow; an un-importable scanner allows
|
|
(default) or, under fail-closed, synthesizes a HIGH warn finding that goes
|
|
through the normal approval flow (#20733)."""
|
|
try:
|
|
from tools.tirith_security import check_command_security
|
|
return check_command_security(command)
|
|
except ImportError:
|
|
if _tirith_fail_open():
|
|
return {"action": "allow", "findings": [], "summary": ""}
|
|
return {
|
|
"action": "warn",
|
|
"findings": [
|
|
{
|
|
"rule_id": "tirith-import-error",
|
|
"severity": "HIGH",
|
|
"title": "Tirith security module unavailable",
|
|
"description": (
|
|
"The Tirith security scanner could not be imported. "
|
|
"Because security.tirith_fail_open is false, this "
|
|
"command cannot be silently allowed. Approve only if "
|
|
"you have verified the command is safe."
|
|
),
|
|
}
|
|
],
|
|
"summary": "Tirith unavailable (fail-closed)",
|
|
}
|
|
|
|
|
|
def check_all_command_guards(command: str, env_type: str,
|
|
approval_callback=None,
|
|
has_host_access: bool = False) -> dict:
|
|
"""Run all pre-exec security checks and return a single approval decision.
|
|
|
|
Tirith and dangerous-command findings are presented as ONE combined
|
|
approval request, so a gateway force=True replay cannot bypass one check
|
|
when only the other was shown to the user. ``has_host_access``: a Docker
|
|
sandbox with bind-mounted host paths is no longer isolated and takes the
|
|
normal flow instead of the container fast-path.
|
|
"""
|
|
if _should_skip_container_guards(env_type, has_host_access=has_host_access):
|
|
return _approved()
|
|
|
|
# Unconditional floors, BEFORE yolo / mode=off / cron approve-mode so no
|
|
# session-level setting can bypass them: hardline catastrophic commands,
|
|
# password-piping to sudo -S with no SUDO_PASSWORD configured, and the
|
|
# user's own approvals.deny rules ("never, even under yolo").
|
|
is_hardline, hardline_desc = detect_hardline_command(command)
|
|
if is_hardline:
|
|
logger.warning("Hardline block: %s (command: %s)", hardline_desc, command[:200])
|
|
return _hardline_block_result(hardline_desc, command)
|
|
|
|
is_sudo_guess, sudo_guess_desc = _check_sudo_stdin_guard(command)
|
|
if is_sudo_guess:
|
|
logger.warning("Sudo stdin guard block: %s (command: %s)",
|
|
sudo_guess_desc, command[:200])
|
|
return _sudo_stdin_block_result(sudo_guess_desc)
|
|
|
|
deny_pattern = _match_user_deny_rule(command)
|
|
if deny_pattern is not None:
|
|
logger.warning("User deny rule %r blocked command: %s",
|
|
deny_pattern, command[:200])
|
|
return _user_deny_block_result(deny_pattern)
|
|
|
|
# Gateway /yolo is session-scoped; CLI --yolo remains process-scoped.
|
|
approval_mode = _get_approval_mode()
|
|
if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled() or approval_mode == "off":
|
|
return _approved()
|
|
|
|
if _command_matches_permanent_allowlist(command):
|
|
return _approved()
|
|
|
|
approval_callback = _resolve_cli_approval_callback(approval_callback)
|
|
is_cli = _is_interactive_cli()
|
|
is_gateway = _is_gateway_approval_context()
|
|
is_ask = env_var_enabled("HERMES_EXEC_ASK")
|
|
|
|
# Single-query (-q) exports HERMES_INTERACTIVE=1 but nobody answers
|
|
# prompts; HERMES_EXEC_ASK has no human either — ignore both so
|
|
# single_query_mode actually takes effect.
|
|
if _is_single_query_approval_context():
|
|
is_cli = is_gateway = is_ask = False
|
|
|
|
# Outside CLI/gateway/ask flows we never block on approvals: each
|
|
# unattended context applies its configured deny/approve mode, else allow.
|
|
if not is_cli and not is_gateway and not is_ask:
|
|
for mode_getter, cfg_key, clause, scope, with_keys in _unattended_contexts():
|
|
result = _unattended_deny(command, mode_getter, cfg_key, clause, scope, with_keys)
|
|
if result is not None:
|
|
return result
|
|
return _approved()
|
|
|
|
# --- Phase 1: gather findings ---
|
|
tirith_result = _tirith_scan(command)
|
|
is_dangerous, pattern_key, description = detect_dangerous_command(command)
|
|
|
|
# --- Phase 2: decide --- warnings = [(pattern_key, description, is_tirith)]
|
|
warnings = []
|
|
session_key = get_current_session_key()
|
|
|
|
# Tirith block AND warn both go through the approval flow (block used to
|
|
# be a hard stop) so users can inspect the findings and approve.
|
|
if tirith_result["action"] in {"block", "warn"}:
|
|
findings = tirith_result.get("findings") or []
|
|
rule_id = findings[0].get("rule_id", "unknown") if findings else "unknown"
|
|
tirith_key = f"tirith:{rule_id}"
|
|
if not is_approved(session_key, tirith_key):
|
|
warnings.append((tirith_key, _format_tirith_description(tirith_result), True))
|
|
|
|
if is_dangerous and not is_approved(session_key, pattern_key):
|
|
warnings.append((pattern_key, description, False))
|
|
|
|
if not warnings:
|
|
return _approved()
|
|
|
|
combined_desc = "; ".join(desc for _, desc, _ in warnings)
|
|
primary_key = warnings[0][0]
|
|
all_keys = [key for key, _, _ in warnings]
|
|
|
|
# --- Phase 2.5: smart approval (auxiliary LLM) ---
|
|
smart_denied_for_owner = False
|
|
if approval_mode == "smart":
|
|
verdict = _smart_verdict(command, combined_desc, primary_key, all_keys, session_key)
|
|
if verdict == "approve":
|
|
# Approve this command only: pattern-level persistence would let
|
|
# one benign command suppress review of later commands in the
|
|
# same broad detector category.
|
|
_reset_denials(session_key)
|
|
logger.debug("Smart approval: auto-approved '%s' (%s)",
|
|
command[:60], combined_desc)
|
|
return {"approved": True, "message": None,
|
|
"smart_approved": True,
|
|
"description": combined_desc}
|
|
if verdict == "deny":
|
|
# A guardian DENY counts toward the consecutive-denial breaker even
|
|
# when an interactive owner may override it for this one operation.
|
|
_record_denial(session_key)
|
|
if not (is_cli or is_gateway or is_ask):
|
|
breaker_addendum = _denial_breaker_addendum(session_key)
|
|
return {
|
|
"approved": False,
|
|
"message": f"BLOCKED by smart approval: {combined_desc}. "
|
|
"The command was assessed as genuinely dangerous. "
|
|
f"Do NOT retry.{breaker_addendum}",
|
|
"smart_denied": True,
|
|
}
|
|
smart_denied_for_owner = True
|
|
# ESCALATE follows the normal, potentially persistent manual behavior.
|
|
|
|
# --- Phase 3: approval ---
|
|
# "Always" is offered when at least one warning is a dangerous-pattern key
|
|
# the persistence layer would actually allowlist permanently. Pure-tirith
|
|
# findings are session-max by design, so a tirith-only prompt hides Always;
|
|
# mixed prompts offer it (the pattern key persists, tirith downgrades to
|
|
# session — see _persist_choice).
|
|
has_permanent_capable = any(not is_t for _, _, is_t in warnings)
|
|
allow_permanent = has_permanent_capable and not smart_denied_for_owner
|
|
|
|
transport_attempt = _present_with_selected_transport(
|
|
command=command,
|
|
description=combined_desc,
|
|
pattern_key=primary_key,
|
|
pattern_keys=all_keys,
|
|
session_key=session_key,
|
|
surface="gateway" if (is_gateway or is_ask) else "cli",
|
|
allow_session=not smart_denied_for_owner,
|
|
allow_permanent=allow_permanent,
|
|
)
|
|
transport_choice, denied = _transport_choice(
|
|
transport_attempt, pattern_key=primary_key, description=combined_desc,
|
|
)
|
|
if denied is not None:
|
|
return denied
|
|
if transport_choice is not None:
|
|
if transport_choice == "deny":
|
|
_record_denial(session_key)
|
|
breaker_addendum = _denial_breaker_addendum(session_key)
|
|
return _denied(
|
|
"BLOCKED: User denied this command through the selected "
|
|
"approval transport. The user has NOT consented to this "
|
|
"action. Do NOT retry or attempt the same outcome through "
|
|
f"another route.{breaker_addendum}",
|
|
pattern_key=primary_key, description=combined_desc, outcome="denied",
|
|
)
|
|
if not smart_denied_for_owner:
|
|
_persist_choice(session_key, transport_choice, warnings)
|
|
return _user_approved(session_key, combined_desc)
|
|
|
|
# Gateway/async approval: block the agent thread until /approve or /deny,
|
|
# mirroring the CLI's synchronous input() flow. The agent never sees
|
|
# "approval_required" here — it gets output or a definitive BLOCKED.
|
|
if is_gateway or is_ask:
|
|
notify_cb = _gateway_notify_cb(session_key)
|
|
|
|
if notify_cb is not None:
|
|
from agent.redact import redact_sensitive_text
|
|
approval_data = _gateway_approval_data(
|
|
redact_sensitive_text(command), redact_sensitive_text(combined_desc),
|
|
primary_key, all_keys,
|
|
allow_permanent=has_permanent_capable, smart_denied=smart_denied_for_owner,
|
|
)
|
|
decision = _await_gateway_decision(
|
|
session_key, notify_cb, approval_data, surface="gateway"
|
|
)
|
|
if decision.get("notify_failed"):
|
|
return _denied(
|
|
"BLOCKED: Failed to send approval request to user. Do NOT retry.",
|
|
pattern_key=primary_key, description=combined_desc,
|
|
outcome="notify_failed",
|
|
)
|
|
refusal = _gateway_refusal(decision)
|
|
if refusal is not None:
|
|
reason, reason_addendum, timeout_addendum, outcome, deny_reason = refusal
|
|
breaker_addendum = _denial_breaker_addendum(session_key)
|
|
return _denied(
|
|
f"BLOCKED: Command {reason}.{reason_addendum} The user "
|
|
f"has NOT consented to this action. Do NOT retry this "
|
|
f"command, do NOT rephrase it, and do NOT attempt the "
|
|
f"same outcome via a different command. Stop the "
|
|
f"current workflow and wait for the user to respond "
|
|
f"before taking any further destructive or "
|
|
f"irreversible action.{timeout_addendum}{breaker_addendum}",
|
|
pattern_key=primary_key, description=combined_desc,
|
|
outcome=outcome, deny_reason=deny_reason,
|
|
)
|
|
# A smart-DENY owner override is always one operation, even if an
|
|
# older client returns "session" or "always".
|
|
if not smart_denied_for_owner:
|
|
_persist_choice(session_key, decision["choice"], warnings)
|
|
return _user_approved(session_key, combined_desc)
|
|
|
|
# No gateway callback (cron, batch, or ask-mode leaked into an
|
|
# interactive CLI): paint the local panel when possible instead of a
|
|
# pending_approval that makes the agent look "auto-blocked".
|
|
if not _should_fall_through_to_cli_approval(
|
|
is_cli=is_cli,
|
|
approval_callback=approval_callback,
|
|
notify_cb=notify_cb,
|
|
):
|
|
from agent.redact import redact_sensitive_text
|
|
_disp_command = redact_sensitive_text(command)
|
|
return _pending_result(
|
|
session_key,
|
|
display_command=_disp_command,
|
|
display_description=redact_sensitive_text(combined_desc),
|
|
pattern_key=primary_key, pattern_keys=all_keys,
|
|
body=f"**Command:**\n```\n{_disp_command}\n```", noun="command",
|
|
smart_denied=smart_denied_for_owner,
|
|
)
|
|
|
|
# CLI interactive: single combined prompt.
|
|
choice = _prompt_cli_with_hooks(
|
|
command, combined_desc, primary_key, all_keys, session_key,
|
|
allow_permanent=allow_permanent,
|
|
smart_denied=smart_denied_for_owner,
|
|
approval_callback=approval_callback,
|
|
)
|
|
|
|
if choice == "timeout":
|
|
breaker_addendum = _denial_breaker_addendum(session_key)
|
|
return _denied(
|
|
"BLOCKED: Command timed out without user response. The user "
|
|
"has NOT consented to this action. Do NOT retry this "
|
|
"command, do NOT rephrase it, and do NOT attempt the same "
|
|
"outcome via a different command. Stop the current workflow "
|
|
"and wait for the user to respond before taking any further "
|
|
"destructive or irreversible action. Silence is not "
|
|
f"consent.{breaker_addendum}",
|
|
pattern_key=primary_key, description=combined_desc, outcome="timeout",
|
|
)
|
|
if choice == "deny":
|
|
breaker_addendum = _denial_breaker_addendum(session_key)
|
|
return _denied(
|
|
"BLOCKED: User denied this command. The user has NOT consented "
|
|
"to this action. Do NOT retry this command, do NOT rephrase "
|
|
"it, and do NOT attempt the same outcome via a different "
|
|
"command. Stop the current workflow and wait for the user "
|
|
f"to respond before taking any further destructive or "
|
|
f"irreversible action.{breaker_addendum}",
|
|
pattern_key=primary_key, description=combined_desc, outcome="denied",
|
|
)
|
|
if not smart_denied_for_owner:
|
|
_persist_choice(session_key, choice, warnings)
|
|
return _user_approved(session_key, combined_desc)
|
|
|
|
|
|
_EXECUTE_CODE_DESCRIPTION = (
|
|
"execute_code script execution. The script can spawn subprocesses or "
|
|
"mutate files without passing through terminal command approval; "
|
|
"approval is one-shot for this run."
|
|
)
|
|
_EXECUTE_CODE_UNATTENDED_TAILS = {
|
|
"single_query": (
|
|
"Single-query mode (-q) runs without a user present to approve it. Use "
|
|
"normal tools instead, or set approvals.single_query_mode: approve only "
|
|
"if this single-query run is intentionally trusted."
|
|
),
|
|
"cron": (
|
|
"Cron jobs run without a user present to approve it. Use normal tools "
|
|
"instead, or set approvals.cron_mode: approve only if this cron profile "
|
|
"is intentionally trusted."
|
|
),
|
|
}
|
|
|
|
|
|
def _execute_code_unattended_block(tail: str) -> dict:
|
|
return _denied(
|
|
"BLOCKED: execute_code runs arbitrary local Python (including "
|
|
"subprocess calls that bypass shell-string approval checks). " + tail,
|
|
pattern_key="execute_code", description=_EXECUTE_CODE_DESCRIPTION,
|
|
outcome="blocked",
|
|
)
|
|
|
|
|
|
def check_execute_code_guard(code: str, env_type: str,
|
|
has_host_access: bool = False) -> dict:
|
|
"""Approve an execute_code script before its child process is spawned.
|
|
|
|
The script can call ``subprocess``/``os.system``/``ctypes`` directly, none
|
|
of which pass through ``terminal()`` / ``DANGEROUS_PATTERNS``. In
|
|
gateway/ask contexts we fail closed by approving the script as a whole
|
|
(#30882). Same dict contract as ``check_all_command_guards``.
|
|
|
|
Documented limitation: a purely local non-interactive non-gateway session
|
|
(no TTY, not gateway, not cron-deny) returns approved — matching the
|
|
terminal auto-approve contract. The hardline floor still blocks
|
|
catastrophic ``terminal()`` commands the script issues.
|
|
"""
|
|
pattern_key = "execute_code"
|
|
description = _EXECUTE_CODE_DESCRIPTION
|
|
|
|
# Isolated backends already sandbox the child. vercel_sandbox has no
|
|
# host-bind concept so it stays always-skipped.
|
|
if env_type == "vercel_sandbox":
|
|
return _approved()
|
|
if _should_skip_container_guards(env_type, has_host_access=has_host_access):
|
|
return _approved()
|
|
|
|
approval_mode = _get_approval_mode()
|
|
if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled() or approval_mode == "off":
|
|
return _approved()
|
|
|
|
is_gateway = _is_gateway_approval_context()
|
|
is_ask = env_var_enabled("HERMES_EXEC_ASK")
|
|
is_cli = _is_interactive_cli()
|
|
approval_callback = _resolve_cli_approval_callback()
|
|
|
|
# No user is present to approve arbitrary code in -q / cron / unattended
|
|
# sessions: each resolves instantly from its configured mode.
|
|
if _is_single_query_approval_context():
|
|
if _get_single_query_approval_mode() == "deny":
|
|
return _execute_code_unattended_block(_EXECUTE_CODE_UNATTENDED_TAILS["single_query"])
|
|
return _approved()
|
|
if _is_cron_approval_context():
|
|
if _get_cron_approval_mode() == "deny":
|
|
return _execute_code_unattended_block(_EXECUTE_CODE_UNATTENDED_TAILS["cron"])
|
|
return _approved()
|
|
if _is_unattended_platform_approval_context():
|
|
if _get_unattended_approval_mode() == "deny":
|
|
return _execute_code_unattended_block(
|
|
"This session runs on an unattended "
|
|
f"platform ({_get_session_platform()}) with no user "
|
|
"present to approve it. Use normal tools instead, or set "
|
|
"approvals.unattended_mode: approve only if sessions on "
|
|
"this surface are intentionally trusted."
|
|
)
|
|
return _approved()
|
|
|
|
# Only gateway/ask contexts get the one-shot whole-script approval. In an
|
|
# interactive CLI the script's terminal() calls are guarded per-call
|
|
# (context propagates into the RPC thread, #33057), so a whole-script
|
|
# prompt would fire on every execute_code call. Ask-mode still takes this
|
|
# path even with INTERACTIVE set (how gateway/smart tests and messaging
|
|
# ask-mode drive whole-script approval); when that leaks into a CLI with no
|
|
# notify callback, the notify_cb-less branch below falls through to the
|
|
# CLI Dangerous Command panel instead of a silent pending_approval.
|
|
if not is_gateway and not is_ask:
|
|
return _approved()
|
|
|
|
session_key = get_current_session_key()
|
|
# Built only past the early-return gates so common paths don't copy a
|
|
# potentially-large script into this string.
|
|
command = f"execute_code <<'PY'\n{code}\nPY"
|
|
|
|
# Without this, "Approve session" / "Always" choices are stored but never
|
|
# consulted, so every execute_code call re-prompts (#39275).
|
|
if is_approved(session_key, pattern_key):
|
|
return _approved()
|
|
|
|
# Smart mode: an APPROVE only suppresses the redundant whole-script prompt;
|
|
# the per-call terminal() guards still run independently.
|
|
smart_denied_for_owner = False
|
|
if approval_mode == "smart":
|
|
verdict = _smart_verdict(command, description, pattern_key, [pattern_key], session_key)
|
|
if verdict == "approve":
|
|
_reset_denials(session_key)
|
|
logger.debug("Smart approval: auto-approved execute_code for session %s",
|
|
session_key)
|
|
return {"approved": True, "message": None,
|
|
"smart_approved": True, "description": description}
|
|
if verdict == "deny":
|
|
_record_denial(session_key)
|
|
if not (is_gateway or is_ask):
|
|
breaker_addendum = _denial_breaker_addendum(session_key)
|
|
return _denied(
|
|
"BLOCKED by smart approval: execute_code script "
|
|
"execution was assessed as genuinely dangerous. "
|
|
f"Do NOT retry.{breaker_addendum}",
|
|
pattern_key=pattern_key, description=description,
|
|
outcome="denied", smart_denied=True,
|
|
)
|
|
smart_denied_for_owner = True
|
|
# ESCALATE retains the normal manual approval behavior.
|
|
|
|
# Redacted copies for user-visible rendering only: a script can embed
|
|
# credentials and the gateway renders this payload to Discord/Slack. The
|
|
# raw `command`/`code` are what get assessed and executed.
|
|
from agent.redact import redact_sensitive_text
|
|
display_command = redact_sensitive_text(command)
|
|
display_code = redact_sensitive_text(code)
|
|
display_description = redact_sensitive_text(description)
|
|
|
|
transport_attempt = _present_with_selected_transport(
|
|
command=command,
|
|
description=description,
|
|
pattern_key=pattern_key,
|
|
pattern_keys=[pattern_key],
|
|
session_key=session_key,
|
|
surface="gateway",
|
|
allow_session=not smart_denied_for_owner,
|
|
allow_permanent=not smart_denied_for_owner,
|
|
)
|
|
transport_choice, denied = _transport_choice(
|
|
transport_attempt, pattern_key=pattern_key, description=description,
|
|
)
|
|
if denied is not None:
|
|
return denied
|
|
if transport_choice is not None:
|
|
if transport_choice == "deny":
|
|
_record_denial(session_key)
|
|
return _denied(
|
|
"BLOCKED: User denied execute_code through the selected "
|
|
"approval transport. The user has NOT consented.",
|
|
pattern_key=pattern_key, description=description, outcome="denied",
|
|
)
|
|
if not smart_denied_for_owner:
|
|
_persist_choice(session_key, transport_choice, [(pattern_key, None, False)])
|
|
return _user_approved(session_key, description)
|
|
|
|
notify_cb = _gateway_notify_cb(session_key)
|
|
|
|
if notify_cb is None:
|
|
# HERMES_EXEC_ASK (or a platform marker) leaked into an interactive CLI
|
|
# (commonly via `import gateway.run`): show the CLI panel the user can
|
|
# answer rather than silently queueing a pending approval.
|
|
if _should_fall_through_to_cli_approval(
|
|
is_cli=is_cli,
|
|
approval_callback=approval_callback,
|
|
notify_cb=notify_cb,
|
|
):
|
|
choice = _prompt_cli_with_hooks(
|
|
display_command, display_description, pattern_key, [pattern_key],
|
|
session_key,
|
|
allow_permanent=not smart_denied_for_owner,
|
|
approval_callback=approval_callback,
|
|
smart_denied=smart_denied_for_owner,
|
|
)
|
|
if choice == "timeout":
|
|
breaker_addendum = _denial_breaker_addendum(session_key)
|
|
return _denied(
|
|
"BLOCKED: Action timed out without user response. The "
|
|
"user has NOT consented to this action. Do NOT retry "
|
|
"it, do NOT rephrase it, and do NOT attempt the same "
|
|
"outcome via a different path. Silence is not "
|
|
f"consent.{breaker_addendum}",
|
|
pattern_key=pattern_key, description=description, outcome="timeout",
|
|
)
|
|
if choice == "deny":
|
|
# No _record_denial(): the breaker counts consecutive guardian
|
|
# LLM DENY verdicts, not deliberate human denials — matching
|
|
# the sibling CLI tails.
|
|
breaker_addendum = _denial_breaker_addendum(session_key)
|
|
return _denied(
|
|
"BLOCKED: User denied execute_code script execution "
|
|
f"(matched '{description}'). Do NOT retry — the user "
|
|
f"has explicitly rejected it.{breaker_addendum}",
|
|
pattern_key=pattern_key, description=description, outcome="denied",
|
|
)
|
|
if not smart_denied_for_owner:
|
|
_persist_choice(session_key, choice, [(pattern_key, None, False)])
|
|
return _user_approved(session_key, description)
|
|
|
|
return _pending_result(
|
|
session_key,
|
|
display_command=display_command,
|
|
display_description=display_description,
|
|
pattern_key=pattern_key, pattern_keys=[pattern_key],
|
|
body=f"**Code:**\n```python\n{display_code}\n```", noun="code",
|
|
smart_denied=smart_denied_for_owner,
|
|
)
|
|
|
|
approval_data = _gateway_approval_data(
|
|
display_command, display_description, pattern_key, [pattern_key],
|
|
allow_permanent=True, smart_denied=smart_denied_for_owner,
|
|
)
|
|
decision = _await_gateway_decision(
|
|
session_key, notify_cb, approval_data, surface="gateway"
|
|
)
|
|
if decision.get("notify_failed"):
|
|
return _denied(
|
|
"BLOCKED: Failed to send execute_code approval request "
|
|
"to user. Do NOT retry.",
|
|
pattern_key=pattern_key, description=description, outcome="notify_failed",
|
|
)
|
|
|
|
refusal = _gateway_refusal(decision)
|
|
if refusal is not None:
|
|
reason, reason_addendum, addendum, outcome, deny_reason = refusal
|
|
breaker_addendum = _denial_breaker_addendum(session_key)
|
|
return _denied(
|
|
f"BLOCKED: execute_code script {reason}.{reason_addendum} The "
|
|
f"user has NOT consented to running this code. Do NOT retry, "
|
|
f"do NOT rephrase the script, and do NOT attempt the same "
|
|
f"outcome via a different tool.{addendum}{breaker_addendum}",
|
|
pattern_key=pattern_key, description=description,
|
|
outcome=outcome, deny_reason=deny_reason,
|
|
)
|
|
|
|
# Never persist a smart-DENY override under the coarse execute_code key;
|
|
# that would approve unrelated future scripts. "once" persists nothing.
|
|
if not smart_denied_for_owner:
|
|
_persist_choice(session_key, decision["choice"], [(pattern_key, None, False)])
|
|
return _user_approved(session_key, description)
|
|
|
|
|
|
# =========================================================================
|
|
# MCP elicitation entry point
|
|
# =========================================================================
|
|
|
|
def request_elicitation_consent(
|
|
message: str,
|
|
description: str,
|
|
*,
|
|
timeout_seconds: int | None = None,
|
|
surface: str = "mcp-elicitation",
|
|
) -> str:
|
|
"""Route an MCP elicitation request to the surface owning the active session.
|
|
|
|
Gateway sessions go through ``_await_gateway_decision``; CLI/TUI through
|
|
``prompt_dangerous_approval``. Always fails closed: a missing notify_cb in
|
|
a gateway session, timeouts, and exceptions map to ``"decline"`` so a server
|
|
treats them as "user did not approve" rather than retrying or hanging.
|
|
Returns ``"accept" | "decline" | "cancel"``.
|
|
"""
|
|
try:
|
|
session_key = get_current_session_key()
|
|
except Exception as exc: # pragma: no cover -- defensive
|
|
logger.warning("Elicitation consent: session lookup failed: %s", exc)
|
|
return "decline"
|
|
|
|
if _is_gateway_approval_context():
|
|
notify_cb = _gateway_notify_cb(session_key)
|
|
if notify_cb is None:
|
|
logger.warning(
|
|
"Elicitation requested in gateway session %s but no "
|
|
"notify_cb is registered — failing closed",
|
|
session_key,
|
|
)
|
|
return "decline"
|
|
|
|
approval_data = {
|
|
"command": message,
|
|
"description": description,
|
|
"pattern_key": "mcp_elicitation",
|
|
"pattern_keys": ["mcp_elicitation"],
|
|
}
|
|
try:
|
|
decision = _await_gateway_decision(
|
|
session_key, notify_cb, approval_data, surface=surface,
|
|
)
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Elicitation gateway dispatch failed: %s", exc, exc_info=True,
|
|
)
|
|
return "decline"
|
|
|
|
if decision.get("notify_failed"):
|
|
return "decline"
|
|
if not decision.get("resolved"):
|
|
return "cancel"
|
|
choice = decision.get("choice")
|
|
if choice in ("once", "session", "always"):
|
|
return "accept"
|
|
return "decline"
|
|
|
|
# allow_permanent=False: elicitation is a per-call confirmation — there
|
|
# is no pattern to remember.
|
|
try:
|
|
choice = prompt_dangerous_approval(
|
|
message,
|
|
description,
|
|
timeout_seconds=timeout_seconds,
|
|
allow_permanent=False,
|
|
)
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Elicitation CLI prompt failed: %s", exc, exc_info=True,
|
|
)
|
|
return "decline"
|
|
|
|
if choice in ("once", "session", "always"):
|
|
return "accept"
|
|
if choice == "timeout":
|
|
return "cancel" # mirror the gateway's unresolved outcome
|
|
return "decline"
|
|
|
|
|
|
# Load permanent allowlist from config on module import
|
|
load_permanent_allowlist()
|