Files
hermes-agent/tools/approval.py
Hermes Agent 541e4dc0ba fix(agent): protect the runtime's own interpreter from agent deletes
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
2026-09-25 16:24:21 -05:00

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 ----