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.
358 lines
19 KiB
Python
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
|