fix(approvals): route plugin asks through runs bridge
This commit is contained in:
@@ -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)."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user