Files
hermes-agent/tests/tools/test_human_input_hooks.py
Teknium c958e1a7b9 feat(plugins): on_human_input_request / on_human_input_resolved hooks
One observer pair fires wherever the agent blocks waiting on a person, so a
notifier plugin no longer needs a hook per prompt type or a `sudo` regex over
terminal commands (#132333, consumer: pushover-hermes-plugin).

Fire sites are the existing choke points every surface already routes through:
- sudo: tools/terminal_tool_sudo.py::_prompt_for_sudo_password (CLI callback,
  Ink TUI/Desktop sudo.request card, /dev/tty fallback)
- clarify: tools/clarify_tool.py::clarify_tool (CLI, TUI/Desktop, gateway)
- approval: tools/approval_prompt.py::prompt_dangerous_approval (CLI/TUI/ACP
  callback and input() paths), ::_present_with_selected_transport (plugin
  transports), tools/approval_gateway_wait.py::_await_gateway_decision
  (gateway platforms)

Payload: kind, request_id (shared by the pair), session_id, session_key,
platform, prompt; resolved adds outcome. prompt is always force-redacted and
the typed password / clarify answers are never passed. Smart (aux-LLM)
approvals and coalesced gateway followers do not fire it: no new human prompt.

Closes #132333
2026-10-05 01:15:16 -07:00

171 lines
7.2 KiB
Python

"""on_human_input_request / on_human_input_resolved fire wherever the agent blocks for a human (#132333).
Each surface's real choke point is driven end to end — sudo password prompt, clarify, dangerous-command
approval on the CLI callback path and the gateway poll path — with only the plugin hook delivery captured.
Every request pairs with exactly one resolution carrying the same ``request_id``, and no typed secret (the
sudo password, a clarify answer) or unredacted credential ever appears in any payload.
"""
import threading
from unittest.mock import patch
import pytest
from tools import approval as approval_module
from tools import approval_context, approval_gateway_wait, terminal_tool, terminal_tool_sudo
from tools.approval_prompt import prompt_dangerous_approval
from tools.clarify_tool import clarify_tool
PASSWORD = "hunter2-do-not-leak"
TOKEN = "ghp_" + "Z" * 36
SECRET_COMMAND = f"sudo env GITHUB_TOKEN={TOKEN} apt-get install -y jq"
HOOKS = ("on_human_input_request", "on_human_input_resolved")
@pytest.fixture
def captured(monkeypatch):
calls: list[tuple[str, dict]] = []
def fake_invoke_hook(name, **kwargs):
if name in HOOKS:
calls.append((name, kwargs))
return []
monkeypatch.setattr(terminal_tool, "_callback_tls", threading.local())
monkeypatch.setattr(terminal_tool_sudo, "_sudo_password_cache", {})
with patch("hermes_cli.plugins.invoke_hook", side_effect=fake_invoke_hook):
yield calls
def _single_pair(calls, kind):
assert [name for name, _ in calls] == list(HOOKS), calls
request, resolved = calls[0][1], calls[1][1]
assert request["kind"] == resolved["kind"] == kind
assert request["request_id"] and request["request_id"] == resolved["request_id"]
assert "outcome" not in request
for key in ("session_id", "session_key", "platform", "prompt"):
assert key in request and request[key] == resolved[key]
return request, resolved
def _assert_no_secret(calls, *secrets):
blob = repr(calls)
for secret in secrets:
assert secret not in blob
def test_hooks_are_registered():
from hermes_cli.plugins import VALID_HOOKS
assert set(HOOKS) <= VALID_HOOKS
class TestSudo:
def test_callback_prompt_fires_pair_without_password(self, captured):
terminal_tool.set_sudo_password_callback(lambda: PASSWORD)
assert terminal_tool_sudo._prompt_for_sudo_password(command=SECRET_COMMAND) == PASSWORD
request, resolved = _single_pair(captured, "sudo")
assert resolved["outcome"] == "provided"
assert "apt-get install" in request["prompt"]
_assert_no_secret(captured, PASSWORD, TOKEN)
def test_empty_answer_is_skipped(self, captured):
terminal_tool.set_sudo_password_callback(lambda: "")
assert terminal_tool_sudo._prompt_for_sudo_password(command="sudo true") == ""
assert _single_pair(captured, "sudo")[1]["outcome"] == "skipped"
def test_tty_fallback_timeout_resolves_as_timeout(self, captured, monkeypatch):
monkeypatch.setattr(terminal_tool_sudo, "_read_hidden_password", lambda result: None)
assert terminal_tool_sudo._prompt_for_sudo_password(timeout_seconds=0, command="sudo true") == ""
assert _single_pair(captured, "sudo")[1]["outcome"] == "timeout"
class TestClarify:
QUESTIONS = [{"question": "Which database?", "choices": ["postgres", "sqlite"]}]
@pytest.mark.parametrize("outcome", ["submitted", "timed_out", "cancelled"])
def test_callback_outcome_is_reported_without_answers(self, captured, outcome):
def callback(normalized):
return {"answers": {normalized[0]["qid"]: PASSWORD}, "outcome": outcome}
clarify_tool(self.QUESTIONS, callback=callback)
request, resolved = _single_pair(captured, "clarify")
assert resolved["outcome"] == outcome
assert "Which database?" in request["prompt"]
_assert_no_secret(captured, PASSWORD)
def test_callback_failure_resolves_as_error(self, captured):
def callback(normalized):
raise RuntimeError("ui gone")
clarify_tool(self.QUESTIONS, callback=callback)
assert _single_pair(captured, "clarify")[1]["outcome"] == "error"
class TestApproval:
def test_cli_callback_prompt_redacts_command(self, captured):
choice = prompt_dangerous_approval(SECRET_COMMAND, "privileged install",
approval_callback=lambda *a, **k: "once")
assert choice == "once"
request, resolved = _single_pair(captured, "approval")
assert resolved["outcome"] == "once"
assert "apt-get install" in request["prompt"]
_assert_no_secret(captured, TOKEN)
def test_gateway_decision_fires_pair(self, captured, monkeypatch):
session_key = "human-input-gateway"
monkeypatch.setattr(approval_context, "_get_approval_timeout", lambda: 5)
approval_module._gateway_queues.pop(session_key, None)
def notify(data):
threading.Timer(0.05, approval_module.resolve_gateway_approval, (session_key, "session")).start()
approval_data = {"command": SECRET_COMMAND, "description": "privileged install",
"pattern_key": "sudo", "pattern_keys": ["sudo"]}
decision = approval_gateway_wait._await_gateway_decision(session_key, notify, approval_data)
assert decision["choice"] == "session"
request, resolved = _single_pair(captured, "approval")
assert request["session_key"] == session_key
assert resolved["outcome"] == "session"
_assert_no_secret(captured, TOKEN)
def test_gateway_notify_failure_still_resolves(self, captured):
def notify(data):
raise ConnectionError("adapter offline")
approval_data = {"command": "rm -rf build", "description": "d", "pattern_key": "rm", "pattern_keys": ["rm"]}
decision = approval_gateway_wait._await_gateway_decision("human-input-notify-fail", notify, approval_data)
assert decision.get("notify_failed") is True
assert _single_pair(captured, "approval")[1]["outcome"] == "notify_failed"
def test_plugin_transport_fires_pair(self, captured, monkeypatch):
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
from tools import approval_prompt
manager = PluginManager()
manifest = PluginManifest(name="phone-fixture", version="1.0.0", description="", source="user", key="phone")
PluginContext(manifest, manager).register_approval_transport("phone", lambda req: req.respond("deny"))
monkeypatch.setattr(approval_prompt, "get_plugin_manager", lambda: manager)
monkeypatch.setattr(approval_context, "_get_approval_transport_config", lambda: ("phone", None))
attempt = approval_prompt._present_with_selected_transport(
command=SECRET_COMMAND, description="d", pattern_key="sudo", pattern_keys=["sudo"],
session_key="human-input-transport", surface="cli", allow_session=True, allow_permanent=True)
assert attempt["choice"] == "deny"
request, resolved = _single_pair(captured, "approval")
assert request["session_key"] == "human-input-transport"
assert resolved["outcome"] == "deny"
_assert_no_secret(captured, TOKEN)