429 lines
16 KiB
Python
429 lines
16 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
|
|
import time
|
|
from tools.approval_human_wait import 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) -> str:
|
|
"""Prompt the user to approve a dangerous command (CLI only).
|
|
|
|
Args:
|
|
allow_permanent: When False, hide [a]lways (tirith warnings present:
|
|
broad permanent allowlisting is wrong for content-level findings).
|
|
allow_session: When False, hide [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.
|
|
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', or 'timeout'. 'timeout'
|
|
means no user response — still blocked (fail-closed), but callers
|
|
report "no response" rather than an explicit denial.
|
|
"""
|
|
from tools import approval as _a
|
|
if timeout_seconds is None:
|
|
timeout_seconds = _a._get_approval_timeout()
|
|
|
|
# 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 (#79719).
|
|
with human_wait_window():
|
|
return _prompt_dangerous_approval_inner(
|
|
command,
|
|
description,
|
|
timeout_seconds,
|
|
allow_permanent,
|
|
approval_callback,
|
|
allow_session=allow_session,
|
|
smart_denied=smart_denied,
|
|
)
|
|
|
|
|
|
_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 _prompt_dangerous_approval_inner(command: str, description: str,
|
|
timeout_seconds: int,
|
|
allow_permanent: bool = True,
|
|
approval_callback=None,
|
|
*, allow_session: bool = True,
|
|
smart_denied: bool = False) -> str:
|
|
# Redact before any user-visible rendering; the original `command` is
|
|
# still what 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:
|
|
callback_kwargs = {"allow_permanent": allow_permanent}
|
|
if not allow_session:
|
|
callback_kwargs["allow_session"] = False
|
|
if smart_denied:
|
|
callback_kwargs["smart_denied"] = True
|
|
return approval_callback(
|
|
display_command, display_description, **callback_kwargs
|
|
)
|
|
except Exception as e:
|
|
logger.error("Approval callback failed: %s", e, exc_info=True)
|
|
return "deny"
|
|
|
|
# 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 (#15216). Deny loudly instead; threads needing
|
|
# interactive approval must install a callback via
|
|
# tools.terminal_tool.set_approval_callback() first.
|
|
try:
|
|
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; denying "
|
|
"to avoid stdin deadlock. command=%r description=%r",
|
|
command, description,
|
|
)
|
|
return "deny"
|
|
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
|
|
if once_only:
|
|
prompt = t("approval.prompt_smart_deny")
|
|
else:
|
|
prompt = t("approval.prompt_long") if allow_permanent else t("approval.prompt_short")
|
|
while True:
|
|
print()
|
|
print(f" {t('approval.dangerous_header', description=display_description)}")
|
|
print(f" {display_command}")
|
|
print()
|
|
if once_only:
|
|
print(t("approval.choose_smart_deny"))
|
|
elif allow_permanent:
|
|
print(t("approval.choose_long"))
|
|
else:
|
|
print(t("approval.choose_short"))
|
|
print()
|
|
sys.stdout.flush()
|
|
|
|
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)
|
|
|
|
if thread.is_alive():
|
|
print("\n" + t("approval.timeout"))
|
|
return "timeout" # distinct from deny: the user never answered
|
|
|
|
choice = result["choice"]
|
|
if once_only:
|
|
choice_map = {
|
|
**{
|
|
value: "once"
|
|
for value in t("approval.smart_deny_once_inputs").split(",")
|
|
},
|
|
**{
|
|
value: "deny"
|
|
for value in t("approval.smart_deny_deny_inputs").split(",")
|
|
},
|
|
}
|
|
decision = choice_map.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 "deny"
|
|
finally:
|
|
if "HERMES_SPINNER_PAUSE" in os.environ:
|
|
del os.environ["HERMES_SPINNER_PAUSE"]
|
|
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 _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.
|
|
"""
|
|
from tools import approval as _a
|
|
name, fallback = _a._get_approval_transport_config()
|
|
if name == "builtin":
|
|
return {"selected": False}
|
|
|
|
try:
|
|
registered = _a.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 {
|
|
"selected": True,
|
|
"choice": "deny",
|
|
"failure": "unavailable",
|
|
"fallback": fallback,
|
|
"name": name,
|
|
}
|
|
|
|
try:
|
|
from agent.redact import redact_sensitive_text
|
|
from hermes_cli.approval_transport import ApprovalRequest, invoke_approval_transport
|
|
|
|
timeout_seconds = _a._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 to logs/hooks.
|
|
logger.warning("Could not build redacted plugin approval request")
|
|
return {
|
|
"selected": True,
|
|
"choice": "deny",
|
|
"failure": "error",
|
|
"fallback": None,
|
|
"name": name,
|
|
}
|
|
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,
|
|
)
|
|
_a._fire_approval_hook("pre_approval_request", **hook_kwargs)
|
|
try:
|
|
from tools.environments.base import touch_activity_if_due
|
|
except Exception: # pragma: no cover - minimal tool-only environments
|
|
touch_activity_if_due = None
|
|
now = time.monotonic()
|
|
activity_state = {"last_touch": now, "start": now}
|
|
|
|
def _poll() -> None:
|
|
if touch_activity_if_due is not None:
|
|
touch_activity_if_due(activity_state, "waiting for plugin approval transport")
|
|
|
|
with human_wait_window(session_key):
|
|
result = invoke_approval_transport(
|
|
registered.present,
|
|
request,
|
|
timeout_seconds=timeout_seconds,
|
|
on_poll=_poll,
|
|
is_interrupted=is_interrupted,
|
|
)
|
|
hook_choice = result.choice if result.failure is None else f"transport_{result.failure}"
|
|
_a._fire_approval_hook("post_approval_response", **hook_kwargs, choice=hook_choice)
|
|
return {
|
|
"selected": True,
|
|
"choice": result.choice,
|
|
"failure": result.failure,
|
|
"fallback": fallback,
|
|
"name": name,
|
|
}
|
|
|
|
|
|
def _transport_denied_result(
|
|
*, pattern_key: str, description: str, failure: str
|
|
) -> dict:
|
|
from tools import approval as _a
|
|
breaker_addendum = _a._denial_breaker_addendum(_a.get_current_session_key())
|
|
return _a._denied(
|
|
f"BLOCKED: Selected approval transport failed ({failure}); the user "
|
|
"has NOT consented to this action. Do NOT retry this command or "
|
|
"attempt the same outcome through another route."
|
|
f"{breaker_addendum}",
|
|
pattern_key=pattern_key, description=description,
|
|
outcome=f"transport_{failure}",
|
|
)
|
|
|
|
|
|
def _transport_choice(attempt: dict, *, pattern_key: str, description: str):
|
|
"""Interpret a ``_present_with_selected_transport`` attempt.
|
|
|
|
Returns ``(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 failure and attempt.get("fallback") == "builtin":
|
|
logger.warning(
|
|
"Approval transport %r failed (%s); using explicit builtin fallback",
|
|
attempt.get("name"),
|
|
failure,
|
|
)
|
|
return None, None
|
|
if failure:
|
|
return None, _transport_denied_result(
|
|
pattern_key=pattern_key, description=description, failure=failure,
|
|
)
|
|
return attempt.get("choice"), None
|
|
|
|
|
|
def request_elicitation_consent(
|
|
message: str,
|
|
description: str,
|
|
*,
|
|
timeout_seconds: int | None = None,
|
|
surface: str = "mcp-elicitation",
|
|
) -> str:
|
|
"""Route an MCP elicitation request to the surface owning the active session.
|
|
|
|
Gateway sessions go 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 = _a.get_current_session_key()
|
|
except Exception as exc: # pragma: no cover -- defensive
|
|
logger.warning("Elicitation consent: session lookup failed: %s", exc)
|
|
return "decline"
|
|
|
|
if _a._is_gateway_approval_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"
|
|
|
|
approval_data = {
|
|
"command": message,
|
|
"description": description,
|
|
"pattern_key": "mcp_elicitation",
|
|
"pattern_keys": ["mcp_elicitation"],
|
|
}
|
|
try:
|
|
decision = _a._await_gateway_decision(
|
|
session_key, notify_cb, approval_data, 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"):
|
|
return "cancel"
|
|
choice = decision.get("choice")
|
|
if choice in ("once", "session", "always"):
|
|
return "accept"
|
|
return "decline"
|
|
|
|
# allow_permanent=False: elicitation is a per-call confirmation — there
|
|
# is no pattern to remember.
|
|
try:
|
|
choice = _a.prompt_dangerous_approval(
|
|
message,
|
|
description,
|
|
timeout_seconds=timeout_seconds,
|
|
allow_permanent=False,
|
|
)
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Elicitation CLI prompt failed: %s", exc, exc_info=True,
|
|
)
|
|
return "decline"
|
|
|
|
if choice in ("once", "session", "always"):
|
|
return "accept"
|
|
if choice == "timeout":
|
|
return "cancel" # mirror the gateway's unresolved outcome
|
|
return "decline"
|