From 1596148ff2256ac1fafb5b19c5ce27d98b0fd22e Mon Sep 17 00:00:00 2001 From: Vivaan Dhawan <150339722+VIVAAN-DHAWAN@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:13:35 +0530 Subject: [PATCH] fix(approval): deterministic approvals.single_query_mode for -q sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli.py | 8 + hermes_cli/config_defaults.py | 11 + .../tools/test_single_query_approval_mode.py | 364 ++++++++++++++++++ tools/approval.py | 182 +++++++++ website/docs/user-guide/security.md | 2 + 5 files changed, 567 insertions(+) create mode 100644 tests/tools/test_single_query_approval_mode.py diff --git a/cli.py b/cli.py index 084cd5941c..0d121c1708 100644 --- a/cli.py +++ b/cli.py @@ -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: diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 2d3bee563d..d2847c6996 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -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. diff --git a/tests/tools/test_single_query_approval_mode.py b/tests/tools/test_single_query_approval_mode.py new file mode 100644 index 0000000000..5a9ae63db5 --- /dev/null +++ b/tests/tools/test_single_query_approval_mode.py @@ -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"] \ No newline at end of file diff --git a/tools/approval.py b/tools/approval.py index ac8b34e442..592b43f406 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -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": diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index d4bc6b3cd4..e63199af0b 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -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. |