_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.
66 lines
2.7 KiB
Python
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()
|