Files
hermes-agent/tools/approval_prompt.py
Teknium 911da42e9e refactor(security): remove the bundled tirith scanner from core
tirith ran `tirith check` on every terminal command, default-on with fail-open, as a
pinned PM binary auto-downloaded at startup. Measured against the shipped 0.4.2 binary
it caught 14/16 attack commands but flagged 12/24 everyday agent commands
(`echo $((6*7))`, `"$VAR/x.sh"`, `gh api | python3`, LAN curls, `.dev` hosts,
`pip install` via analysis_incomplete), and in cron/-q deny mode each of those is a
hard block. It only ever saw terminal strings (execute_code, writes and the browser
bypass it), fail-open meant no protection wherever the binary could not install
(Windows, Termux, x64 musl), and it produced a steady issue/PR stream (fail_open
semantics four times, install loops filling disks, a SIGSEGV hang, session-approval
scoping, third-party promo text on approval cards).

Removed: tools/tirith_security.py, the tirith branches of the command guard (the
is_tirith tuple, session-only "always" downgrade, import-error/fail-open paths,
unattended tirith pass), the CLI and gateway startup installers, the PM package and
lock pins, the nix runtime dep, security.tirith_* defaults, the CLI warning string
and three tirith tips (gaps refilled from the tail so translations survive), plus
the gateway's "no automated assessor" warning that only existed to point at tirith.

Config v50 drops security.tirith_* and enables nothing in its place. Hermes ships
no tirith plugin; if tirith upstream publishes one, it goes through normal catalog
admission under their name.
2026-10-06 14:22:13 -07:00

358 lines
19 KiB
Python

