fix(approvals): route plugin asks through runs bridge

This commit is contained in:
yu-xin-c
2026-09-18 15:57:27 +08:00
committed by Teknium
parent 5daee8cbff
commit 151c06e5ec
2 changed files with 54 additions and 5 deletions

View File

@@ -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)."""

View File

@@ -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: