fix(approval): deterministic approvals.single_query_mode for -q sessions
hermes chat -q sets HERMES_INTERACTIVE=1 (for interactive sudo prompts) but
runs one turn with no user waiting to answer approval prompts. Previously a
dangerous command triggered the interactive gate, waited the full 300s
timeout, 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).
Add approvals.single_query_mode (default deny, mirror of cron_mode):
deny — block dangerous commands and execute_code deterministically with
a clear 'no user present' message (no 300s wait)
approve — auto-approve dangerous commands/execute_code in -q mode
cli.py marks the session with HERMES_SINGLE_QUERY_SESSION; the shared gate
(_run_approval_gate, check_all_command_guards, check_execute_code_guard)
treats -q as a deterministic non-interactive context when that marker is set.
execute_code, the -q escape hatch, now honors single_query_mode instead of
auto-approving headlessly. Includes tirith parity in the combined guard and
docs. Fixes #86878.
This commit is contained in:
@@ -19889,6 +19889,14 @@ def main(
|
||||
# agent must wait the full MCP cold-start bound before its first
|
||||
# (and only) tool snapshot. See #51316.
|
||||
cli._single_query_mode = True
|
||||
# Mark single-query for the approval gate. cli.py sets
|
||||
# HERMES_INTERACTIVE earlier for interactive sudo prompts, but a -q
|
||||
# run has NO user waiting to answer approval prompts. The gate reads
|
||||
# this marker (via gateway.session_context.get_session_env, which falls
|
||||
# back to os.environ when the session-context layer isn't engaged) and
|
||||
# takes the deterministic approvals.single_query_mode path instead of
|
||||
# waiting the full timeout. See #86878.
|
||||
os.environ["HERMES_SINGLE_QUERY_SESSION"] = "1"
|
||||
if not cli._claim_active_session("cli", stderr=bool(quiet)):
|
||||
sys.exit(1)
|
||||
try:
|
||||
|
||||
@@ -2188,6 +2188,16 @@ DEFAULT_CONFIG = {
|
||||
# deny — block the command and let the agent find another way (default, safe)
|
||||
# approve — auto-approve all dangerous commands in cron jobs
|
||||
#
|
||||
# single_query_mode — what to do when a single-query (-q) session hits a
|
||||
# dangerous command. -q runs export HERMES_INTERACTIVE=1 (for interactive
|
||||
# sudo prompts) but have NO user waiting to answer approval prompts — an
|
||||
# unanswered prompt just waits the full timeout then fails closed, so the
|
||||
# agent is forced to work around the block (often via execute_code). This
|
||||
# setting makes that intent explicit:
|
||||
# deny — block the command and let the agent find another way (default,
|
||||
# safe; mirrors cron_mode deny)
|
||||
# approve — auto-approve all dangerous commands in single-query mode
|
||||
#
|
||||
# timeout — seconds to wait for the user's approve/deny before failing
|
||||
# closed (deny). Shared by the CLI prompt and gateway/messaging waits.
|
||||
# Messaging approvals arrive as a push notification the user may not see
|
||||
@@ -2197,6 +2207,7 @@ DEFAULT_CONFIG = {
|
||||
"mode": "smart",
|
||||
"timeout": 300,
|
||||
"cron_mode": "deny",
|
||||
"single_query_mode": "deny",
|
||||
# Operator-customizable policy text for smart approvals. When
|
||||
# non-empty, this is appended to the smart-approval guardian's
|
||||
# SYSTEM prompt (trusted channel) as additional rules — e.g.
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
"""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 gateway.session_context import clear_session_vars, reset_session_vars, set_session_vars
|
||||
from tools.approval import (
|
||||
_get_single_query_approval_mode,
|
||||
check_all_command_guards,
|
||||
check_dangerous_command,
|
||||
detect_dangerous_command,
|
||||
)
|
||||
|
||||
|
||||
@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_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": "deny"}}):
|
||||
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_allow_maps_to_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": "allow"}}):
|
||||
assert _get_single_query_approval_mode() == "approve"
|
||||
|
||||
def test_yes_maps_to_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": "yes"}}):
|
||||
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._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._get_single_query_approval_mode", return_value="deny"):
|
||||
result = check_dangerous_command("ls -la", "local")
|
||||
assert result["approved"]
|
||||
|
||||
def test_block_message_includes_description(self, monkeypatch):
|
||||
"""The block message should mention what pattern was matched."""
|
||||
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._get_single_query_approval_mode", return_value="deny"):
|
||||
result = check_dangerous_command("rm -rf /tmp/stuff", "local")
|
||||
assert not result["approved"]
|
||||
assert "dangerous" in result["message"].lower() or "delete" in result["message"].lower()
|
||||
|
||||
|
||||
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._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._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._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._get_single_query_approval_mode", return_value="approve"):
|
||||
result = check_all_command_guards("rm -rf /tmp/stuff", "local")
|
||||
assert result["approved"]
|
||||
|
||||
def test_tirith_content_threat_blocked_in_single_query_deny(self, monkeypatch):
|
||||
"""Content-level threats caught only by tirith (not the regex patterns)
|
||||
are blocked in single-query-deny mode — the same regression #22070 fixed
|
||||
for cron must not resurface for -q."""
|
||||
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
|
||||
fake_tirith = {
|
||||
"action": "block",
|
||||
"findings": [{"severity": "HIGH", "title": "Homograph URL",
|
||||
"description": "URL contains Cyrillic lookalike chars"}],
|
||||
"summary": "homograph url",
|
||||
}
|
||||
with (
|
||||
mock_patch("tools.approval._get_single_query_approval_mode", return_value="deny"),
|
||||
mock_patch("tools.approval.detect_dangerous_command",
|
||||
return_value=(False, None, None)),
|
||||
mock_patch("tools.tirith_security.check_command_security",
|
||||
return_value=fake_tirith),
|
||||
):
|
||||
result = check_all_command_guards("curl http://xn--e1afmkfd.example/x", "local")
|
||||
assert not result["approved"]
|
||||
assert "BLOCKED" in result["message"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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._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._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_module, "_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._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._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._get_single_query_approval_mode", return_value="approve"):
|
||||
result = check_all_command_guards("rm -rf /", "local")
|
||||
assert not result["approved"]
|
||||
@@ -265,6 +265,31 @@ def _is_cron_approval_context() -> bool:
|
||||
return env_var_enabled("HERMES_CRON_SESSION")
|
||||
|
||||
|
||||
def _is_single_query_approval_context() -> bool:
|
||||
"""True when the current approval decision is from a single-query (-q) session.
|
||||
|
||||
``hermes chat -q "..."`` runs one turn and exits with no user waiting to
|
||||
answer approval prompts, but it still exports ``HERMES_INTERACTIVE=1`` so
|
||||
interactive sudo password prompts can be driven from stdin. Without an
|
||||
explicit marker, ``_is_interactive_cli()`` would report True and the gate
|
||||
would wait the full approval timeout for a human who never comes — failing
|
||||
closed after 300s and forcing the agent to work around the block (often
|
||||
via ``execute_code``, which also auto-approves in non-gateway mode). An
|
||||
explicit ``single_query_mode`` config makes that path deterministic.
|
||||
|
||||
Prefer the session ContextVar so a gateway/API turn spawned concurrently
|
||||
cannot taint unrelated CLI work in the same process (single-query is a
|
||||
CLI-only construct; interactivity is decided in cli.py). Falls back to the
|
||||
legacy process env var for CLI/tests that don't engage the session context.
|
||||
"""
|
||||
try:
|
||||
from gateway.session_context import get_session_env
|
||||
|
||||
return is_truthy_value(get_session_env("HERMES_SINGLE_QUERY_SESSION", ""))
|
||||
except Exception:
|
||||
return env_var_enabled("HERMES_SINGLE_QUERY_SESSION")
|
||||
|
||||
|
||||
def _is_gateway_approval_context() -> bool:
|
||||
"""True when this call is inside a gateway/API session.
|
||||
|
||||
@@ -3151,6 +3176,19 @@ def _get_cron_approval_mode() -> str:
|
||||
return "deny"
|
||||
|
||||
|
||||
def _get_single_query_approval_mode() -> str:
|
||||
"""Read the single-query (-q) approval mode from config. Returns 'deny' or 'approve'."""
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
config = load_config_readonly()
|
||||
mode = str(cfg_get(config, "approvals", "single_query_mode", default="deny")).lower().strip()
|
||||
if mode in {"approve", "off", "allow", "yes"}:
|
||||
return "approve"
|
||||
return "deny"
|
||||
except Exception:
|
||||
return "deny"
|
||||
|
||||
|
||||
def _strip_shell_comments(command: str) -> str:
|
||||
"""Strip shell-style comments from a command before LLM assessment.
|
||||
|
||||
@@ -3312,6 +3350,7 @@ def _run_approval_gate(
|
||||
display_target: str,
|
||||
approval_callback=None,
|
||||
cron_deny_message: str,
|
||||
single_query_deny_message: str,
|
||||
autoapprove_log_prefix: str,
|
||||
fail_closed_when_no_human: bool = False,
|
||||
no_human_block_message: str = "",
|
||||
@@ -3341,6 +3380,8 @@ def _run_approval_gate(
|
||||
``tools.terminal_tool.set_approval_callback`` is used.
|
||||
cron_deny_message: Message returned when a cron job hits this gate
|
||||
under ``cron_mode: deny``.
|
||||
single_query_deny_message: Message returned when a single-query
|
||||
(-q) session hits this gate under ``single_query_mode: deny``.
|
||||
autoapprove_log_prefix: Log line prefix for the non-interactive
|
||||
auto-approve warning (identifies command vs plugin origin).
|
||||
fail_closed_when_no_human: When True, a non-interactive non-gateway
|
||||
@@ -3371,7 +3412,34 @@ def _run_approval_gate(
|
||||
is_cli = _is_interactive_cli()
|
||||
is_gateway = _is_gateway_approval_context()
|
||||
|
||||
# Single-query (-q) sessions export HERMES_INTERACTIVE=1 but have no user
|
||||
# to answer approval prompts — an unanswered prompt just waits the full
|
||||
# timeout then fails closed. Treat them as a deterministic non-interactive
|
||||
# context governed by approvals.single_query_mode (mirrors cron below).
|
||||
if _is_single_query_approval_context():
|
||||
is_cli = False
|
||||
is_gateway = False
|
||||
|
||||
if not is_cli and not is_gateway:
|
||||
# Single-query (-q) sessions: respect single_query_mode config
|
||||
if _is_single_query_approval_context():
|
||||
if _get_single_query_approval_mode() == "deny":
|
||||
return {
|
||||
"approved": False,
|
||||
"message": single_query_deny_message,
|
||||
"pattern_key": pattern_key,
|
||||
"description": description,
|
||||
}
|
||||
# single_query_mode: approve — auto-approve. Unlike cron, this must
|
||||
# return here rather than fall through: the plugin-escalation
|
||||
# fail_closed branch below would otherwise block the very action
|
||||
# single_query_mode: approve just authorized.
|
||||
logger.warning(
|
||||
"%s (pattern: %s): %s — single-query auto-approve "
|
||||
"(approvals.single_query_mode: approve).",
|
||||
autoapprove_log_prefix, pattern_key, description,
|
||||
)
|
||||
return {"approved": True, "message": None}
|
||||
# Cron sessions: respect cron_mode config
|
||||
if _is_cron_approval_context():
|
||||
if _get_cron_approval_mode() == "deny":
|
||||
@@ -3641,6 +3709,13 @@ def check_dangerous_command(command: str, env_type: str,
|
||||
"To allow dangerous commands in cron jobs, set "
|
||||
"approvals.cron_mode: approve in config.yaml."
|
||||
),
|
||||
single_query_deny_message=(
|
||||
f"BLOCKED: Command flagged as dangerous ({description}) but "
|
||||
"single-query mode (-q) runs without a user present to approve "
|
||||
"it. Find an alternative approach that avoids this command. "
|
||||
"To allow dangerous commands in single-query mode, set "
|
||||
"approvals.single_query_mode: approve in config.yaml."
|
||||
),
|
||||
autoapprove_log_prefix=(
|
||||
"AUTO-APPROVED dangerous command in non-interactive non-gateway context"
|
||||
),
|
||||
@@ -3721,6 +3796,13 @@ def request_tool_approval(
|
||||
"alternative approach. To allow flagged actions in cron jobs, set "
|
||||
"approvals.cron_mode: approve in config.yaml."
|
||||
),
|
||||
single_query_deny_message=(
|
||||
f"BLOCKED: Tool '{tool_name}' requires approval ({description}) "
|
||||
"but single-query mode (-q) runs without a user present to "
|
||||
"approve it. Find an alternative approach. To allow flagged "
|
||||
"actions in single-query mode, set "
|
||||
"approvals.single_query_mode: approve in config.yaml."
|
||||
),
|
||||
autoapprove_log_prefix=(
|
||||
f"plugin-escalated tool call '{tool_name}' in "
|
||||
"non-interactive non-gateway context"
|
||||
@@ -4117,9 +4199,88 @@ def check_all_command_guards(command: str, env_type: str,
|
||||
is_gateway = _is_gateway_approval_context()
|
||||
is_ask = env_var_enabled("HERMES_EXEC_ASK")
|
||||
|
||||
# Single-query (-q) sessions export HERMES_INTERACTIVE=1 but have no user
|
||||
# to answer approval prompts — an unanswered prompt just waits the full
|
||||
# timeout then fails closed. Treat them as a deterministic non-interactive
|
||||
# context governed by approvals.single_query_mode (mirrors cron below).
|
||||
if _is_single_query_approval_context():
|
||||
is_cli = False
|
||||
is_gateway = False
|
||||
# HERMES_EXEC_ASK routes through the gateway decision loop (no human
|
||||
# either here) — ignore it so single_query_mode actually takes effect.
|
||||
is_ask = False
|
||||
|
||||
# Preserve the existing non-interactive behavior: outside CLI/gateway/ask
|
||||
# flows, we do not block on approvals and we skip external guard work.
|
||||
if not is_cli and not is_gateway and not is_ask:
|
||||
# Single-query (-q) sessions: respect single_query_mode config
|
||||
if _is_single_query_approval_context():
|
||||
if _get_single_query_approval_mode() == "deny":
|
||||
is_dangerous, _pk, description = detect_dangerous_command(command)
|
||||
if is_dangerous:
|
||||
return {
|
||||
"approved": False,
|
||||
"message": (
|
||||
f"BLOCKED: Command flagged as dangerous ({description}) "
|
||||
"but single-query mode (-q) runs without a user "
|
||||
"present to approve it. Find an alternative approach "
|
||||
"that avoids this command. To allow dangerous "
|
||||
"commands in single-query mode, set "
|
||||
"approvals.single_query_mode: approve in config.yaml."
|
||||
),
|
||||
"pattern_key": _pk,
|
||||
"description": description,
|
||||
}
|
||||
# Also run tirith check in single-query-deny mode so content-level
|
||||
# threats (homograph URLs, pipe-to-interpreter, terminal
|
||||
# injection, etc.) are caught even when they do not match
|
||||
# the pattern-based detection above.
|
||||
try:
|
||||
from tools.tirith_security import check_command_security
|
||||
_sq_tirith = check_command_security(command)
|
||||
if _sq_tirith.get("action") in ("block", "warn"):
|
||||
_sq_desc = _format_tirith_description(_sq_tirith)
|
||||
return {
|
||||
"approved": False,
|
||||
"message": (
|
||||
f"BLOCKED: {_sq_desc} "
|
||||
"but single-query mode (-q) runs without a user "
|
||||
"present to approve it. Find an alternative "
|
||||
"approach that avoids this command. To allow "
|
||||
"dangerous commands in single-query mode, set "
|
||||
"approvals.single_query_mode: approve in config.yaml."
|
||||
),
|
||||
}
|
||||
except ImportError:
|
||||
# Tirith not installed. Honour security.tirith_fail_open:
|
||||
# the default (True) allows as before, but when an operator
|
||||
# has explicitly opted into fail-closed the command cannot
|
||||
# be silently allowed — and a single-query session has no
|
||||
# user to approve it, so fail-closed means block (mirrors
|
||||
# the cron branch below, see #20733).
|
||||
_sq_fail_open = True # safe default if config is unreadable
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly as _load_cfg
|
||||
_sec = (_load_cfg() or {}).get("security", {}) or {}
|
||||
if _sec.get("tirith_enabled", True):
|
||||
_sq_fail_open = _sec.get("tirith_fail_open", True)
|
||||
except Exception:
|
||||
pass
|
||||
if not _sq_fail_open:
|
||||
return {
|
||||
"approved": False,
|
||||
"message": (
|
||||
"BLOCKED: the Tirith security scanner could not be "
|
||||
"imported and security.tirith_fail_open is false, "
|
||||
"so this command cannot be silently allowed — and "
|
||||
"single-query mode (-q) runs without a user "
|
||||
"present to approve it. Find an alternative "
|
||||
"approach, install tirith, or set "
|
||||
"approvals.single_query_mode: approve in config.yaml."
|
||||
),
|
||||
}
|
||||
# else: tirith_fail_open is True — allow as before
|
||||
# single_query_mode: approve — fall through to auto-approve below.
|
||||
# Cron sessions: respect cron_mode config
|
||||
if _is_cron_approval_context():
|
||||
if _get_cron_approval_mode() == "deny":
|
||||
@@ -4663,6 +4824,27 @@ def check_execute_code_guard(code: str, env_type: str,
|
||||
is_gateway = _is_gateway_approval_context()
|
||||
is_ask = env_var_enabled("HERMES_EXEC_ASK")
|
||||
|
||||
# Single-query (-q): no user is present to approve arbitrary code. Mirrors
|
||||
# the cron branch below so the -q escape-hatch no longer auto-approves.
|
||||
if _is_single_query_approval_context():
|
||||
if _get_single_query_approval_mode() == "deny":
|
||||
return {
|
||||
"approved": False,
|
||||
"message": (
|
||||
"BLOCKED: execute_code runs arbitrary local Python "
|
||||
"(including subprocess calls that bypass shell-string "
|
||||
"approval checks). Single-query mode (-q) runs without a "
|
||||
"user present to approve it. Use normal tools instead, or "
|
||||
"set approvals.single_query_mode: approve only if this "
|
||||
"single-query run is intentionally trusted."
|
||||
),
|
||||
"pattern_key": pattern_key,
|
||||
"description": description,
|
||||
"outcome": "blocked",
|
||||
"user_consent": False,
|
||||
}
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
# Cron: no user is present to approve arbitrary code.
|
||||
if _is_cron_approval_context():
|
||||
if _get_cron_approval_mode() == "deny":
|
||||
|
||||
@@ -34,6 +34,7 @@ approvals:
|
||||
mode: smart # smart | manual | off
|
||||
timeout: 300 # seconds to wait for user response (default: 300)
|
||||
cron_mode: deny # deny | approve — what cron jobs do when they hit a dangerous command
|
||||
single_query_mode: deny # deny | approve — what single-query (-q) sessions do on a dangerous command
|
||||
mcp_reload_confirm: true # /reload-mcp asks before invalidating the MCP tool cache
|
||||
destructive_slash_confirm: true # /clear, /new, /reset, /undo prompt before discarding state
|
||||
```
|
||||
@@ -45,6 +46,7 @@ The full set of keys:
|
||||
| `mode` | `smart` | Approval policy for dangerous shell commands — see the table below. |
|
||||
| `timeout` | `300` | Seconds Hermes waits for an approval reply before timing out. |
|
||||
| `cron_mode` | `deny` | How [cron jobs](./features/cron.md) behave headlessly when they trigger a dangerous-command prompt. `deny` blocks the command (the agent must find another path); `approve` auto-approves everything in cron context. |
|
||||
| `single_query_mode` | `deny` | How one-shot [`hermes chat -q`](./cli.md) sessions behave when they trigger a dangerous-command prompt. A `-q` session runs a single turn and exits with no user waiting to answer prompts; `deny` blocks the command (the agent must find another path), `approve` auto-approves everything in single-query context. Mirrors `cron_mode`. |
|
||||
| `mcp_reload_confirm` | `true` | When true, `/reload-mcp` asks before rebuilding the MCP tool set. Rebuilding invalidates the provider prompt cache (tool schemas live in the system prompt), so the next message re-sends full input tokens. Users who click **Always Approve** flip this key to `false`. |
|
||||
| `destructive_slash_confirm` | `true` | When true, destructive session slash commands (`/clear`, `/new`, `/reset`, `/undo`) prompt before discarding conversation state. Three-option dialog (Approve Once / Always Approve / Cancel) routed through native yes/no buttons on Telegram, Discord, and Slack; text fallback elsewhere. Users who click **Always Approve** flip this key to `false`. The TUI also honors this setting for its `/clear`, `/new`, and `/reset` modal; `HERMES_TUI_NO_CONFIRM=1` force-skips that modal regardless of the configured value. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user