"""Human prompt surfaces for :mod:`tools.approval`.
The interactive CLI prompt (callback panel or ``input()`` fallback) and the
operator-selected plugin approval transport. Detection, allowed scopes,
persistence, timeout policy and the final authorization stay host-owned in
``tools.approval``; this module only asks and reports the answer.
"""
import logging
import os
import sys
import threading
from tools import approval_context as _ctx, approval_gateway_wait as _gw
from tools.approval_human_wait import activity_heartbeat, human_wait_window
from tools.interrupt import is_interrupted
logger = logging.getLogger("tools.approval")
def prompt_dangerous_approval(command: str, description: str, timeout_seconds: int | None = None,
allow_permanent: bool = True, approval_callback=None,
*, allow_session: bool = True, smart_denied: bool = False,
title: str | None = None) -> str:
"""Prompt the user to approve a dangerous command (CLI only).
allow_permanent=False hides [a]lways. allow_session=False hides
[s]ession too — the caller grants one operation and re-asks next time (the
protected agent-instruction gate in ``tools/file_tools.py``); offering a scope
the caller discards makes every later write re-prompt and reads as broken.
smart_denied: owner override of a Smart DENY, offer only once/deny.
title: header for the plain-input prompt when the question is not a dangerous command
("Save to memory?", "<server> is asking"); the default header stays the dangerous-command one.
approval_callback: CLI prompt_toolkit callback ``(command, description, *,
allow_permanent=True, allow_session=True, smart_denied=False) -> str``; legacy
signatures keep working while both keywords hold their defaults.
Returns 'once', 'session', 'always', 'deny', 'timeout', or 'cancelled'. 'timeout'
means no user response — still blocked (fail-closed), but callers report "no
response" rather than an explicit denial. 'cancelled' is an :class:`Unanswered`
sentinel: the prompt never reached a human (callback raised, no callback under
prompt_toolkit, interrupted read) and ``.cause`` says why (#22992).
See #81887.
"""
if timeout_seconds is None:
timeout_seconds = _ctx._get_approval_timeout()
from tools.human_input_hooks import human_input_request
# Everything below is a human prompt (callback panel or input() fallback, both bounded by the approval deadline):
# record it as human-wait time so the concurrent batch deadline excludes it.
# See #79719.
with human_input_request("approval", prompt=command) as human, human_wait_window():
human.outcome = choice = _ask_human(command, description, timeout_seconds, allow_permanent,
approval_callback, allow_session, smart_denied, title=title)
return choice
class Unanswered(str):
"""Choice ``"cancelled"`` carrying the reason nobody answered. Compares equal to the gateway's
withdrawn-prompt choice so every consumer already handling ``cancelled`` fails closed without
attributing a denial to the user."""
__slots__ = ("cause",)
def __new__(cls, cause: str):
self = super().__new__(cls, "cancelled")
self.cause = cause
return self
_CLI_CHOICE_ALIASES = {
"o": "once", "once": "once",
"s": "session", "session": "session",
"a": "always", "always": "always",
}
_CLI_CHOICE_I18N = {
"once": "approval.allowed_once",
"session": "approval.allowed_session",
"always": "approval.allowed_always",
"deny": "approval.denied",
}
def _read_choice(prompt: str, timeout_seconds: int) -> str | None:
"""Read one answer on a daemon thread; None when the user never answered."""
result = {"choice": ""}
def get_input():
try:
result["choice"] = input(prompt).strip().lower()
except (EOFError, OSError):
result["choice"] = ""
thread = threading.Thread(target=get_input, daemon=True)
thread.start()
thread.join(timeout=timeout_seconds)
return None if thread.is_alive() else result["choice"]
def callback_accepts(callback, keyword: str) -> bool:
"""True when *callback* takes ``keyword`` (or ``**kwargs``); approval callbacks predate ``title``
and a TypeError inside the callback would read as a deny."""
import inspect
try:
params = inspect.signature(callback).parameters
except (TypeError, ValueError):
return False
return keyword in params or any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
def _ask_human(command: str, description: str, timeout_seconds: int, allow_permanent: bool,
approval_callback, allow_session: bool, smart_denied: bool, title: str | None = None) -> str:
# Redact before any user-visible rendering; the original `command` still executes after approval. Same redactor as
# memory/log sanitization so tokens mask consistently across surfaces.
from agent.redact import redact_sensitive_text
display_command = redact_sensitive_text(command)
display_description = redact_sensitive_text(description)
# Smart DENY and a session-less gate both reduce the menu to once/deny.
once_only = smart_denied or not allow_session
if approval_callback is not None:
try:
# Non-default scopes only: legacy callbacks lack the newer keywords.
callback_kwargs = {"allow_permanent": allow_permanent,
**({"allow_session": False} if not allow_session else {}),
**({"smart_denied": True} if smart_denied else {}),
**({"title": title} if title and callback_accepts(approval_callback, "title") else {})}
return approval_callback(display_command, display_description, **callback_kwargs)
except Exception as e:
logger.error("Approval callback failed: %s", e, exc_info=True)
return Unanswered(f"the approval callback failed: {type(e).__name__}")
# Fail-closed guard: when prompt_toolkit owns the terminal and no callback is registered on this thread, the
# input() fallback would spawn a daemon thread whose read never sees Enter (keystrokes go to prompt_toolkit) — an
# invisible deadlock. Fail closed loudly instead; threads needing interactive approval must install a callback via
# tools.terminal_tool.set_approval_callback() first.
try:
# Fail fast and log loudly so the caller can surface a real error to the agent. Any thread
# that needs interactive approval must install a callback via
# tools.terminal_tool.set_approval_callback() before reaching this point (see delegate_tool.py,
# run_agent.py _execute_tool_calls_concurrent / _spawn_background_review for the established
# pattern). See #15216.
from prompt_toolkit.application.current import get_app_or_none
if get_app_or_none() is not None:
logger.warning("Dangerous-command approval requested on a thread with no "
"approval callback while prompt_toolkit is active; failing closed "
"to avoid stdin deadlock. command=%r description=%r", command, description)
return Unanswered("no approval callback is registered on this thread while prompt_toolkit owns "
"the terminal, so the prompt could not be shown")
except Exception:
pass # prompt_toolkit absent or detection failed: legacy input() path is safe
os.environ["HERMES_SPINNER_PAUSE"] = "1"
try:
from agent.i18n import t
# (prompt key, menu key) by menu shape: once/deny, full, or no [a]lways.
shape = "smart_deny" if once_only else "long" if allow_permanent else "short"
prompt_key, menu_key = f"approval.prompt_{shape}", f"approval.choose_{shape}"
header = title or t('approval.dangerous_header', description=display_description)
print(f"\n {header}"
f"\n {display_command}\n\n{t(menu_key)}\n")
sys.stdout.flush()
choice = _read_choice(t(prompt_key), timeout_seconds)
if choice is None:
print("\n" + t("approval.timeout", **_ctx.approval_timeout_notice_kwargs()))
return "timeout" # distinct from deny: the user never answered
if once_only:
decision = {**dict.fromkeys(t("approval.smart_deny_once_inputs").split(","), "once"),
**dict.fromkeys(t("approval.smart_deny_deny_inputs").split(","), "deny"),
}.get(choice, "deny")
else:
decision = _CLI_CHOICE_ALIASES.get(choice, "deny")
if decision == "always" and not allow_permanent:
decision = "session"
print(t(_CLI_CHOICE_I18N[decision]))
return decision
except (EOFError, KeyboardInterrupt):
print("\n" + t("approval.cancelled"))
return Unanswered("the prompt was interrupted before an answer was given")
finally:
os.environ.pop("HERMES_SPINNER_PAUSE", None)
print()
sys.stdout.flush()
def get_plugin_manager():
"""Lazy plugin-manager seam used by tests and early tool-only imports."""
from hermes_cli.plugins import discover_plugins, get_plugin_manager as _get_manager
# Approval can be imported before model_tools (which triggers discovery); make an explicitly selected transport
# available on the first approval instead of treating the undiscovered registry as unavailable.
discover_plugins()
return _get_manager()
def _attempt(name: str, choice, failure, fallback) -> dict:
"""Result shape of :func:`_present_with_selected_transport` once a transport is selected."""
return {"selected": True, "choice": choice, "failure": failure, "fallback": fallback, "name": name}
def _present_with_selected_transport(*, command: str, description: str, pattern_key: str,
pattern_keys: list[str], session_key: str, surface: str,
allow_session: bool, allow_permanent: bool) -> dict:
"""Present through an explicitly selected plugin transport, if any. A selected
transport replaces every built-in prompt surface; detection, allowed scopes,
persistence, timeout, and final authorization stay host-owned. A failed
transport reaches a built-in surface only under the explicit
``transport_fallback: builtin`` opt-in."""
name, fallback = _ctx._get_approval_transport_config()
if name == "builtin":
return {"selected": False}
try:
registered = get_plugin_manager().get_approval_transport(name)
except Exception:
# Plugin/discovery exception text may contain plugin-owned secrets.
logger.warning("Could not resolve selected approval transport %r", name)
registered = None
if registered is None:
logger.warning("Selected approval transport %r is unavailable", name)
return _attempt(name, "deny", "unavailable", fallback)
try:
from agent.redact import redact_sensitive_text
from hermes_cli.approval_transport import ApprovalRequest, invoke_approval_transport
timeout_seconds = _ctx._get_approval_timeout()
request = ApprovalRequest.create(
command=redact_sensitive_text(command, force=True),
description=redact_sensitive_text(description, force=True), pattern_key=pattern_key,
pattern_keys=tuple(pattern_keys), session_key=session_key, surface=surface, allow_session=allow_session,
allow_permanent=allow_permanent, timeout_seconds=timeout_seconds,
)
except Exception:
# Never fall back to raw text if redaction or request construction fails:
# fail closed without calling the plugin or leaking the unredacted payload.
logger.warning("Could not build redacted plugin approval request")
return _attempt(name, "deny", "error", None)
hook_kwargs = dict(
command=request.command, description=request.description, pattern_key=pattern_key,
pattern_keys=list(pattern_keys), session_key=session_key, surface=f"transport:{name}",
request_id=request.request_id, request_digest=request.digest,
)
_ctx._fire_approval_hook("pre_approval_request", **hook_kwargs)
from tools.human_input_hooks import human_input_request
with human_input_request("approval", prompt=request.command, session_key=session_key) as human, \
human_wait_window(session_key):
result = invoke_approval_transport(
registered.present, request, timeout_seconds=timeout_seconds,
on_poll=activity_heartbeat("waiting for plugin approval transport"),
is_interrupted=is_interrupted,
)
human.outcome = result.choice if result.failure is None else f"transport_{result.failure}"
_ctx._fire_approval_hook("post_approval_response", **hook_kwargs, choice=human.outcome)
return _attempt(name, result.choice, result.failure, fallback)
def _transport_choice(attempt: dict, *, pattern_key: str, description: str):
"""Interpret a ``_present_with_selected_transport`` attempt into
``(choice, denied_result)``: both None when the built-in surfaces should run
(no transport selected, or a failure with the explicit builtin fallback); a
denied result for any other failure; else the user's choice."""
if not attempt.get("selected"):
return None, None
failure = attempt.get("failure")
if not failure:
return attempt.get("choice"), None
if attempt.get("fallback") == "builtin":
logger.warning("Approval transport %r failed (%s); using explicit builtin fallback",
attempt.get("name"), failure)
return None, None
from tools import approval as _a
breaker_addendum = _a._denial_breaker_addendum(_ctx.get_current_session_key())
return None, _a._denied(
f"BLOCKED: Selected approval transport failed ({failure}); the user "
"has NOT consented to this action. Do NOT retry this command or "
f"attempt the same outcome through another route.{breaker_addendum}",
pattern_key=pattern_key, description=description, outcome=f"transport_{failure}",
)
def _consent(choice, unresolved: str) -> str:
"""Map an approval choice to an elicitation verdict; *unresolved* is the no-answer outcome."""
if choice in ("once", "session", "always"):
return "accept"
return unresolved if choice in ("timeout", "cancelled") else "decline"
def request_elicitation_consent(message: str, description: str, *,
timeout_seconds: int | None = None,
surface: str = "mcp-elicitation", title: str = "Confirm this action?") -> str:
"""Route an MCP elicitation request to the surface owning the active session:
gateway sessions through ``_await_gateway_decision``, CLI/TUI through
``prompt_dangerous_approval``. Always fails closed: a missing notify_cb in a
gateway session, timeouts, and exceptions map to ``"decline"`` so a server
treats them as "user did not approve" rather than retrying or hanging.
Returns ``"accept" | "decline" | "cancel"``."""
from tools import approval as _a
try:
session_key = _ctx.get_current_session_key()
except Exception as exc: # pragma: no cover -- defensive
logger.warning("Elicitation consent: session lookup failed: %s", exc)
return "decline"
# api_server is an unattended *platform* for the dangerous-command gate, but a live ``/v1/runs`` run
# registers an approval notify callback and answers via ``POST /v1/runs/{id}/approval``
# (gateway/platforms/api_server_runs.py), so its per-call MCP consent takes the gateway path too.
# A run without a callback still fails closed below; cron and ``-q`` workers stay excluded.
api_run_context = (
_ctx._get_session_platform() == "api_server"
and not _ctx._is_cron_approval_context()
and not _ctx._is_single_query_approval_context()
)
if _ctx._is_gateway_approval_context() or api_run_context:
notify_cb = _a._gateway_notify_cb(session_key)
if notify_cb is None:
logger.warning("Elicitation requested in gateway session %s but no "
"notify_cb is registered — failing closed", session_key)
return "decline"
try:
decision = _gw._await_gateway_decision(
session_key, notify_cb, {"command": message, "description": description,
"pattern_key": "mcp_elicitation",
"pattern_keys": ["mcp_elicitation"]}, surface=surface)
except Exception as exc:
logger.error("Elicitation gateway dispatch failed: %s", exc, exc_info=True)
return "decline"
if decision.get("notify_failed"):
return "decline"
if not decision.get("resolved") or decision.get("cancelled"):
return "cancel" # nobody answered (timeout / prompt withdrawn) — not a user refusal
return _consent(decision.get("choice"), "decline")
# Nobody can answer a -q, cron or unattended-platform worker. A `hermes chat -q` turn still has the CLI's
# panel callback registered, which would wait the full approval timeout before failing closed.
if _ctx._no_user_can_answer():
logger.info("Elicitation consent (%s) declined: no user can answer in this session", surface)
return "decline"
# Same observer payload as the gateway branch (#131876); post fires in a finally so the
# wait settles for observers on the early fail-closed return too.
hook_kwargs = dict(command=message, description=description, pattern_key="mcp_elicitation",
pattern_keys=["mcp_elicitation"], session_key=session_key, surface=surface)
_ctx._fire_approval_hook("pre_approval_request", **hook_kwargs)
hook_choice = "cancelled"
try:
from tools.terminal_tool import _get_approval_callback
# allow_permanent=False: elicitation is a per-call confirmation — no pattern to remember.
# Pass the agent thread's panel callback: without it prompt_toolkit fails the prompt closed unseen.
choice = prompt_dangerous_approval(message, description, timeout_seconds=timeout_seconds,
allow_permanent=False, title=title,
approval_callback=_get_approval_callback())
hook_choice = choice
except Exception as exc:
logger.error("Elicitation CLI prompt failed: %s", exc, exc_info=True)
return "decline"
finally:
_ctx._fire_approval_hook("post_approval_response", **hook_kwargs, choice=hook_choice)
return _consent(choice, "cancel") # timeout mirrors the gateway's unresolved outcome