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:
Vivaan Dhawan
2026-08-15 16:13:35 +05:30
committed by Teknium
parent 26b2b47593
commit 1596148ff2
5 changed files with 567 additions and 0 deletions
+8
View File
@@ -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:
+11
View File
@@ -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"]
+182
View File
@@ -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":
+2
View File
@@ -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. |