A session asked to clean up older Pythons removed the uv-managed base
interpreter its own venv depended on; the next boot died with 'uv
trampoline failed to spawn Python child process' and no agent tool could
repair it, because the agent itself no longer started (#58748). Prior
uninstall detection (85ce25687e) only flagged package-manager commands.
Add agent/runtime_self_protection.py and wire it into both layers:
- The approval floor (_floor_block) now blocks shell commands that
delete the running interpreter, its own venv, the pyvenv.cfg base, or
the uv-managed install directory — rm/rmdir/rd/del/erase/Remove-Item
with any flags, find <root> -delete, and uv python uninstall of the
running version (including --all). The floor runs before yolo /
approvals.mode=off / cron approve mode, so no session setting can
bypass it.
- The file-safety write classifier denies write/patch/move/delete to the
same paths, so the file tools cannot overwrite the interpreter either.
Only the runtime the process itself boots from is protected; every other
venv and interpreter on the machine stays manageable.
Fixes #58748
1364 lines
68 KiB
Python
1364 lines
68 KiB
Python
"""Dangerous command approval -- the gate flow and per-session state.
|
|
|
|
Owns the session state (approvals, yolo, gateway queues, denial breaker), the three guard
|
|
entry points (``check_all_command_guards``, ``check_execute_code_guard``,
|
|
``request_tool_approval`` / ``_run_approval_gate``) and the shared human-decision engine
|
|
behind them. Leaves: ``approval_detection`` (hardline/dangerous patterns), ``approval_context``
|
|
(contextvars, config readers), ``approval_floors`` (pre-gate blocks, allowlist match),
|
|
``approval_prompt`` (CLI prompt, plugin transports, MCP elicitation), ``approval_gateway_wait``
|
|
(blocking gateway round-trip), ``approval_smart`` (guardian LLM), ``approval_human_wait``.
|
|
Leaves read facade-owned state (``_lock``, queues, denial breaker) back through ``tools.approval`` at
|
|
call time; sibling-defined names are imported from their defining module.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
import hashlib
|
|
import importlib
|
|
import logging
|
|
import os
|
|
import threading
|
|
from typing import Optional
|
|
|
|
from utils import env_var_enabled, is_truthy_value
|
|
from tools import approval_context
|
|
from tools.approval_context import (
|
|
_get_session_platform, _is_cron_approval_context,
|
|
_is_gateway_approval_context, _is_interactive_cli, _is_single_query_approval_context,
|
|
_is_unattended_platform_approval_context, _resolve_cli_approval_callback, _should_fall_through_to_cli_approval,
|
|
_tirith_fail_open, get_current_session_key,
|
|
)
|
|
from tools.approval_detection import (
|
|
_approval_key_aliases, _check_sudo_stdin_guard, detect_dangerous_command, detect_hardline_command,
|
|
)
|
|
from tools.approval_floors import (
|
|
_command_matches_permanent_allowlist, _hardline_block_result, _match_user_deny_rule, _sudo_stdin_block_result,
|
|
_user_deny_block_result,
|
|
)
|
|
from tools.approval_gateway_wait import _await_gateway_decision
|
|
from tools.approval_prompt import _present_with_selected_transport, _transport_choice, prompt_dangerous_approval
|
|
from tools.approval_smart import _smart_verdict
|
|
|
|
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-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()
|
|
# Routed multiplex profiles: one permanent allowlist per profile home (see ``_permanent_set``).
|
|
_permanent_approved_by_home: dict[str, set] = {}
|
|
|
|
# --- Consecutive-denial circuit breaker for smart approvals ---------------------------------------------------------
|
|
# Each retry of a smart-denied command 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. Capped so short-lived session keys cannot grow it without bound; oldest (least
|
|
# recently denied) entries are evicted.
|
|
_denial_tally: dict[str, int] = {}
|
|
_DENIAL_TALLY_MAX_SESSIONS = 256
|
|
|
|
|
|
def _get_denial_breaker_threshold() -> int:
|
|
"""``approvals.denial_breaker_threshold``: default 3; 0 or negative disables."""
|
|
try:
|
|
return int(approval_context._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 eviction drops 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`; the text 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 ""
|
|
# WARNING (was DEBUG): a failed/blocked guardian call is a real event the operator needs to see — the
|
|
# whole point of #82846 is that the hang was invisible. Log the elapsed time and error class too.
|
|
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) ---------------------------------
|
|
|
|
|
|
# Optional free-text reason supplied with an explicit deny (``/deny <reason>``) so the agent can adapt
|
|
# instead of only hearing "denied". Ported from qwibitai/nanoclaw#2832.
|
|
_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 send on the 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)
|
|
for entry in _gateway_queues.pop(session_key, []):
|
|
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 ``/deny <reason>`` free text,
|
|
relayed to the agent in the BLOCKED message. Returns the number resolved.
|
|
"""
|
|
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)
|
|
# Popping the entry and committing its outcome are ONE critical section: the waiter's
|
|
# ``_drop_entry`` reads ``entry.result`` under this same lock after its deadline check, so a
|
|
# choice acked to the client here can never be popped-and-lost as a timeout (#112548).
|
|
for entry in targets:
|
|
entry.result = choice
|
|
if reason:
|
|
entry.reason = reason
|
|
entry.event.set()
|
|
return len(targets)
|
|
|
|
|
|
def withdraw_gateway_approval(session_key: str, request_id: str, cause: str) -> bool:
|
|
"""Withdraw one pending approval nobody can answer (the only attached client cannot render it).
|
|
The waiter wakes at once with ``cancelled=cause`` — a withdrawal, never a user deny — instead of
|
|
idling for the whole approvals.timeout (#112548). False when it is no longer pending."""
|
|
with _lock:
|
|
queue = _gateway_queues.get(session_key, [])
|
|
entry = next((e for e in queue if e.data.get("request_id") == request_id), None)
|
|
if entry is None:
|
|
return False
|
|
queue.remove(entry)
|
|
if not queue:
|
|
_gateway_queues.pop(session_key, None)
|
|
entry.cancelled = cause
|
|
entry.event.set()
|
|
return True
|
|
|
|
|
|
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 register_gateway_settle(session_key: str, request_id: str, settle) -> bool:
|
|
"""Attach ``settle(reason)`` to one pending approval; it runs once when that wait ends by any path.
|
|
False when the request is no longer pending (the surface should withdraw its prompt itself)."""
|
|
with _lock:
|
|
for entry in _gateway_queues.get(session_key, []):
|
|
if entry.data.get("request_id") == request_id:
|
|
entry.settle = settle
|
|
return True
|
|
return False
|
|
|
|
|
|
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 pending_gateway_approval_count() -> int:
|
|
"""Unresolved gateway approvals across every session — a backend blocked on one is not idle."""
|
|
with _lock:
|
|
return sum(len(queue) for queue in _gateway_queues.values())
|
|
|
|
|
|
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.tool 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 _set_session_yolo(session_key: str, enabled: bool) -> None:
|
|
if not session_key:
|
|
return
|
|
with _lock:
|
|
(_session_yolo.add if enabled else _session_yolo.discard)(session_key)
|
|
_release_permission_mode_dependents(session_key)
|
|
|
|
|
|
def enable_session_yolo(session_key: str) -> None:
|
|
"""Enable YOLO bypass for a single session key."""
|
|
_set_session_yolo(session_key, True)
|
|
|
|
|
|
def disable_session_yolo(session_key: str) -> None:
|
|
"""Disable YOLO bypass for a single session key."""
|
|
_set_session_yolo(session_key, False)
|
|
|
|
|
|
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)
|
|
for entry in _gateway_queues.pop(session_key, []):
|
|
# Cancel blocked waits now so the old run unwinds instead of idling until timeout;
|
|
# the prompt was withdrawn, nobody denied it.
|
|
entry.cancelled = "the session ended before the prompt was answered"
|
|
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.
|
|
for module, shutdown in (("tools.code_kernel", "shutdown_kernels_for_owner"),
|
|
("tools.code_kernel_remote", "shutdown_remote_kernels_for_owner")):
|
|
try:
|
|
getattr(importlib.import_module(module), shutdown)(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 _yolo_active() -> bool:
|
|
"""CLI ``--yolo`` (process-scoped, frozen at import) or gateway ``/yolo``
|
|
(session-scoped). Hardline / deny-rule floors run BEFORE this everywhere."""
|
|
return _YOLO_MODE_FROZEN or is_current_session_yolo_enabled()
|
|
|
|
|
|
def _permanent_set() -> set:
|
|
"""The permanent allowlist that governs the ACTIVE profile. Unscoped (single-profile process,
|
|
or the multiplexer's own launch profile) → the module-level set tests and the CLI seed. A routed
|
|
profile (HERMES_HOME override) → its own set, lazily loaded from ITS ``command_allowlist``: the
|
|
launch profile's "always" approvals must not pre-approve commands for a secondary, nor may a
|
|
secondary's "always" choice be written back into the launch profile's config. Callers hold ``_lock``.
|
|
"""
|
|
from hermes_constants import get_hermes_home_override, hermes_home_key
|
|
if get_hermes_home_override() is None:
|
|
return _permanent_approved
|
|
home_key = hermes_home_key()
|
|
approved = _permanent_approved_by_home.get(home_key)
|
|
if approved is None:
|
|
try:
|
|
approved = _read_permanent_allowlist()
|
|
except Exception as e:
|
|
logger.warning("Failed to load permanent allowlist: %s", e)
|
|
approved = set()
|
|
_permanent_approved_by_home[home_key] = approved
|
|
return approved
|
|
|
|
|
|
def is_approved(session_key: str, pattern_key: str) -> bool:
|
|
"""Session-scoped or permanent approval. Accepts the canonical key and the legacy
|
|
regex-derived key so existing command_allowlist entries survive key migrations."""
|
|
aliases = _approval_key_aliases(pattern_key)
|
|
with _lock:
|
|
approved = _permanent_set() | _session_approved.get(session_key, set())
|
|
return any(alias in approved for alias in aliases)
|
|
|
|
|
|
def _is_permanently_approved(pattern_key: str) -> bool:
|
|
"""Permanent approval only, with compatibility for migrated pattern keys."""
|
|
aliases = _approval_key_aliases(pattern_key)
|
|
with _lock:
|
|
return any(alias in _permanent_set() for alias in aliases)
|
|
|
|
|
|
def approve_permanent(pattern_key: str):
|
|
"""Add a pattern to the permanent allowlist."""
|
|
with _lock:
|
|
_permanent_set().add(pattern_key)
|
|
|
|
|
|
def load_permanent(patterns: set):
|
|
"""Bulk-load permanent allowlist entries from config."""
|
|
with _lock:
|
|
governing = _permanent_set()
|
|
governing.clear()
|
|
governing.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
|
|
findings), so ``always`` downgrades them to session. ``once`` persists nothing."""
|
|
for key, _, is_tirith in warnings:
|
|
if choice not in ("session", "always"):
|
|
continue
|
|
approve_session(session_key, key)
|
|
if choice == "always" and not is_tirith:
|
|
approve_permanent(key)
|
|
with _lock:
|
|
snapshot = set(_permanent_set())
|
|
save_permanent_allowlist(snapshot)
|
|
|
|
|
|
# --- Config persistence for permanent allowlist ---------------------------------------------------------------------
|
|
|
|
def _read_permanent_allowlist() -> set:
|
|
"""``command_allowlist`` of the active profile's config as a set (empty on malformed input)."""
|
|
from hermes_cli.config import load_config_readonly
|
|
config = load_config_readonly()
|
|
raw = config.get("command_allowlist")
|
|
legacy = isinstance(raw, str)
|
|
if legacy:
|
|
# Old config-set versions serialized list values as scalar strings.
|
|
import hermes_yaml as yaml
|
|
try:
|
|
raw = yaml.safe_load(raw)
|
|
except yaml.YAMLError:
|
|
raw = False
|
|
if raw is None and not legacy:
|
|
raw = []
|
|
if not isinstance(raw, list) or any(not isinstance(item, str) for item in raw):
|
|
logger.warning("Ignoring malformed command_allowlist; configure a list of strings.")
|
|
return set()
|
|
if legacy:
|
|
logger.warning("Recovered legacy string command_allowlist; re-save it as a list of strings.")
|
|
return set(raw)
|
|
|
|
|
|
# What ``command_allowlist`` held the last time this process synchronised with the
|
|
# file, per profile home ("" = the unscoped launch profile). Everything in the
|
|
# governing permanent set beyond it is an approval THIS process made, and is the
|
|
# only thing a save is entitled to add: the difference separates "the operator
|
|
# granted this here" from "this was on disk when we started, and may since have
|
|
# been revoked".
|
|
_permanent_baseline_by_home: dict[str, set] = {}
|
|
|
|
|
|
def _baseline_key() -> str:
|
|
from hermes_constants import get_hermes_home_override, hermes_home_key
|
|
return "" if get_hermes_home_override() is None else hermes_home_key()
|
|
|
|
|
|
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:
|
|
patterns = _read_permanent_allowlist()
|
|
load_permanent(patterns)
|
|
with _lock:
|
|
_permanent_baseline_by_home[_baseline_key()] = set(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, reconciling with the file.
|
|
|
|
``command_allowlist`` is a file an operator edits by hand; removing an entry
|
|
there is the documented way to withdraw a standing approval. This process read
|
|
it once at import and ``load_permanent`` only ever unions, so writing the
|
|
in-memory set straight back deleted entries added on disk since import and
|
|
resurrected the ones removed. The result written is ``what is on disk now``
|
|
plus ``what this process approved since its own baseline``; revoked entries are
|
|
also dropped from the governing permanent set so ``is_approved()`` stops
|
|
honouring them. Nothing re-reads the file on the approval hot path.
|
|
|
|
``patterns`` may only ADD: an entry left out of it is not removed, because the
|
|
on-disk list wins for anything this process did not approve itself. Remove
|
|
entries by editing ``command_allowlist`` in config.yaml.
|
|
"""
|
|
try:
|
|
from hermes_cli.config import load_config, save_config
|
|
config = load_config()
|
|
on_disk = set(config.get("command_allowlist", []) or [])
|
|
with _lock:
|
|
key = _baseline_key()
|
|
baseline = _permanent_baseline_by_home.get(key, set())
|
|
merged = on_disk | (set(patterns) - baseline)
|
|
config["command_allowlist"] = sorted(merged)
|
|
save_config(config)
|
|
_permanent_baseline_by_home[key] = set(merged)
|
|
governing = _permanent_set()
|
|
governing.clear()
|
|
governing.update(merged)
|
|
except Exception as e:
|
|
logger.warning("Could not save allowlist: %s", e)
|
|
|
|
|
|
# --- Bypass check (yolo / mode=off) ---------------------------------------------------------------------------------
|
|
|
|
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 approval_context._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=""))
|
|
|
|
|
|
# --- Result builders shared by the gates ----------------------------------------------------------------------------
|
|
|
|
def _approved() -> dict:
|
|
return {"approved": True, "message": None}
|
|
|
|
|
|
# ``outcome`` -> one plain sentence for the person who just answered (or did not). ``message`` is
|
|
# addressed to the model ("Do NOT retry ..."); surfaces render ``user_summary`` first and fold the
|
|
# model text away, so a Reject click does not read like an error the user caused.
|
|
_USER_SUMMARIES = {
|
|
"denied": "You denied this {noun} — it did not run.",
|
|
"timeout": "No answer within {minutes} — the {noun} did not run.",
|
|
"notify_failed": "The approval request could not be delivered — the {noun} did not run.",
|
|
"cancelled": "The approval prompt was withdrawn or never reached you — the {noun} did not run.",
|
|
"blocked": "This {noun} is not allowed in an unattended session — it did not run.",
|
|
}
|
|
|
|
|
|
def _user_summary(outcome: str, noun: str = "command") -> str:
|
|
from tools.approval_context import _get_approval_timeout, format_approval_window
|
|
window = format_approval_window(_get_approval_timeout())
|
|
return _USER_SUMMARIES.get(outcome, "This {noun} did not run.").format(noun=noun, minutes=window)
|
|
|
|
|
|
def _denied(message: str, *, pattern_key: str, description: str, outcome: str, noun: str = "command",
|
|
**extra) -> dict:
|
|
"""Standard non-consent result: the agent must not retry or rephrase. ``user_summary`` is the
|
|
one-line human reading of the same outcome (see ``_USER_SUMMARIES``)."""
|
|
return {"approved": False, "message": message, "pattern_key": pattern_key,
|
|
"description": description, "outcome": outcome, "user_consent": False,
|
|
"user_summary": _user_summary(outcome, noun), **extra}
|
|
|
|
|
|
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_notify_cb(session_key: str):
|
|
with _lock:
|
|
return _gateway_notify_cbs.get(session_key)
|
|
|
|
|
|
def _pending_result(spec, session_key: str, *, command: str, description: str,
|
|
pattern_key: str, pattern_keys: list[str], body: str | None,
|
|
smart_denied: bool) -> dict:
|
|
"""Queue an approval nobody can answer right now (no gateway notifier, no CLI panel) for
|
|
``/approve`` / ``/deny`` review. Command/code gates return the backward-compatible
|
|
``pending_approval`` shape (``pattern_keys`` + STOP text); the action gate ``approval_required``."""
|
|
pending = {"command": command, "pattern_key": pattern_key}
|
|
if spec.pending_keys:
|
|
pending["pattern_keys"] = pattern_keys
|
|
pending["description"] = description
|
|
if smart_denied:
|
|
pending.update(smart_denied=True, allow_permanent=False)
|
|
submit_pending(session_key, pending)
|
|
if not spec.pending_keys:
|
|
return {
|
|
"approved": False, "pattern_key": pattern_key, "status": "approval_required",
|
|
"command": command, "description": description,
|
|
"message": (f"⚠️ This action is potentially dangerous ({description}). "
|
|
f"Asking the user for approval.\n\n**Target:**\n```\n{command}\n```"),
|
|
}
|
|
body = body or f"**Command:**\n```\n{command}\n```"
|
|
result = {
|
|
"approved": False, "pattern_key": pattern_key, "status": "pending_approval",
|
|
"approval_pending": True, "command": command, "description": description,
|
|
"message": (
|
|
f"⚠️ {description}. Asking the user for approval.\n\n{body}\n\n"
|
|
f"STOP: do NOT re-run, rephrase, or re-issue this {spec.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
|
|
|
|
|
|
# --- Unattended contexts (nobody present to answer a prompt) --------------------------------------------------------
|
|
|
|
@dataclass(frozen=True)
|
|
class _Unattended:
|
|
"""One non-interactive context and the text every gate uses to explain it."""
|
|
name: str # "single_query" | "cron" | "unattended"
|
|
cfg_key: str # approvals.<cfg_key>: approve|deny
|
|
clause: str # "why nobody can approve" (lower-case sentence fragment)
|
|
scope: str # "in cron jobs" — completes "To allow ... {scope}"
|
|
trust: str # execute_code: "approve only if {trust}"
|
|
|
|
def mode(self) -> str:
|
|
# Looked up on the defining module at call time so tests patching the getters keep working.
|
|
return getattr(approval_context, f"_get_{self.name}_approval_mode")()
|
|
|
|
def block_message(self, subject: str, *, noun: str, advice: str) -> str:
|
|
return (f"BLOCKED: {subject} but {self.clause}. {advice} To allow {noun} {self.scope}, set "
|
|
f"approvals.{self.cfg_key}: approve in config.yaml.")
|
|
|
|
@property
|
|
def exec_tail(self) -> str:
|
|
return (f"{self.clause[0].upper()}{self.clause[1:]}. Use normal tools "
|
|
f"instead, or set approvals.{self.cfg_key}: approve only if {self.trust}.")
|
|
|
|
|
|
_SINGLE_QUERY_CTX = _Unattended(
|
|
"single_query", "single_query_mode",
|
|
"single-query mode (-q) runs without a user present to approve it",
|
|
"in single-query mode", "this single-query run is intentionally trusted",
|
|
)
|
|
_CRON_CTX = _Unattended(
|
|
"cron", "cron_mode", "cron jobs run without a user present to approve it",
|
|
"in cron jobs", "this cron profile is intentionally trusted",
|
|
)
|
|
|
|
|
|
def _unattended_contexts() -> list[_Unattended]:
|
|
"""Active unattended contexts in evaluation order: single-query first (``hermes chat -q``
|
|
exports HERMES_INTERACTIVE=1 but nobody answers); cron beats a platform marker because
|
|
cron binds the platform for delivery routing only."""
|
|
contexts = []
|
|
if _is_single_query_approval_context():
|
|
contexts.append(_SINGLE_QUERY_CTX)
|
|
if _is_cron_approval_context():
|
|
contexts.append(_CRON_CTX)
|
|
elif _is_unattended_platform_approval_context():
|
|
contexts.append(_Unattended(
|
|
"unattended", "unattended_mode",
|
|
"this session runs on an unattended platform "
|
|
f"({_get_session_platform()}) with no user present to approve it",
|
|
"on unattended platforms", "sessions on this surface are intentionally trusted",
|
|
))
|
|
return contexts
|
|
|
|
|
|
def _unattended_deny(command: str, ctx: _Unattended) -> dict | None:
|
|
"""Deny-mode handling for one unattended context (cron / -q / webhook); None = allow.
|
|
|
|
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.
|
|
"""
|
|
if ctx.mode() != "deny":
|
|
return None
|
|
|
|
def block(subject: str) -> dict:
|
|
return {"approved": False, "message": ctx.block_message(
|
|
subject, noun="dangerous commands",
|
|
advice="Find an alternative approach that avoids this command.")}
|
|
|
|
is_dangerous, pattern_key, description = detect_dangerous_command(command)
|
|
if is_dangerous and not _is_permanently_approved(pattern_key):
|
|
result = block(f"Command flagged as dangerous ({description})")
|
|
if ctx.name == "single_query":
|
|
result.update(pattern_key=pattern_key, 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 {ctx.clause}. "
|
|
f"Find an alternative approach, install tirith, or set approvals.{ctx.cfg_key}: approve in config.yaml.")}
|
|
if tirith.get("action") in ("block", "warn"):
|
|
return block(_format_tirith_description(tirith))
|
|
return None
|
|
|
|
|
|
# --- Human-decision engine shared by the three gates ----------------------------------------------------------------
|
|
# Every flagged action reaches a human the same way — selected plugin transport → gateway round-trip → pending
|
|
# fallback → CLI prompt → persist — so the consent contract (silence is not consent, deny is a hard halt, a smart-DENY
|
|
# override is one operation) cannot drift between gates. Only wording and a few policy knobs differ per flavor; they
|
|
# live in _GateSpec.
|
|
|
|
@dataclass(frozen=True)
|
|
class _GateSpec:
|
|
noun: str # "command" | "code" — for the pending STOP text
|
|
transport: bool # offer the selected plugin transport first
|
|
user_approved: bool # human approval resets the denial tally
|
|
redact_cli: bool # CLI prompt + hooks see the redacted copy
|
|
pending_keys: bool # pending fallback: redacted ``pending_approval`` shape with
|
|
# pattern_keys (True) vs raw ``approval_required`` (False)
|
|
# Message templates. ``{breaker}`` = the denial circuit-breaker addendum,
|
|
# read only where a template shows it (reading it logs when tripped).
|
|
notify_failed: str
|
|
gateway_refused: str # {reason}{reason_addendum}{timeout_addendum}{breaker}
|
|
transport_denied: str # {breaker}
|
|
cli_timeout: str # {breaker}
|
|
cli_denied: str # {description}{breaker}
|
|
smart_log: str # {command}{description}{session_key}
|
|
|
|
|
|
_STOP_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 to respond before "
|
|
"taking any further destructive or irreversible action."
|
|
)
|
|
_STOP_ACTION = (
|
|
" 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."
|
|
)
|
|
|
|
_COMMAND_GATE = _GateSpec(
|
|
noun="command", transport=True, user_approved=True, redact_cli=False, pending_keys=True,
|
|
notify_failed="BLOCKED: Failed to send approval request to user. Do NOT retry.",
|
|
gateway_refused="BLOCKED: Command {reason}.{reason_addendum}" + _STOP_COMMAND
|
|
+ "{timeout_addendum}{breaker}",
|
|
transport_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 another route.{breaker}"
|
|
),
|
|
cli_timeout="BLOCKED: Command timed out without user response." + _STOP_COMMAND
|
|
+ " Silence is not consent.{breaker}",
|
|
cli_denied="BLOCKED: User denied this command." + _STOP_COMMAND + "{breaker}",
|
|
smart_log="Smart approval: auto-approved '{command}' ({description})",
|
|
)
|
|
_EXECUTE_CODE_GATE = _GateSpec(
|
|
noun="code", transport=True, user_approved=True, redact_cli=True, pending_keys=True,
|
|
notify_failed="BLOCKED: Failed to send execute_code approval request to user. Do NOT retry.",
|
|
gateway_refused=(
|
|
"BLOCKED: execute_code script {reason}.{reason_addendum} The user has "
|
|
"NOT consented to running this code. Do NOT retry, do NOT rephrase the "
|
|
"script, and do NOT attempt the same outcome via a different tool.{timeout_addendum}{breaker}"
|
|
),
|
|
transport_denied=(
|
|
"BLOCKED: User denied execute_code through the selected approval transport. The user has NOT consented."
|
|
),
|
|
cli_timeout="BLOCKED: Action timed out without user response." + _STOP_ACTION
|
|
+ " Silence is not consent.{breaker}",
|
|
cli_denied=(
|
|
"BLOCKED: User denied execute_code script execution (matched "
|
|
"'{description}'). Do NOT retry — the user has explicitly rejected it.{breaker}"
|
|
),
|
|
smart_log="Smart approval: auto-approved execute_code for session {session_key}",
|
|
)
|
|
# Plugin-escalated tool calls / protected writes: no transport, no breaker,
|
|
# no user_approved marker (parity with the historical gate).
|
|
_ACTION_GATE = _GateSpec(
|
|
noun="action", transport=False, user_approved=False, redact_cli=False, pending_keys=False,
|
|
notify_failed="BLOCKED: Failed to send approval request to user. Do NOT retry.",
|
|
gateway_refused="BLOCKED: Action {reason}.{reason_addendum}" + _STOP_ACTION
|
|
+ "{timeout_addendum}",
|
|
transport_denied="",
|
|
cli_timeout="BLOCKED: Action timed out without user response." + _STOP_ACTION
|
|
+ " Silence is not consent.",
|
|
cli_denied=(
|
|
"BLOCKED: User denied this potentially dangerous action (matched "
|
|
"'{description}'). Do NOT retry — the user has explicitly rejected it."
|
|
),
|
|
smart_log="",
|
|
)
|
|
|
|
|
|
def _smart_gate(spec: _GateSpec, command: str, description: str, pattern_key: str,
|
|
pattern_keys: list[str], session_key: str, *,
|
|
human_present: bool) -> tuple[dict | None, bool]:
|
|
"""Guardian-LLM step -> ``(result, smart_denied_for_owner)``: a result ends the gate;
|
|
``smart_denied_for_owner`` means an interactive owner may still override the DENY for this
|
|
one operation (once/deny only, nothing persists).
|
|
|
|
APPROVE approves this command only — pattern-level persistence would let one benign
|
|
command suppress review of later commands in the same broad detector category. A DENY
|
|
counts toward the denial breaker even when an owner may override it. ESCALATE follows the
|
|
normal, potentially persistent manual behavior.
|
|
"""
|
|
verdict = _smart_verdict(command, description, pattern_key, pattern_keys, session_key)
|
|
if verdict == "approve":
|
|
_reset_denials(session_key)
|
|
logger.debug(spec.smart_log.format(command=command[:60], description=description, session_key=session_key))
|
|
return {"approved": True, "message": None, "smart_approved": True, "description": description}, False
|
|
if verdict != "deny":
|
|
return None, False
|
|
_record_denial(session_key)
|
|
if human_present:
|
|
return None, True
|
|
return {
|
|
# Unattended programmatic platforms (webhook/msgraph_webhook/ api_server): respect unattended_mode
|
|
# config. Resolves instantly — never a pending approval nobody can answer (#37284, #87509).
|
|
"approved": False,
|
|
"message": (f"BLOCKED by smart approval: {description}. The command was assessed as genuinely "
|
|
f"dangerous. Do NOT retry.{_denial_breaker_addendum(session_key)}"),
|
|
"smart_denied": True,
|
|
}, True
|
|
|
|
|
|
def _human_decision(spec: _GateSpec, *, command: str, description: str,
|
|
pattern_key: str, pattern_keys: list[str], warnings: list[tuple],
|
|
session_key: str, approval_callback, is_cli: bool, is_gateway: bool,
|
|
is_ask: bool, smart: bool = False,
|
|
permanent_capable: bool = True, pending_body=None) -> dict:
|
|
"""Ask a human (after the optional guardian-LLM step) and turn the answer into the gate result.
|
|
|
|
``warnings`` are the ``(key, _, is_tirith)`` tuples :func:`_persist_choice` stores on
|
|
session/always. ``permanent_capable`` hides [a]lways when no key could be permanently
|
|
allowlisted (pure-tirith prompts); a smart-DENY owner override reduces every surface to
|
|
once/deny and persists nothing. ``pending_body`` is a thunk, built only once a human is
|
|
actually asked, so a smart APPROVE never pays for redacting a large script.
|
|
"""
|
|
from agent.redact import redact_sensitive_text
|
|
|
|
smart_denied = False
|
|
if smart:
|
|
result, smart_denied = _smart_gate(spec, command, description, pattern_key, pattern_keys,
|
|
session_key, human_present=is_cli or is_gateway or is_ask)
|
|
if result is not None:
|
|
return result
|
|
pending_body = pending_body() if pending_body else None
|
|
allow_permanent = permanent_capable and not smart_denied
|
|
|
|
def deny(template: str, outcome: str, **fmt) -> dict:
|
|
breaker = ""
|
|
if "{breaker}" in template:
|
|
breaker = _denial_breaker_addendum(session_key)
|
|
deny_reason = fmt.pop("deny_reason", None)
|
|
extra = {"deny_reason": deny_reason} if "reason" in fmt else {}
|
|
return _denied(template.format(description=description, breaker=breaker, **fmt),
|
|
pattern_key=pattern_key, description=description,
|
|
outcome=outcome, noun=spec.noun, **extra)
|
|
|
|
def grant(choice: str) -> dict:
|
|
# A smart-DENY owner override is always one operation, even if an older client returns "session" or "always".
|
|
if not smart_denied:
|
|
_persist_choice(session_key, choice, warnings)
|
|
if spec.user_approved:
|
|
return _user_approved(session_key, description)
|
|
return _approved()
|
|
|
|
if spec.transport:
|
|
attempt = _present_with_selected_transport(
|
|
command=command, description=description, pattern_key=pattern_key, pattern_keys=pattern_keys,
|
|
session_key=session_key, surface="gateway" if (is_gateway or is_ask) else "cli",
|
|
allow_session=not smart_denied, allow_permanent=allow_permanent,
|
|
)
|
|
choice, denied = _transport_choice(attempt, pattern_key=pattern_key, description=description)
|
|
if denied is not None:
|
|
return denied
|
|
if choice is not None:
|
|
if choice == "deny":
|
|
_record_denial(session_key)
|
|
return deny(spec.transport_denied, "denied")
|
|
return grant(choice)
|
|
|
|
# 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:
|
|
# Redacted copies for user-visible rendering only (the gateway paints them into Discord/Slack); the raw
|
|
# command still executes after approval and persistence keys off pattern_key.
|
|
display_command = redact_sensitive_text(command)
|
|
display_description = redact_sensitive_text(description)
|
|
notify_cb = _gateway_notify_cb(session_key)
|
|
if notify_cb is not None:
|
|
# 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 persistence already caps scope at session.
|
|
data = {
|
|
"command": display_command, "pattern_key": pattern_key,
|
|
"pattern_keys": pattern_keys, "description": display_description,
|
|
"allow_permanent": permanent_capable and not smart_denied,
|
|
"allow_session": not smart_denied,
|
|
}
|
|
if smart_denied:
|
|
data["smart_denied"] = True
|
|
decision = _await_gateway_decision(session_key, notify_cb, data, surface="gateway")
|
|
if decision.get("notify_failed"):
|
|
return _denied(spec.notify_failed, pattern_key=pattern_key,
|
|
description=description, outcome="notify_failed", noun=spec.noun)
|
|
# 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".
|
|
choice, deny_reason = decision["choice"], decision.get("reason")
|
|
if decision.get("cancelled"):
|
|
# The prompt was withdrawn (turn interrupted or ended) before anyone answered:
|
|
# still fail closed, but do not attribute a refusal to the user.
|
|
return deny(spec.gateway_refused, "cancelled",
|
|
reason=f"approval was withdrawn before the user answered ({decision['cancelled']})",
|
|
reason_addendum="", timeout_addendum="", deny_reason=None)
|
|
if not decision["resolved"]:
|
|
return deny(spec.gateway_refused, "timeout", reason="timed out without user response",
|
|
reason_addendum="", timeout_addendum=" Silence is not consent.",
|
|
deny_reason=deny_reason)
|
|
if choice is None or choice == "deny":
|
|
return deny(spec.gateway_refused, "denied", reason="denied by user",
|
|
reason_addendum=(f' Reason given by the user: "{deny_reason}".' if deny_reason else ""),
|
|
timeout_addendum="", deny_reason=deny_reason)
|
|
return grant(choice)
|
|
|
|
# No gateway callback (cron, batch, or ask-mode leaked into an interactive CLI, historically via `import
|
|
# gateway.run`): 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,
|
|
):
|
|
if not spec.pending_keys:
|
|
display_command, display_description = command, description
|
|
return _pending_result(
|
|
spec, session_key, command=display_command, description=display_description, pattern_key=pattern_key,
|
|
pattern_keys=pattern_keys, body=pending_body, smart_denied=smart_denied,
|
|
)
|
|
|
|
# CLI interactive: single combined prompt, wrapped in the pre/post plugin hooks.
|
|
prompt_command, prompt_description = command, description
|
|
if spec.redact_cli:
|
|
prompt_command = redact_sensitive_text(command)
|
|
prompt_description = redact_sensitive_text(description)
|
|
hook_kwargs = dict(command=prompt_command, description=prompt_description, pattern_key=pattern_key,
|
|
pattern_keys=list(pattern_keys), session_key=session_key, surface="cli")
|
|
approval_context._fire_approval_hook("pre_approval_request", **hook_kwargs)
|
|
choice = prompt_dangerous_approval(prompt_command, prompt_description, allow_permanent=allow_permanent,
|
|
smart_denied=smart_denied, approval_callback=approval_callback)
|
|
approval_context._fire_approval_hook("post_approval_response", **hook_kwargs, choice=choice)
|
|
if choice == "timeout":
|
|
return deny(spec.cli_timeout, "timeout")
|
|
if choice == "cancelled":
|
|
# The prompt never reached a human (callback raised, no callback under prompt_toolkit, interrupted
|
|
# read): fail closed, but do not attribute a refusal to the user (#22992).
|
|
return deny(spec.gateway_refused, "cancelled",
|
|
reason="was not approved: the approval prompt could not be delivered or was not answered "
|
|
f"({getattr(choice, 'cause', 'no answer')})",
|
|
reason_addendum="", timeout_addendum=" Silence is not consent.", deny_reason=None)
|
|
if choice == "deny":
|
|
# No _record_denial(): the breaker counts consecutive guardian LLM
|
|
# DENY verdicts, not deliberate human denials.
|
|
return deny(spec.cli_denied, "denied")
|
|
return grant(choice)
|
|
|
|
|
|
def _presence(approval_callback=None) -> tuple:
|
|
"""``(approval_callback, is_cli, is_gateway, is_ask)`` for the current context.
|
|
|
|
Single-query ``-q`` and cron clear the presence trio: ``hermes chat -q`` exports
|
|
HERMES_INTERACTIVE=1 for sudo prompts, and a gateway sets HERMES_EXEC_ASK=1 at startup and
|
|
passes its environ to every external cron worker (#110932) — in neither can a human answer
|
|
the card, so the gate must resolve from ``approvals.<ctx>_mode`` instead of parking on a
|
|
pending approval. Unattended *platforms* keep ``is_ask``: api_server relies on it for the
|
|
``/v1/runs`` approval bridge (``approval.request`` → ``POST /v1/runs/{id}/approval``)."""
|
|
approval_callback = _resolve_cli_approval_callback(approval_callback)
|
|
is_cli, is_gateway = _is_interactive_cli(), _is_gateway_approval_context()
|
|
is_ask = env_var_enabled("HERMES_EXEC_ASK")
|
|
if _is_single_query_approval_context() or _is_cron_approval_context():
|
|
is_cli = is_gateway = is_ask = False
|
|
return approval_callback, is_cli, is_gateway, is_ask
|
|
|
|
|
|
def _run_approval_gate(
|
|
*, pattern_key: str, description: str, display_target: str, approval_callback=None,
|
|
subject: str = "", noun: str = "flagged actions",
|
|
advice: str = "Find an alternative approach that avoids this action.",
|
|
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 (tool call or write): decision core for
|
|
:func:`request_tool_approval` and the file-tool write gates.
|
|
|
|
Order: yolo bypass → session-cache short-circuit → interactive/gateway/unattended branch →
|
|
prompt → persistence. Input-shape checks (hardline, allowlist, pattern detection) are the
|
|
caller's job. ``fail_closed_when_no_human``: a non-interactive, non-gateway, non-cron
|
|
context without an ask bridge BLOCKS instead of auto-approving, so a plugin-flagged action
|
|
never runs ungated.
|
|
Unattended deny text is ``ctx.block_message(subject, noun, advice)`` unless the caller passes
|
|
an explicit ``*_deny_message`` (the file-tool write gates word their own).
|
|
"""
|
|
# Hardline blocks are the caller's job BEFORE this gate, so yolo here only skips the recoverable approval layer.
|
|
# ``approvals.mode: off`` is the third bypass source (the Desktop "Approvals: off" toggle writes it); the shell
|
|
# guards honour it, so every action routed through this gate (computer_use, plugin rules, SSH-config writes,
|
|
# dangerous-pattern prompts) must too, or "off" still prompts on those surfaces.
|
|
if _yolo_active() or approval_context._get_approval_mode() == "off":
|
|
return _approved()
|
|
session_key = get_current_session_key()
|
|
if is_approved(session_key, pattern_key):
|
|
return _approved()
|
|
|
|
approval_callback, is_cli, is_gateway, is_ask = _presence(approval_callback)
|
|
if not is_cli and not is_gateway and not is_ask:
|
|
log_args = (autoapprove_log_prefix, pattern_key, description)
|
|
# Every unattended context resolves instantly — never a pending approval nobody can answer.
|
|
deny_messages = {
|
|
"single_query": single_query_deny_message, "cron": cron_deny_message,
|
|
"unattended": unattended_deny_message,
|
|
}
|
|
for ctx in _unattended_contexts():
|
|
if ctx.mode() == "deny":
|
|
message = deny_messages[ctx.name]
|
|
if not message and ctx.name == "unattended":
|
|
# Platform contexts keep the generic wording (historical shape).
|
|
message = ctx.block_message(f"approval required ({description})", noun="flagged actions",
|
|
advice="Find an alternative approach that avoids this action.")
|
|
elif not message:
|
|
message = ctx.block_message(subject, noun=noun, advice=advice)
|
|
return _blocked(message, pattern_key=pattern_key, description=description)
|
|
if ctx.name == "single_query":
|
|
# Return here rather than fall through: the fail-closed branch would
|
|
# otherwise block what single_query_mode: approve just authorized.
|
|
logger.warning("%s (pattern: %s): %s — single-query auto-approve "
|
|
"(approvals.single_query_mode: approve).", *log_args)
|
|
return _approved()
|
|
break # cron/unattended approve-mode: auto-approve below
|
|
else:
|
|
if 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.", *log_args)
|
|
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.", *log_args)
|
|
return _approved()
|
|
|
|
return _human_decision(
|
|
_ACTION_GATE, command=display_target, description=description, pattern_key=pattern_key,
|
|
pattern_keys=[pattern_key], warnings=[(pattern_key, None, False)], session_key=session_key,
|
|
approval_callback=approval_callback, is_cli=is_cli, is_gateway=is_gateway, is_ask=is_ask,
|
|
)
|
|
|
|
|
|
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."""
|
|
if env_type == "docker":
|
|
return not has_host_access
|
|
if env_type in ("singularity", "modal", "daytona", "vercel_sandbox"):
|
|
return True
|
|
# Plugin backends declare the same classification through the provider ABI (#94400);
|
|
# fail-soft to False so an unknown or raising backend — or a raising registry
|
|
# lookup — keeps the guards on rather than propagating out of the approval predicate.
|
|
try:
|
|
from agent.terminal_env_registry import provider_flag
|
|
return bool(provider_flag(env_type, "skip_container_guards", False))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _user_deny_block(command: str) -> dict | None:
|
|
"""The operator's ``approvals.deny`` rules are documented as never bypassable — not by yolo,
|
|
not by mode=off, and not by an isolated container either: they express intent about what the
|
|
agent may DO, not what it can reach, so they are evaluated before the container fast path."""
|
|
deny_pattern = _match_user_deny_rule(command)
|
|
if deny_pattern is None:
|
|
return None
|
|
logger.warning("User deny rule %r blocked command: %s", deny_pattern, command[:200])
|
|
return _user_deny_block_result(deny_pattern)
|
|
|
|
|
|
def _floor_block(command: str, *, sudo_guard: bool = False) -> dict | None:
|
|
"""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 (full guard
|
|
only), the user's own approvals.deny rules ("never, even under yolo"), and
|
|
deletion of the Python interpreter/venv this very runtime boots from (a
|
|
delete the agent cannot walk back — the next start fails before any tool
|
|
can run, #58748)."""
|
|
from agent.runtime_self_protection import command_deletes_runtime
|
|
|
|
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)
|
|
runtime_target = command_deletes_runtime(command)
|
|
if runtime_target:
|
|
logger.warning("Runtime self-delete block: %s (command: %s)", runtime_target, command[:200])
|
|
return _hardline_block_result(f"recursive/any delete of {runtime_target}", command)
|
|
if sudo_guard:
|
|
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)
|
|
return _user_deny_block(command)
|
|
|
|
|
|
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 (pattern layer only). ``has_host_access``:
|
|
a Docker sandbox that bind-mounts host paths 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 _user_deny_block(command) or _approved()
|
|
blocked = _floor_block(command)
|
|
if blocked is not None:
|
|
return blocked
|
|
if _yolo_active():
|
|
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,
|
|
subject=f"Command flagged as dangerous ({description})", noun="dangerous commands",
|
|
advice="Find an alternative approach that avoids this command.",
|
|
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", ...}``:
|
|
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
|
|
context without an approval bridge fails CLOSED. ``rule_key`` controls the ``[a]lways``
|
|
allowlist grain; when empty it is ``tool_name`` + a hash of ``reason`` so DISTINCT reasons
|
|
on the same tool persist independently. Returns the ``check_dangerous_command`` result shape.
|
|
"""
|
|
description = reason or f"Plugin requires approval for {tool_name}"
|
|
if not rule_key:
|
|
rule_key = f"{tool_name}:{hashlib.sha256(description.encode('utf-8')).hexdigest()[:12]}"
|
|
subject = f"Tool '{tool_name}' requires approval ({description})"
|
|
return _run_approval_gate(
|
|
# Namespaced so plugin-rule approvals share the allowlist machinery without ever colliding with a real
|
|
# command pattern key; the display target is a synthetic label for the display/allowlist layer.
|
|
pattern_key=f"plugin_rule:{rule_key}", description=description,
|
|
display_target=f"<{tool_name}> (plugin approval rule)", approval_callback=approval_callback,
|
|
subject=subject, advice="Find an alternative approach.",
|
|
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: {subject} 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."""
|
|
parts = []
|
|
for f in tirith_result.get("findings") or []:
|
|
severity, title, desc = f.get("severity", ""), f.get("title", ""), f.get("description", "")
|
|
if title:
|
|
text = f"{title}: {desc}" if desc else title
|
|
parts.append(f"[{severity}] {text}" if severity else text)
|
|
if not parts:
|
|
summary = tirith_result.get("summary") or "security issue detected"
|
|
return f"Security scan: {summary}"
|
|
return "Security scan — " + "; ".join(parts)
|
|
|
|
|
|
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", "summary": "Tirith unavailable (fail-closed)", "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."),
|
|
}]}
|
|
|
|
|
|
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 takes the normal flow."""
|
|
if _should_skip_container_guards(env_type, has_host_access=has_host_access):
|
|
return _user_deny_block(command) or _approved()
|
|
|
|
blocked = _floor_block(command, sudo_guard=True)
|
|
if blocked is not None:
|
|
return blocked
|
|
|
|
from agent.terminal_approval_batch import consume_prepared_guard
|
|
prepared = consume_prepared_guard(command, env_type, has_host_access)
|
|
if prepared is not None:
|
|
return prepared
|
|
|
|
approval_mode = approval_context._get_approval_mode()
|
|
if _yolo_active() or approval_mode == "off":
|
|
return _approved()
|
|
if _command_matches_permanent_allowlist(command):
|
|
return _approved()
|
|
|
|
approval_callback, is_cli, is_gateway, is_ask = _presence(approval_callback)
|
|
# 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 ctx in _unattended_contexts():
|
|
result = _unattended_deny(command, ctx)
|
|
if result is not None:
|
|
return result
|
|
return _approved()
|
|
|
|
# Gather findings: warnings = [(pattern_key, description, is_tirith)]. 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.
|
|
tirith_result = _tirith_scan(command)
|
|
is_dangerous, pattern_key, description = detect_dangerous_command(command)
|
|
warnings = []
|
|
session_key = get_current_session_key()
|
|
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]
|
|
|
|
# "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).
|
|
return _human_decision(
|
|
_COMMAND_GATE, command=command, description=combined_desc,
|
|
pattern_key=primary_key, pattern_keys=all_keys, warnings=warnings,
|
|
session_key=session_key, approval_callback=approval_callback,
|
|
is_cli=is_cli, is_gateway=is_gateway, is_ask=is_ask, smart=approval_mode == "smart",
|
|
permanent_capable=any(not is_t for _, _, is_t in warnings),
|
|
)
|
|
|
|
|
|
_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."
|
|
)
|
|
|
|
|
|
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. Same dict contract as ``check_all_command_guards``.
|
|
Documented limitation: a purely local non-interactive non-gateway session returns approved
|
|
(the terminal auto-approve contract); the hardline floor still blocks catastrophic
|
|
``terminal()`` commands the script issues.
|
|
|
|
See #30882.
|
|
The hardline floor still blocks catastrophic ``terminal()`` commands the script issues; running
|
|
arbitrary code headlessly without any approval surface is trusted-by-config (set a gateway/ask surface
|
|
or ``approvals.cron_mode`` to require approval). See #30882.
|
|
"""
|
|
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 = approval_context._get_approval_mode()
|
|
if _yolo_active() or approval_mode == "off":
|
|
return _approved()
|
|
|
|
# (-q clears the presence flags, but its unattended context resolves first anyway.)
|
|
approval_callback, is_cli, is_gateway, is_ask = _presence()
|
|
# No user is present to approve arbitrary code in -q / cron / unattended
|
|
# sessions: the first active context resolves instantly from its mode.
|
|
for ctx in _unattended_contexts():
|
|
if ctx.mode() == "deny":
|
|
return _denied(
|
|
"BLOCKED: execute_code runs arbitrary local Python (including "
|
|
"subprocess calls that bypass shell-string approval checks). " + ctx.exec_tail,
|
|
pattern_key=pattern_key, description=description, outcome="blocked", noun="code",
|
|
)
|
|
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
|
|
# engine 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. The gateway renders the pending payload to Discord/Slack, so the script body is redacted for
|
|
# display; the raw code is what gets assessed and run.
|
|
from agent.redact import redact_sensitive_text
|
|
return _human_decision(
|
|
_EXECUTE_CODE_GATE, command=command, description=description, pattern_key=pattern_key,
|
|
pattern_keys=[pattern_key], warnings=[(pattern_key, None, False)], session_key=session_key,
|
|
approval_callback=approval_callback, is_cli=is_cli, is_gateway=is_gateway, is_ask=is_ask,
|
|
smart=approval_mode == "smart",
|
|
pending_body=lambda: f"**Code:**\n```python\n{redact_sensitive_text(code)}\n```",
|
|
)
|
|
|
|
|
|
# Load permanent allowlist from config on module import
|
|
load_permanent_allowlist()
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
import contextlib # noqa: F401,E402
|
|
import contextvars # noqa: F401,E402
|
|
import fnmatch # noqa: F401,E402
|
|
import functools # noqa: F401,E402
|
|
import re # noqa: F401,E402
|
|
import shlex # noqa: F401,E402
|
|
import sys # noqa: F401,E402
|
|
import tempfile # noqa: F401,E402
|
|
import time # noqa: F401,E402
|
|
import unicodedata # noqa: F401,E402
|
|
import uuid # noqa: F401,E402
|
|
|
|
|
|
_PLUGIN_COMPAT_LAZY = {
|
|
'DANGEROUS_PATTERNS': ('tools.approval_detection', 'DANGEROUS_PATTERNS'),
|
|
'DANGEROUS_PATTERNS_COMPILED': ('tools.approval_detection', 'DANGEROUS_PATTERNS_COMPILED'),
|
|
'HARDLINE_PATTERNS': ('tools.approval_detection', 'HARDLINE_PATTERNS'),
|
|
'HARDLINE_PATTERNS_COMPILED': ('tools.approval_detection', 'HARDLINE_PATTERNS_COMPILED'),
|
|
'HUMAN_WAIT_MARGIN_S': ('tools.approval_human_wait', 'HUMAN_WAIT_MARGIN_S'),
|
|
'cfg_get': ('hermes_cli.config', 'cfg_get'),
|
|
'get_plugin_manager': ('tools.approval_prompt', 'get_plugin_manager'),
|
|
'human_wait_ceiling': ('tools.approval_human_wait', 'human_wait_ceiling'),
|
|
'human_wait_seconds': ('tools.approval_human_wait', 'human_wait_seconds'),
|
|
'human_wait_window': ('tools.approval_human_wait', 'human_wait_window'),
|
|
'is_interrupted': ('tools.interrupt', 'is_interrupted'),
|
|
'request_elicitation_consent': ('tools.approval_prompt', 'request_elicitation_consent'),
|
|
'reset_current_observability_context': ('tools.approval_context', 'reset_current_observability_context'),
|
|
'reset_current_session_key': ('tools.approval_context', 'reset_current_session_key'),
|
|
'reset_hermes_interactive_context': ('tools.approval_context', 'reset_hermes_interactive_context'),
|
|
'set_current_observability_context': ('tools.approval_context', 'set_current_observability_context'),
|
|
'set_current_session_key': ('tools.approval_context', 'set_current_session_key'),
|
|
'set_hermes_interactive_context': ('tools.approval_context', 'set_hermes_interactive_context'),
|
|
}
|
|
|
|
|
|
def __getattr__(name): # PEP 562 — lazy so no import cycles
|
|
target = _PLUGIN_COMPAT_LAZY.get(name)
|
|
if target is None:
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
import importlib
|
|
from hermes_cli.plugin_compat import warn_once
|
|
warn_once(__name__, name, *target)
|
|
return getattr(importlib.import_module(target[0]), target[1])
|
|
# ---- END PLUGIN-COMPAT ----
|