"""Dangerous command approval -- the gate flow and per-session state. Single source of truth for the dangerous command system. This module 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. The leaves it re-exports: - :mod:`tools.approval_detection` -- hardline / dangerous pattern detection - :mod:`tools.approval_context` -- session/interactive contextvars, config readers - :mod:`tools.approval_floors` -- pre-gate blocks and the permanent allowlist match - :mod:`tools.approval_prompt` -- CLI prompt, plugin transports, MCP elicitation - :mod:`tools.approval_gateway_wait` -- blocking gateway round-trip - :mod:`tools.approval_smart` -- guardian-LLM verdicts - :mod:`tools.approval_human_wait` -- human-wait accounting Every private name is re-exported here so ``from tools.approval import X`` and ``patch("tools.approval.X")`` keep working; leaf modules call back through ``tools.approval`` at call time for the same reason. """ from dataclasses import dataclass import hashlib import logging import os import threading from typing import Optional from utils import env_var_enabled, is_truthy_value from tools.approval_context import ( # noqa: F401 -- re-exported for callers/tests _approval_session_key, _approval_turn_id, _approval_tool_call_id, set_hermes_interactive_context, reset_hermes_interactive_context, _is_interactive_cli, _fire_approval_hook, set_current_session_key, reset_current_session_key, set_current_observability_context, reset_current_observability_context, get_current_session_key, _get_session_platform, _is_cron_approval_context, _UNATTENDED_APPROVAL_PLATFORMS, _is_unattended_platform_approval_context, _is_single_query_approval_context, _is_gateway_approval_context, _resolve_cli_approval_callback, _should_fall_through_to_cli_approval, _normalize_approval_mode, _get_approval_config, _get_approval_mode, _get_approval_timeout, _get_cron_approval_mode, _get_single_query_approval_mode, _get_unattended_approval_mode, _tirith_fail_open, _get_approval_transport_config, ) from tools.approval_prompt import ( # noqa: F401 -- re-exported for callers/tests prompt_dangerous_approval, get_plugin_manager, _present_with_selected_transport, _transport_choice, request_elicitation_consent, ) from tools.approval_floors import ( # noqa: F401 -- re-exported for callers/tests _match_user_deny_rule, _user_deny_block_result, _save_blocked_payload, _hardline_block_result, _sudo_stdin_block_result, _has_allowlist_shell_operator, _command_matches_permanent_allowlist, ) from tools.approval_detection import ( # noqa: F401 -- re-exported for callers/tests _SYSTEM_CONFIG_PATH, _COMMAND_TAIL, HARDLINE_PATTERNS, _check_sudo_stdin_guard, detect_hardline_command, DANGEROUS_PATTERNS, _approval_key_aliases, _normalize_command_for_detection, _rewrite_resolved_user_home, _rewrite_resolved_hermes_home, _MAX_SEPARATOR_FREE_COMMAND_CHARS, _PARSER_LIMIT_DESCRIPTION, _MALFORMED_EXEC_DESCRIPTION, _bash_exec_payload, _read_shell_word, _deobfuscate_shell_word_for_detection, _iter_shell_command_starts, _command_detection_variants, detect_dangerous_command, ) from tools.approval_human_wait import ( # noqa: F401 -- re-exported for callers/tests _human_wait_lock, _human_wait_states, _HUMAN_WAIT_MAX_SESSIONS, HUMAN_WAIT_MARGIN_S, human_wait_ceiling, human_wait_window, human_wait_seconds, ) from tools.approval_smart import ( # noqa: F401 -- re-exported for callers/tests _strip_shell_comments, _strip_line_comment, _get_smart_policy, _smart_approve, _smart_verdict, ) from tools.approval_gateway_wait import ( # noqa: F401 -- re-exported for callers/tests _ApprovalEntry, _await_gateway_decision, ) 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() # ========================================================================= # Consecutive-denial circuit breaker for smart approvals # ========================================================================= # Nothing stops the model from retrying variants of a smart-denied command — # each retry burns another guardian LLM call. After # ``approvals.denial_breaker_threshold`` consecutive guardian DENY verdicts in # one session (default 3; 0 disables) the deny message escalates to a # hard-stop instruction; any approval resets the tally. Only TOOL RESULT text # changes — no history surgery, no interrupts — so it is prompt-cache-invariant. _denial_tally: dict[str, int] = {} # Small cap so an army of short-lived session keys cannot grow it without # bound; oldest (least recently denied) entries are evicted. _DENIAL_TALLY_MAX_SESSIONS = 256 def _get_denial_breaker_threshold() -> int: """``approvals.denial_breaker_threshold``: default 3; 0 or negative disables.""" try: return int(_get_approval_config().get("denial_breaker_threshold", 3)) except (ValueError, TypeError): return 3 def _record_denial(session_key: str) -> int: """Increment and return the session's consecutive guardian-denial count. Pop-and-reinsert keeps actively-denying sessions at the most-recent end so insertion-ordered eviction drops genuinely idle keys. """ with _lock: count = _denial_tally.pop(session_key, 0) + 1 _denial_tally[session_key] = count while len(_denial_tally) > _DENIAL_TALLY_MAX_SESSIONS: _denial_tally.pop(next(iter(_denial_tally))) return count def _reset_denials(session_key: str) -> None: """Clear the session's consecutive-denial tally (an approval happened).""" with _lock: _denial_tally.pop(session_key, None) def _denial_breaker_addendum(session_key: str) -> str: """Escalated hard-stop text once the breaker has tripped, else ''. Read-only: callers increment via :func:`_record_denial` on the guardian DENY verdict. The result is appended verbatim to the deny message. """ with _lock: count = _denial_tally.get(session_key, 0) threshold = _get_denial_breaker_threshold() if threshold <= 0 or count < threshold: return "" logger.warning( "Smart-approval circuit breaker tripped for session %s: " "%d consecutive denials (threshold %d)", session_key, count, threshold, ) return ( f" CIRCUIT BREAKER: {count} consecutive commands were blocked by " "the security reviewer. STOP attempting variations of this " "operation. Report the blocked operation to the user and either " "ask them to run it manually or use /approve." ) # ========================================================================= # Gateway approval queue (the blocking wait loop lives in approval_gateway_wait) # ========================================================================= _gateway_queues: dict[str, list] = {} # session_key → [_ApprovalEntry, …] _gateway_notify_cbs: dict[str, object] = {} # session_key → callable(approval_data) def register_gateway_notify(session_key: str, cb) -> None: """Register ``cb(approval_data: dict) -> None`` for sending approval requests. The callback bridges sync→async: it runs in the agent thread and must schedule the actual send on the event loop. """ with _lock: _gateway_notify_cbs[session_key] = cb def unregister_gateway_notify(session_key: str) -> None: """Unregister the callback and wake ALL blocked threads for this session so they don't hang forever (agent run finished or interrupted).""" with _lock: _gateway_notify_cbs.pop(session_key, None) entries = _gateway_queues.pop(session_key, []) for entry in entries: entry.event.set() def resolve_gateway_approval(session_key: str, choice: str, resolve_all: bool = False, reason: Optional[str] = None, request_id: Optional[str] = None) -> int: """Unblock waiting agent thread(s) from the gateway's /approve or /deny handler. *resolve_all* resolves every pending approval (``/approve all``); otherwise the oldest (FIFO) or the one matching *request_id*. *reason* is the free text from ``/deny ``, relayed to the agent in the BLOCKED message. Returns the number resolved (0 = nothing pending). """ with _lock: queue = _gateway_queues.get(session_key) if not queue: return 0 if request_id: targets = [entry for entry in queue if entry.data.get("request_id") == request_id] if not targets: return 0 queue[:] = [entry for entry in queue if entry not in targets] elif resolve_all: targets = list(queue) queue.clear() else: targets = [queue.pop(0)] if not queue: _gateway_queues.pop(session_key, None) for entry in targets: entry.result = choice if reason: entry.reason = reason entry.event.set() return len(targets) def list_gateway_approvals(session_key: str) -> list[dict]: """Return replay-safe snapshots of unresolved approvals for one session.""" with _lock: return [dict(entry.data) for entry in _gateway_queues.get(session_key, [])] def ack_gateway_approval(session_key: str, request_id: str) -> bool: """Record that a client received a particular pending approval request.""" with _lock: for entry in _gateway_queues.get(session_key, []): if entry.data.get("request_id") == request_id: entry.acknowledged = True return True return False def has_blocking_approval(session_key: str) -> bool: """Check if a session has one or more blocking gateway approvals waiting.""" with _lock: return bool(_gateway_queues.get(session_key)) def get_pending_gateway_approval(session_key: str) -> dict | None: """Copy of the oldest unresolved gateway approval, for reconnecting clients to restore a prompt. Read-only snapshot — the queue stays authoritative.""" if not session_key: return None with _lock: queue = _gateway_queues.get(session_key) if not queue: return None return dict(queue[0].data) def submit_pending(session_key: str, approval: dict): """Store a pending approval request for a session.""" with _lock: _pending[session_key] = approval def approve_session(session_key: str, pattern_key: str): """Approve a pattern for this session only.""" with _lock: _session_approved.setdefault(session_key, set()).add(pattern_key) def _release_permission_mode_dependents(session_key: str) -> None: """Drop resources whose immutable mode derives from Hermes YOLO. Lazy import so approval-only sessions never load computer-use. Releasing on both edges makes enabling YOLO replace a standard backend and disabling it revoke a private unrestricted daemon immediately. """ try: from tools.computer_use import release_computer_use_session release_computer_use_session(session_key) except Exception: logger.debug( "Failed to release permission-mode dependent resources for %s", session_key, exc_info=True, ) def enable_session_yolo(session_key: str) -> None: """Enable YOLO bypass for a single session key.""" if not session_key: return with _lock: _session_yolo.add(session_key) _release_permission_mode_dependents(session_key) def disable_session_yolo(session_key: str) -> None: """Disable YOLO bypass for a single session key.""" if not session_key: return with _lock: _session_yolo.discard(session_key) _release_permission_mode_dependents(session_key) def clear_session(session_key: str) -> None: """Remove all approval and yolo state for a given session.""" if not session_key: return with _lock: _session_approved.pop(session_key, None) _session_yolo.discard(session_key) _pending.pop(session_key, None) entries = _gateway_queues.pop(session_key, []) for entry in entries: # Cancel blocked waits now so the old run unwinds instead of idling # until timeout. entry.result = "deny" entry.event.set() _release_permission_mode_dependents(session_key) # Session-persistent code kernels (local and remote) share this owner key # and die at the same boundary so a finished conversation cannot leak a # live interpreter. try: from tools.code_kernel import shutdown_kernels_for_owner shutdown_kernels_for_owner(session_key) except Exception: pass try: from tools.code_kernel_remote import shutdown_remote_kernels_for_owner shutdown_remote_kernels_for_owner(session_key) except Exception: pass def is_session_yolo_enabled(session_key: str) -> bool: """Return True when YOLO bypass is enabled for a specific session.""" if not session_key: return False with _lock: return session_key in _session_yolo def is_current_session_yolo_enabled() -> bool: """Return True when the active approval session has YOLO bypass enabled.""" return is_session_yolo_enabled(get_current_session_key(default="")) def is_approved(session_key: str, pattern_key: str) -> bool: """Check if a pattern is approved (session-scoped or permanent). Accepts the canonical key and the legacy regex-derived key so existing command_allowlist entries keep working after key migrations. """ aliases = _approval_key_aliases(pattern_key) with _lock: if any(alias in _permanent_approved for alias in aliases): return True session_approvals = _session_approved.get(session_key, set()) return any(alias in session_approvals for alias in aliases) def approve_permanent(pattern_key: str): """Add a pattern to the permanent allowlist.""" with _lock: _permanent_approved.add(pattern_key) def load_permanent(patterns: set): """Bulk-load permanent allowlist entries from config.""" with _lock: _permanent_approved.update(patterns) def _persist_choice(session_key: str, choice: str, warnings: list[tuple]) -> None: """Persist a human ``session``/``always`` choice for each ``(key, _, is_tirith)``. Tirith findings are session-max by design: no broad permanent allowlisting of content-level security findings, so ``always`` downgrades them to session. ``once`` (or any other choice) persists nothing. """ for key, _, is_tirith in warnings: if choice == "session" or (choice == "always" and is_tirith): approve_session(session_key, key) elif choice == "always": approve_session(session_key, key) approve_permanent(key) save_permanent_allowlist(_permanent_approved) # ========================================================================= # Config persistence for permanent allowlist # ========================================================================= def load_permanent_allowlist() -> set: """Load ``command_allowlist`` from config and sync it into the approval state so is_approved() honors 'always' choices from previous sessions.""" try: from hermes_cli.config import load_config_readonly config = load_config_readonly() patterns = set(config.get("command_allowlist", []) or []) if patterns: load_permanent(patterns) return patterns except Exception as e: logger.warning("Failed to load permanent allowlist: %s", e) return set() def save_permanent_allowlist(patterns: set): """Save permanently allowed command patterns to config.""" try: from hermes_cli.config import load_config, save_config config = load_config() config["command_allowlist"] = list(patterns) save_config(config) except Exception as e: logger.warning("Could not save allowlist: %s", e) # ========================================================================= # 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 _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 # ========================================================================= _APPROVED = {"approved": True, "message": None} def _approved() -> dict: return dict(_APPROVED) def _denied(message: str, *, pattern_key: str, description: str, outcome: str, **extra) -> dict: """Standard non-consent result: the agent must not retry or rephrase.""" result = { "approved": False, "message": message, "pattern_key": pattern_key, "description": description, "outcome": outcome, "user_consent": False, } result.update(extra) return result def _blocked(message: str, *, pattern_key: str, description: str) -> dict: """Non-interactive block (cron / -q / unattended / no-human): no consent keys.""" return { "approved": False, "message": message, "pattern_key": pattern_key, "description": description, } def _user_approved(session_key: str, description: str) -> dict: """A human approval (incl. ESCALATE-then-approve or a smart-DENY owner override) resets the consecutive-denial tally.""" _reset_denials(session_key) return {"approved": True, "message": None, "user_approved": True, "description": description} def _gateway_refusal(decision: dict): """``(reason, reason_addendum, timeout_addendum, outcome, deny_reason)`` when the gateway decision is not an approval, else None. Consent contract: silence is NOT consent, and an explicit deny is a hard halt — both produce a BLOCKED outcome. ``/deny `` free text is relayed verbatim so the agent can adapt rather than only hearing "denied". """ resolved, choice = decision["resolved"], decision["choice"] deny_reason = decision.get("reason") if resolved and choice is not None and choice != "deny": return None if not resolved: return ("timed out without user response", "", " Silence is not consent.", "timeout", deny_reason) reason_addendum = f' Reason given by the user: "{deny_reason}".' if deny_reason else "" return ("denied by user", reason_addendum, "", "denied", deny_reason) def _gateway_notify_cb(session_key: str): with _lock: return _gateway_notify_cbs.get(session_key) def _gateway_approval_data(display_command: str, display_description: str, pattern_key: str, pattern_keys: list[str], *, allow_permanent: bool, smart_denied: bool) -> dict: """Payload the gateway renders to Discord/Slack/etc. (screenshottable — the caller passes REDACTED copies; the raw command still executes after approval and persistence keys off pattern_key, so redaction is display-only). Smart DENY overrides are one-operation decisions, so the UI must not offer a permanent scope. Session approval is safe for every non-Smart-DENY prompt — including pure-tirith ones, where the persistence layer already caps scope at session; adapters render the session tier independently. """ data = { "command": display_command, "pattern_key": pattern_key, "pattern_keys": pattern_keys, "description": display_description, "allow_permanent": allow_permanent and not smart_denied, "allow_session": not smart_denied, } if smart_denied: data["smart_denied"] = True return data def _pending_result(session_key: str, *, display_command: str, display_description: str, pattern_key: str, pattern_keys: list[str], body: str, noun: str, smart_denied: bool) -> dict: """Queue a pending approval (no gateway notifier registered) and return the backward-compatible ``pending_approval`` result.""" pending_data = { "command": display_command, "pattern_key": pattern_key, "pattern_keys": pattern_keys, "description": display_description, } if smart_denied: pending_data.update(smart_denied=True, allow_permanent=False) submit_pending(session_key, pending_data) result = { "approved": False, "pattern_key": pattern_key, "status": "pending_approval", "approval_pending": True, "command": display_command, "description": display_description, "message": ( f"⚠️ {display_description}. Asking the user for approval.\n\n{body}\n\n" f"STOP: do NOT re-run, rephrase, or re-issue this {noun} — each " "variant sends the user ANOTHER approval card. Wait for the " "user's decision; if this turn must end, report that approval " "is pending." ), } if smart_denied: result.update(smart_denied=True, allow_permanent=False) return result def _action_pending_result(session_key: str, *, display_command: str, display_description: str, pattern_key: str, **_ignored) -> dict: """Queue a plugin-escalated action nobody can answer right now (e.g. API server without an attached chat) for ``/approve`` / ``/deny`` review; the agent sees ``approval_required``.""" submit_pending(session_key, { "command": display_command, "pattern_key": pattern_key, "description": display_description, }) return { "approved": False, "pattern_key": pattern_key, "status": "approval_required", "command": display_command, "description": display_description, "message": ( f"⚠️ This action is potentially dangerous ({display_description}). " f"Asking the user for approval.\n\n**Target:**\n```\n{display_command}\n```" ), } def _prompt_cli_with_hooks(command: str, description: str, pattern_key: str, pattern_keys: list[str], session_key: str, **prompt_kwargs) -> str: """CLI prompt wrapped in the pre/post approval plugin hooks.""" hook_kwargs = dict( command=command, description=description, pattern_key=pattern_key, pattern_keys=list(pattern_keys), session_key=session_key, surface="cli", ) _fire_approval_hook("pre_approval_request", **hook_kwargs) choice = prompt_dangerous_approval(command, description, **prompt_kwargs) _fire_approval_hook("post_approval_response", **hook_kwargs, choice=choice) return choice # ========================================================================= # 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.: 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 at call time so tests patching the getters keep working. return _UNATTENDED_MODE_GETTERS[self.name]() def hint(self, noun: str, advice: str) -> str: """``{advice} To allow {noun} {scope}, set approvals.: approve in config.yaml.``""" return (f"{advice} To allow {noun} {self.scope}, set " f"approvals.{self.cfg_key}: approve in config.yaml.") def block_message(self, subject: str, *, noun: str, advice: str) -> str: return f"BLOCKED: {subject} but {self.clause}. {self.hint(noun, advice)}" @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 " f"{self.trust}.") _UNATTENDED_MODE_GETTERS = { "single_query": lambda: _get_single_query_approval_mode(), "cron": lambda: _get_cron_approval_mode(), "unattended": lambda: _get_unattended_approval_mode(), } _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). Pattern detection first, then tirith so content-level threats (homograph URLs, pipe-to-interpreter, terminal injection) are caught even when the pattern detector misses. An un-importable tirith honours ``security.tirith_fail_open``: fail-closed means block, since nobody can approve (#20733). Returns None to allow. """ if ctx.mode() != "deny": return None advice = "Find an alternative approach that avoids this command." is_dangerous, _pk, description = detect_dangerous_command(command) if is_dangerous: result = { "approved": False, "message": ctx.block_message(f"Command flagged as dangerous ({description})", noun="dangerous commands", advice=advice), } if ctx.name == "single_query": result.update(pattern_key=_pk, description=description) return result try: from tools.tirith_security import check_command_security tirith = check_command_security(command) except ImportError: if _tirith_fail_open(): return None return { "approved": False, "message": ( "BLOCKED: the Tirith security scanner could not be " "imported and security.tirith_fail_open is false, " f"so this command cannot be silently allowed — and {ctx.clause}. " f"Find an alternative approach, install tirith, or set " f"approvals.{ctx.cfg_key}: approve in config.yaml." ), } if tirith.get("action") in ("block", "warn"): return { "approved": False, "message": ctx.block_message(_format_tirith_description(tirith), noun="dangerous commands", advice=advice), } 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 the # 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 redact_pending: bool # pending payload/result carry the redacted copy pending: object # builder for the "no notifier, no panel" fallback # 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, redact_pending=True, pending=_pending_result, 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, redact_pending=True, pending=_pending_result, 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, redact_pending=False, pending=_action_pending_result, 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 consecutive-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 { "approved": False, "message": f"BLOCKED by smart approval: {description}. " "The command was assessed as genuinely dangerous. " f"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_denied: bool = False, permanent_capable: bool = True, pending_body: str | None = None) -> dict: """Ask a human 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. """ from agent.redact import redact_sensitive_text 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, **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: decision = _await_gateway_decision( session_key, notify_cb, _gateway_approval_data(display_command, display_description, pattern_key, pattern_keys, allow_permanent=permanent_capable, smart_denied=smart_denied), surface="gateway", ) if decision.get("notify_failed"): return _denied(spec.notify_failed, pattern_key=pattern_key, description=description, outcome="notify_failed") refusal = _gateway_refusal(decision) if refusal is not None: reason, reason_addendum, timeout_addendum, outcome, deny_reason = refusal return deny(spec.gateway_refused, outcome, reason=reason, reason_addendum=reason_addendum, timeout_addendum=timeout_addendum, deny_reason=deny_reason) return grant(decision["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.redact_pending: display_command, display_description = command, description return spec.pending( session_key, display_command=display_command, display_description=display_description, pattern_key=pattern_key, pattern_keys=pattern_keys, body=pending_body or f"**Command:**\n```\n{display_command}\n```", noun=spec.noun, smart_denied=smart_denied, ) # CLI interactive: single combined prompt. prompt_command, prompt_description = command, description if spec.redact_cli: prompt_command = redact_sensitive_text(command) prompt_description = redact_sensitive_text(description) choice = _prompt_cli_with_hooks( prompt_command, prompt_description, pattern_key, pattern_keys, session_key, allow_permanent=allow_permanent, smart_denied=smart_denied, approval_callback=approval_callback, ) if choice == "timeout": return deny(spec.cli_timeout, "timeout") 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 _run_approval_gate( *, pattern_key: str, description: str, display_target: str, approval_callback=None, cron_deny_message: str, single_query_deny_message: str, unattended_deny_message: str = "", autoapprove_log_prefix: str, fail_closed_when_no_human: bool = False, no_human_block_message: str = "", ) -> dict: """Shared human-approval gate for a flagged action (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 BLOCKS instead of auto-approving, so a plugin-flagged action never runs ungated without a human. """ # Hardline blocks are the caller's job BEFORE this gate, so yolo here only # skips the recoverable approval layer. if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled(): return _approved() session_key = get_current_session_key() if is_approved(session_key, pattern_key): return _approved() approval_callback = _resolve_cli_approval_callback(approval_callback) is_cli = _is_interactive_cli() is_gateway = _is_gateway_approval_context() if _is_single_query_approval_context(): is_cli = is_gateway = False if not is_cli and not is_gateway: deny_messages = {"single_query": single_query_deny_message, "cron": cron_deny_message} for ctx in _unattended_contexts(): if ctx.mode() == "deny": if ctx.name in deny_messages: message = deny_messages[ctx.name] else: # Resolves instantly — never a pending approval nobody can answer. message = unattended_deny_message or ctx.block_message( f"approval required ({description})", noun="flagged actions", advice="Find an alternative approach that avoids this action.") 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).", autoapprove_log_prefix, pattern_key, description, ) 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.", autoapprove_log_prefix, pattern_key, description, ) return _blocked( no_human_block_message or ( f"BLOCKED: approval required ({description}) but no " "interactive user or gateway is present to approve it." ), pattern_key=pattern_key, description=description, ) logger.warning( "%s (pattern: %s): %s — set HERMES_INTERACTIVE or " "HERMES_GATEWAY_SESSION to require approval.", autoapprove_log_prefix, pattern_key, description, ) return _approved() 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=env_var_enabled("HERMES_EXEC_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, so it goes through normal approval. """ if env_type == "docker": return not has_host_access return env_type in ("singularity", "modal", "daytona", "vercel_sandbox") def _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), and the user's own approvals.deny rules ("never, even under yolo").""" is_hardline, hardline_desc = detect_hardline_command(command) if is_hardline: logger.warning("Hardline block: %s (command: %s)", hardline_desc, command[:200]) return _hardline_block_result(hardline_desc, command) 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) deny_pattern = _match_user_deny_rule(command) if deny_pattern is not None: logger.warning("User deny rule %r blocked command: %s", deny_pattern, command[:200]) return _user_deny_block_result(deny_pattern) return None 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 bind-mounts host paths, so its commands can reach the host and must not skip approval. Returns ``{"approved": True/False, "message": str or None, ...}``. """ if _should_skip_container_guards(env_type, has_host_access=has_host_access): return _approved() blocked = _floor_block(command) if blocked is not None: return blocked # Gateway /yolo is session-scoped; CLI --yolo is process-scoped. if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled(): return _approved() if _command_matches_permanent_allowlist(command): return _approved() is_dangerous, pattern_key, description = detect_dangerous_command(command) if not is_dangerous: return _approved() subject = f"Command flagged as dangerous ({description})" advice = "Find an alternative approach that avoids this command." return _run_approval_gate( pattern_key=pattern_key, description=description, display_target=command, approval_callback=approval_callback, cron_deny_message=_CRON_CTX.block_message(subject, noun="dangerous commands", advice=advice), single_query_deny_message=_SINGLE_QUERY_CTX.block_message( subject, noun="dangerous commands", advice=advice), autoapprove_log_prefix=( "AUTO-APPROVED dangerous command in non-interactive non-gateway context" ), ) def request_tool_approval( tool_name: str, reason: str, *, rule_key: str = "", approval_callback=None, ) -> dict: """Escalate an arbitrary tool call to the human-approval gate. Entry point for a plugin ``pre_tool_call`` hook returning ``{"action": "approve", "message": ...}``: it asks the SAME human gate as Tier-2 dangerous shell patterns (session/permanent allowlist, CLI prompt, gateway pending, once/session/always/deny, timeout fail-closed), so the LLM cannot skip it. Cron honors ``approvals.cron_mode``; any OTHER non-interactive non-gateway context fails CLOSED. ``rule_key`` controls the ``[a]lways`` allowlist grain. When empty, the key is ``tool_name`` + a hash of ``reason`` so DISTINCT reasons on the same tool persist independently ("write to ~/.ssh" does not auto-approve a later "send email" rule). Returns the ``check_dangerous_command`` result shape. """ description = reason or f"Plugin requires approval for {tool_name}" if not rule_key: rule_key = f"{tool_name}:{hashlib.sha256(description.encode('utf-8')).hexdigest()[:12]}" subject = f"Tool '{tool_name}' requires approval ({description})" advice = "Find an alternative approach." return _run_approval_gate( # Namespaced so plugin-rule approvals share the allowlist machinery # without ever colliding with a real command pattern key. pattern_key=f"plugin_rule:{rule_key}", description=description, # Synthetic label for the display/allowlist layer; it never executes. display_target=f"<{tool_name}> (plugin approval rule)", approval_callback=approval_callback, cron_deny_message=_CRON_CTX.block_message(subject, noun="flagged actions", advice=advice), single_query_deny_message=_SINGLE_QUERY_CTX.block_message( subject, noun="flagged actions", advice=advice), 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.""" findings = tirith_result.get("findings") or [] parts = [] for f in findings: severity = f.get("severity", "") title = f.get("title", "") desc = f.get("description", "") if title and desc: parts.append(f"[{severity}] {title}: {desc}" if severity else f"{title}: {desc}") elif title: parts.append(f"[{severity}] {title}" if severity else title) if not parts: summary = tirith_result.get("summary") or "security issue detected" return f"Security scan: {summary}" return "Security scan — " + "; ".join(parts) def _tirith_scan(command: str) -> dict: """Tirith result for the interactive flow; an un-importable scanner allows (default) or, under fail-closed, synthesizes a HIGH warn finding that goes through the normal approval flow (#20733).""" try: from tools.tirith_security import check_command_security return check_command_security(command) except ImportError: if _tirith_fail_open(): return {"action": "allow", "findings": [], "summary": ""} return { "action": "warn", "findings": [ { "rule_id": "tirith-import-error", "severity": "HIGH", "title": "Tirith security module unavailable", "description": ( "The Tirith security scanner could not be imported. " "Because security.tirith_fail_open is false, this " "command cannot be silently allowed. Approve only if " "you have verified the command is safe." ), } ], "summary": "Tirith unavailable (fail-closed)", } def check_all_command_guards(command: str, env_type: str, approval_callback=None, has_host_access: bool = False) -> dict: """Run all pre-exec security checks and return a single approval decision. Tirith and dangerous-command findings are presented as ONE combined approval request, so a gateway force=True replay cannot bypass one check when only the other was shown to the user. ``has_host_access``: a Docker sandbox with bind-mounted host paths is no longer isolated and takes the normal flow instead of the container fast-path. """ if _should_skip_container_guards(env_type, has_host_access=has_host_access): return _approved() blocked = _floor_block(command, sudo_guard=True) if blocked is not None: return blocked # Gateway /yolo is session-scoped; CLI --yolo remains process-scoped. approval_mode = _get_approval_mode() if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled() or approval_mode == "off": return _approved() if _command_matches_permanent_allowlist(command): return _approved() approval_callback = _resolve_cli_approval_callback(approval_callback) is_cli = _is_interactive_cli() is_gateway = _is_gateway_approval_context() is_ask = env_var_enabled("HERMES_EXEC_ASK") # Single-query (-q) exports HERMES_INTERACTIVE=1 but nobody answers # prompts; HERMES_EXEC_ASK has no human either — ignore both so # single_query_mode actually takes effect. if _is_single_query_approval_context(): is_cli = is_gateway = is_ask = False # Outside CLI/gateway/ask flows we never block on approvals: each # unattended context applies its configured deny/approve mode, else allow. if not is_cli and not is_gateway and not is_ask: for 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] smart_denied_for_owner = False if approval_mode == "smart": result, smart_denied_for_owner = _smart_gate( _COMMAND_GATE, command, combined_desc, primary_key, all_keys, session_key, human_present=is_cli or is_gateway or is_ask, ) if result is not None: return result # "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_denied=smart_denied_for_owner, 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 (#30882). Same dict contract as ``check_all_command_guards``. Documented limitation: a purely local non-interactive non-gateway session (no TTY, not gateway, not cron-deny) returns approved — matching the terminal auto-approve contract. The hardline floor still blocks catastrophic ``terminal()`` commands the script issues. """ pattern_key = "execute_code" description = _EXECUTE_CODE_DESCRIPTION # Isolated backends already sandbox the child. vercel_sandbox has no # host-bind concept so it stays always-skipped. if env_type == "vercel_sandbox": return _approved() if _should_skip_container_guards(env_type, has_host_access=has_host_access): return _approved() approval_mode = _get_approval_mode() if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled() or approval_mode == "off": return _approved() is_gateway = _is_gateway_approval_context() is_ask = env_var_enabled("HERMES_EXEC_ASK") is_cli = _is_interactive_cli() approval_callback = _resolve_cli_approval_callback() # No user is present to approve arbitrary code in -q / cron / unattended # sessions: 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", ) 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. smart_denied_for_owner = False if approval_mode == "smart": result, smart_denied_for_owner = _smart_gate( _EXECUTE_CODE_GATE, command, description, pattern_key, [pattern_key], session_key, human_present=True, ) if result is not None: return result # 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_denied=smart_denied_for_owner, pending_body=f"**Code:**\n```python\n{redact_sensitive_text(code)}\n```", ) # Load permanent allowlist from config on module import load_permanent_allowlist()