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.
308 lines
16 KiB
Python
308 lines
16 KiB
Python
"""Tests for approvals.single_query_mode — configurable approval behavior for
|
|
single-query (-q) sessions.
|
|
|
|
Background (#86878): ``hermes chat -q "..."`` runs one turn and exits. cli.py
|
|
exports ``HERMES_INTERACTIVE=1`` (needed for interactive sudo password
|
|
prompts), which previously made ``_is_interactive_cli()`` report True in the
|
|
approval gate. A -q run has NO user waiting to answer approval prompts, so a
|
|
dangerous command just waited the full timeout (300s) then failed closed — and
|
|
the agent was effectively forced to work around the block (often silently
|
|
auto-approving via ``execute_code``, which auto-approves in non-gateway mode).
|
|
``approvals.single_query_mode`` (default ``deny``, mirror of cron_mode) makes
|
|
that decision deterministic and explicit.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
import tools.approval as approval_module
|
|
from tools import approval_context
|
|
from gateway.session_context import clear_session_vars, reset_session_vars, set_session_vars
|
|
from tools.approval import check_all_command_guards, check_dangerous_command
|
|
from tools.approval_context import _get_single_query_approval_mode
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_approval_state():
|
|
approval_module._permanent_approved.clear()
|
|
approval_module.clear_session("default")
|
|
approval_module.clear_session("test-session")
|
|
reset_session_vars()
|
|
yield
|
|
approval_module._permanent_approved.clear()
|
|
approval_module.clear_session("default")
|
|
approval_module.clear_session("test-session")
|
|
reset_session_vars()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _get_single_query_approval_mode() config parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSingleQueryApprovalModeParsing:
|
|
def test_default_is_deny(self):
|
|
"""When no config is set, single_query_mode defaults to 'deny'."""
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config_readonly", return_value={"approvals": {}}):
|
|
assert _get_single_query_approval_mode() == "deny"
|
|
|
|
|
|
def test_explicit_approve(self):
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config_readonly", return_value={"approvals": {"single_query_mode": "approve"}}):
|
|
assert _get_single_query_approval_mode() == "approve"
|
|
|
|
def test_off_maps_to_approve(self):
|
|
"""'off' is an alias for 'approve' (matches --yolo semantics)."""
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config_readonly", return_value={"approvals": {"single_query_mode": "off"}}):
|
|
assert _get_single_query_approval_mode() == "approve"
|
|
|
|
|
|
def test_case_insensitive(self):
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config_readonly", return_value={"approvals": {"single_query_mode": "APPROVE"}}):
|
|
assert _get_single_query_approval_mode() == "approve"
|
|
|
|
def test_unknown_value_defaults_to_deny(self):
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config_readonly", return_value={"approvals": {"single_query_mode": "maybe"}}):
|
|
assert _get_single_query_approval_mode() == "deny"
|
|
|
|
def test_config_load_failure_defaults_to_deny(self):
|
|
"""If config loading fails entirely, default to deny (safe)."""
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config_readonly", side_effect=RuntimeError("config broken")):
|
|
assert _get_single_query_approval_mode() == "deny"
|
|
|
|
def test_yaml_boolean_false_maps_to_deny(self):
|
|
"""YAML 1.1 parses bare 'off' as False. Ensure it maps to deny."""
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config_readonly", return_value={"approvals": {"single_query_mode": False}}):
|
|
# str(False) = "False", which is not in the approve set, so deny
|
|
assert _get_single_query_approval_mode() == "deny"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Single-query context detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSingleQueryContextDetection:
|
|
def test_env_var_marks_single_query(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
assert approval_module._is_single_query_approval_context() is True
|
|
|
|
def test_env_var_unset_is_not_single_query(self, monkeypatch):
|
|
monkeypatch.delenv("HERMES_SINGLE_QUERY_SESSION", raising=False)
|
|
assert approval_module._is_single_query_approval_context() is False
|
|
|
|
def test_env_var_false_is_not_single_query(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "0")
|
|
assert approval_module._is_single_query_approval_context() is False
|
|
|
|
def test_blank_session_context_masks_leaked_env(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
tokens = set_session_vars(cron_session="")
|
|
try:
|
|
# Session context engaged: get_session_env returns "" because the
|
|
# single-query var lives outside _VAR_MAP — but the legacy env
|
|
# fallback route in _is_single_query_approval_context still sees it.
|
|
assert approval_module._is_single_query_approval_context() is True
|
|
finally:
|
|
clear_session_vars(tokens)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_dangerous_command() with a single-query session
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSingleQueryDenyMode:
|
|
"""When HERMES_SINGLE_QUERY_SESSION is set and single_query_mode=deny,
|
|
dangerous commands are blocked deterministically instead of waiting a full
|
|
approval timeout for a user who is not there."""
|
|
|
|
def test_dangerous_command_blocked_in_single_query_deny_mode(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval_context._get_single_query_approval_mode", return_value="deny"):
|
|
result = check_dangerous_command("rm -rf /tmp/stuff", "local")
|
|
assert not result["approved"]
|
|
assert "BLOCKED" in result["message"]
|
|
assert "single_query_mode" in result["message"]
|
|
|
|
def test_safe_command_allowed_in_single_query_deny_mode(self, monkeypatch):
|
|
"""Non-dangerous commands still work even with single_query_mode=deny."""
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval_context._get_single_query_approval_mode", return_value="deny"):
|
|
result = check_dangerous_command("ls -la", "local")
|
|
assert result["approved"]
|
|
|
|
|
|
class TestSingleQueryApproveMode:
|
|
"""When HERMES_SINGLE_QUERY_SESSION is set and single_query_mode=approve,
|
|
dangerous commands pass through — no prompt, no timeout wait."""
|
|
|
|
def test_dangerous_command_allowed_in_single_query_approve_mode(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval_context._get_single_query_approval_mode", return_value="approve"):
|
|
result = check_dangerous_command("rm -rf /tmp/stuff", "local")
|
|
assert result["approved"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_all_command_guards() with a single-query session
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSingleQueryDenyModeAllGuards:
|
|
"""The combined guard function also respects single_query_mode."""
|
|
|
|
def test_dangerous_command_blocked_in_combined_guard(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval_context._get_single_query_approval_mode", return_value="deny"):
|
|
result = check_all_command_guards("rm -rf /tmp/stuff", "local")
|
|
assert not result["approved"]
|
|
assert "BLOCKED" in result["message"]
|
|
assert "single_query_mode" in result["message"]
|
|
|
|
def test_safe_command_allowed_in_combined_guard(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval_context._get_single_query_approval_mode", return_value="deny"):
|
|
result = check_all_command_guards("echo hello", "local")
|
|
assert result["approved"]
|
|
|
|
def test_combined_guard_approve_mode(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval_context._get_single_query_approval_mode", return_value="approve"):
|
|
result = check_all_command_guards("rm -rf /tmp/stuff", "local")
|
|
assert result["approved"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_execute_code_guard(): the -q escape hatch is closed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSingleQueryExecuteCode:
|
|
"""execute_code auto-approves in plain non-gateway mode. A -q run must not
|
|
silently auto-approve arbitrary code — it goes through single_query_mode."""
|
|
|
|
def test_execute_code_blocked_in_single_query_deny(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval_context._get_single_query_approval_mode", return_value="deny"):
|
|
result = approval_module.check_execute_code_guard("import os", "local")
|
|
assert not result["approved"]
|
|
assert result["outcome"] == "blocked"
|
|
assert "single_query_mode" in result["message"]
|
|
|
|
def test_execute_code_allowed_in_single_query_approve(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval_context._get_single_query_approval_mode", return_value="approve"):
|
|
result = approval_module.check_execute_code_guard("import os", "local")
|
|
assert result["approved"]
|
|
|
|
def test_headless_execute_code_still_auto_approves_outside_single_query(self, monkeypatch):
|
|
"""Without the single-query marker, headless execute_code keeps its
|
|
documented auto-approve contract (no behavior change outside -q)."""
|
|
monkeypatch.delenv("HERMES_SINGLE_QUERY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
|
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
|
monkeypatch.setattr(approval_context, "_get_approval_mode", lambda: "manual")
|
|
|
|
result = approval_module.check_execute_code_guard("import os", "local")
|
|
assert result["approved"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Edge cases: single-query mode interaction with other mechanisms
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSingleQueryModeInteractions:
|
|
"""Single-query mode should NOT interfere with other approval mechanisms."""
|
|
|
|
def test_container_env_still_auto_approves(self, monkeypatch):
|
|
"""Docker/sandbox environments bypass approvals regardless of mode."""
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval_context._get_single_query_approval_mode", return_value="deny"):
|
|
result = check_dangerous_command("rm -rf /", "docker")
|
|
assert result["approved"]
|
|
|
|
def test_yolo_overrides_single_query_deny(self, monkeypatch):
|
|
"""--yolo still bypasses single_query_mode=deny for dangerous (non-hardline)
|
|
commands."""
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
monkeypatch.setenv("HERMES_YOLO_MODE", "1")
|
|
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
|
|
# _YOLO_MODE_FROZEN is frozen at module import time (security: prevents
|
|
# prompt injection from runtime-setting HERMES_YOLO_MODE). Patch the
|
|
# module attribute directly to simulate process-startup with
|
|
# HERMES_YOLO_MODE=1.
|
|
from unittest.mock import patch as mock_patch
|
|
with (
|
|
mock_patch.object(approval_module, "_YOLO_MODE_FROZEN", True),
|
|
mock_patch("tools.approval_context._get_single_query_approval_mode", return_value="deny"),
|
|
):
|
|
result = check_dangerous_command("rm -rf /tmp/stuff", "local")
|
|
assert result["approved"]
|
|
|
|
def test_hardline_block_still_fires(self, monkeypatch):
|
|
"""Hardline commands are blocked even under single_query_mode=approve."""
|
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
|
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval_context._get_single_query_approval_mode", return_value="approve"):
|
|
result = check_all_command_guards("rm -rf /", "local")
|
|
assert not result["approved"]
|