diff --git a/tests/tools/test_request_tool_approval.py b/tests/tools/test_request_tool_approval.py index 29fd459bcb..d1a390a45a 100644 --- a/tests/tools/test_request_tool_approval.py +++ b/tests/tools/test_request_tool_approval.py @@ -168,6 +168,54 @@ class TestRequestToolApproval: assert res["approved"] is False assert "no interactive user or gateway" in res["message"].lower() + def test_api_server_exec_ask_uses_approval_bridge(self, monkeypatch): + """Plugin escalation reaches the /v1/runs approval bridge when ask mode is active.""" + monkeypatch.setattr(approval, "_is_interactive_cli", lambda: False) + monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False) + monkeypatch.setattr(tools_approval_context, "_is_gateway_approval_context", lambda: False) + monkeypatch.setattr(approval, "_is_cron_approval_context", lambda: False) + monkeypatch.setattr(tools_approval_context, "_is_cron_approval_context", lambda: False) + monkeypatch.setattr(approval, "_is_single_query_approval_context", lambda: False) + monkeypatch.setattr(approval_context, "_get_approval_mode", lambda: "manual") + monkeypatch.setenv("HERMES_EXEC_ASK", "1") + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "api_server") + + notified = [] + + def approve(data): + notified.append(data) + assert approval.resolve_gateway_approval( + "test-session", "once", request_id=data["request_id"] + ) == 1 + + approval.register_gateway_notify("test-session", approve) + try: + res = request_tool_approval("home_lock", "unlock the front door", rule_key="unlock") + finally: + approval.unregister_gateway_notify("test-session") + + assert res["approved"] is True + assert len(notified) == 1 + assert notified[0]["pattern_key"] == "plugin_rule:unlock" + + def test_api_server_without_exec_ask_remains_fail_closed(self, monkeypatch): + """An api_server call without an active approval bridge must not run ungated.""" + monkeypatch.setattr(approval, "_is_interactive_cli", lambda: False) + monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False) + monkeypatch.setattr(tools_approval_context, "_is_gateway_approval_context", lambda: False) + monkeypatch.setattr(approval, "_is_cron_approval_context", lambda: False) + monkeypatch.setattr(tools_approval_context, "_is_cron_approval_context", lambda: False) + monkeypatch.setattr(approval, "_is_single_query_approval_context", lambda: False) + monkeypatch.setattr(approval_context, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(approval_context, "_get_unattended_approval_mode", lambda: "deny") + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "api_server") + + res = request_tool_approval("home_lock", "unlock the front door", rule_key="unlock") + + assert res["approved"] is False + assert "unattended platform" in res["message"].lower() + def test_yolo_session_bypasses_gate(self, monkeypatch): """A --yolo session skips the plugin approval gate (parity with the dangerous-command path, via the shared _run_approval_gate).""" diff --git a/tools/approval.py b/tools/approval.py index 984470a7bd..627c7c92c2 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -961,7 +961,8 @@ def _run_approval_gate( Order: yolo bypass → session-cache short-circuit → interactive/gateway/unattended branch → prompt → persistence. Input-shape checks (hardline, allowlist, pattern detection) are the caller's job. ``fail_closed_when_no_human``: a non-interactive, non-gateway, non-cron - context BLOCKS instead of auto-approving, so a plugin-flagged action never runs ungated. + context without an ask bridge BLOCKS instead of auto-approving, so a plugin-flagged action + never runs ungated. Unattended deny text is ``ctx.block_message(subject, noun, advice)`` unless the caller passes an explicit ``*_deny_message`` (the file-tool write gates word their own). """ @@ -976,7 +977,7 @@ def _run_approval_gate( return _approved() approval_callback, is_cli, is_gateway, is_ask = _presence(approval_callback) - if not is_cli and not is_gateway: + if not is_cli and not is_gateway and not is_ask: log_args = (autoapprove_log_prefix, pattern_key, description) # Every unattended context resolves instantly — never a pending approval nobody can answer. deny_messages = { @@ -1098,9 +1099,9 @@ def request_tool_approval(tool_name: str, reason: str, *, rule_key: str = "", ap it asks the SAME human gate as Tier-2 dangerous shell patterns (session/permanent allowlist, CLI prompt, gateway pending, once/session/always/deny, timeout fail-closed), so the LLM cannot skip it. Cron honors ``approvals.cron_mode``; any OTHER non-interactive - non-gateway context fails CLOSED. ``rule_key`` controls the ``[a]lways`` allowlist grain; - when empty it is ``tool_name`` + a hash of ``reason`` so DISTINCT reasons on the same tool - persist independently. Returns the ``check_dangerous_command`` result shape. + context without an approval bridge fails CLOSED. ``rule_key`` controls the ``[a]lways`` + allowlist grain; when empty it is ``tool_name`` + a hash of ``reason`` so DISTINCT reasons + on the same tool persist independently. Returns the ``check_dangerous_command`` result shape. """ description = reason or f"Plugin requires approval for {tool_name}" if not rule_key: