Files
hermes-agent/tests/tools/test_protected_instruction_cli_channel.py
kshitijk4poor 33c9b1d779 fix(terminal): hermes chat -q never waits on a sudo prompt nobody answers
_chat_run_agent registers the CLI sudo callback in -q too and the process
exports HERMES_INTERACTIVE=1, so a sudo command without SUDO_PASSWORD /
NOPASSWD filled a panel nothing draws and waited its 45 s deadline per call
(every kanban worker). Single-query is now headless for sudo like a
delegated child: _no_sudo_user() gates the prompt (_transform_sudo_command),
the SUDO_PASSWORD tip on a headless sudo failure (_handle_sudo_failure, now
worded for any session that cannot prompt), and the "you will be prompted
again" hint (terminal_tool_result.can_reprompt). A configured SUDO_PASSWORD
and the session cache still apply; an interactive `hermes chat -q` on a TTY
never sets HERMES_SINGLE_QUERY_SESSION, so it keeps its prompt. A /dev/tty
fallthrough was rejected: it prints the prompt box onto -Q / stream-json
stdout, and on Windows msvcrt blocks 45 s on a worker's hidden console.

The protected-instruction tests now pin the cron and webhook contexts the
shared helper added, and the single_query_mode case registers a callback
that would approve, so it cannot pass via the no-callback early return.
Cases duplicating test_file_write_safety.py are dropped, and the guard
comment states the rule instead of the incident.
2026-10-05 20:08:11 +05:30

66 lines
2.7 KiB
Python

"""Protected-instruction approval fails closed at once, without asking anyone, where nobody can answer.
``hermes chat -q`` (how kanban spawns every worker), cron and unattended platforms can have the CLI approval
callback registered on the agent thread. The gate used to ask it, block for the full approvals timeout, then
report "timed out" — which reads as a human ignoring a prompt nobody saw. The interactive approve/deny paths
are covered in test_file_write_safety.py; the real CLI modal in tests/hermes_cli/test_cli_approval_ui.py.
"""
import json
import pytest
@pytest.fixture(autouse=True)
def _gate_on(monkeypatch):
import tools.file_tools_write_guards as guards
from tools.terminal_tool import set_approval_callback
monkeypatch.setattr(guards, "_protected_instruction_config", lambda: (True, []))
for name in ("HERMES_SINGLE_QUERY_SESSION", "HERMES_CRON_SESSION", "HERMES_SESSION_PLATFORM"):
monkeypatch.delenv(name, raising=False)
set_approval_callback(None)
yield
set_approval_callback(None)
@pytest.mark.parametrize("env", [
{"HERMES_SINGLE_QUERY_SESSION": "1"},
{"HERMES_CRON_SESSION": "1"},
{"HERMES_SESSION_PLATFORM": "webhook"},
], ids=["single_query", "cron", "webhook"])
def test_registered_callback_is_never_asked_without_a_user(tmp_path, monkeypatch, env):
from tools.terminal_tool import set_approval_callback
from tools.file_tools import write_file_tool
for name, value in env.items():
monkeypatch.setenv(name, value)
asked = []
set_approval_callback(lambda command, description, **kw: asked.append(command) or "once")
target = tmp_path / "SOUL.md"
res = json.loads(write_file_tool(str(target), "content"))
assert asked == []
assert "no interactive user or gateway is present" in res["error"]
assert "timed out" not in res["error"]
assert not target.exists()
@pytest.mark.parametrize("mode", ["approve", "deny"])
def test_single_query_mode_never_auto_approves_this_gate(tmp_path, monkeypatch, mode):
"""``approvals.single_query_mode`` governs the dangerous-command gate only: a protected-instruction write
always needs a live human."""
from tools import approval_context
from tools.file_tools import write_file_tool
from tools.terminal_tool import set_approval_callback
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
monkeypatch.setattr(approval_context, "_get_single_query_approval_mode", lambda: mode)
asked = []
set_approval_callback(lambda command, description, **kw: asked.append(command) or "once")
target = tmp_path / "SOUL.md"
res = json.loads(write_file_tool(str(target), "content"))
assert asked == []
assert "BLOCKED" in res["error"]
assert not target.exists()