ba030bc0db
Tests did monkeypatch.setattr(<facade module>, name) where name is now defined in a sibling module and the production path reads the sibling's binding. Where production reads through BOTH bindings the setattr is duplicated onto the defining module (import added next to the existing alias import); where only the sibling reads it the target is repointed. Seams whose production readers go through the facade are left alone.
573 lines
19 KiB
Python
573 lines
19 KiB
Python
"""Approval transport plugin contract and fail-closed host routing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
from tools import approval_context, approval_prompt
|
|
|
|
|
|
def _manifest(name: str = "fixture-approval") -> PluginManifest:
|
|
return PluginManifest(
|
|
name=name,
|
|
version="1.0.0",
|
|
description="fixture",
|
|
source="user",
|
|
key=name,
|
|
)
|
|
|
|
|
|
def _context(manager: PluginManager, name: str = "fixture-approval") -> PluginContext:
|
|
return PluginContext(_manifest(name), manager)
|
|
|
|
|
|
def _request():
|
|
from hermes_cli.approval_transport import ApprovalRequest
|
|
|
|
return ApprovalRequest.create(
|
|
command="rm -rf /tmp/example",
|
|
description="recursive delete",
|
|
pattern_key="rm_recursive",
|
|
pattern_keys=("rm_recursive",),
|
|
session_key="session-a",
|
|
surface="cli",
|
|
allow_session=True,
|
|
allow_permanent=True,
|
|
)
|
|
|
|
|
|
def test_context_registers_owned_approval_transport():
|
|
manager = PluginManager()
|
|
callback = lambda request: request.respond("deny")
|
|
|
|
_context(manager).register_approval_transport("phone", callback)
|
|
|
|
registered = manager.get_approval_transport("phone")
|
|
assert registered is not None
|
|
assert registered.name == "phone"
|
|
assert registered.plugin_id == "fixture-approval"
|
|
assert registered.present is callback
|
|
|
|
|
|
def test_transport_names_are_unique_and_builtin_is_reserved():
|
|
manager = PluginManager()
|
|
_context(manager, "first").register_approval_transport("phone", lambda request: None)
|
|
|
|
with pytest.raises(ValueError, match="already registered"):
|
|
_context(manager, "second").register_approval_transport("phone", lambda request: None)
|
|
with pytest.raises(ValueError, match="reserved"):
|
|
_context(manager).register_approval_transport("builtin", lambda request: None)
|
|
|
|
|
|
def test_force_reload_clears_transport_registry(monkeypatch):
|
|
manager = PluginManager()
|
|
_context(manager).register_approval_transport("phone", lambda request: None)
|
|
manager._discovered = True
|
|
monkeypatch.setattr(manager, "_discover_and_load_inner", lambda: None)
|
|
|
|
manager.discover_and_load(force=True)
|
|
|
|
assert manager.get_approval_transport("phone") is None
|
|
|
|
|
|
def test_transport_registry_is_manager_and_profile_isolated(monkeypatch, tmp_path):
|
|
first = PluginManager()
|
|
second = PluginManager()
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profiles" / "work"))
|
|
_context(first).register_approval_transport("phone", lambda request: None)
|
|
|
|
assert first.get_approval_transport("phone") is not None
|
|
assert second.get_approval_transport("phone") is None
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profiles" / "personal"))
|
|
assert first.get_approval_transport("phone") is None
|
|
|
|
|
|
def test_host_accepts_bound_sync_and_async_decisions():
|
|
from hermes_cli.approval_transport import invoke_approval_transport
|
|
|
|
request = _request()
|
|
sync_result = invoke_approval_transport(
|
|
lambda received: received.respond("session"), request, timeout_seconds=1
|
|
)
|
|
|
|
async def present(received):
|
|
await asyncio.sleep(0)
|
|
return received.respond("once")
|
|
|
|
async_result = invoke_approval_transport(present, request, timeout_seconds=1)
|
|
|
|
assert sync_result.choice == "session"
|
|
assert sync_result.failure is None
|
|
assert async_result.choice == "once"
|
|
assert async_result.failure is None
|
|
|
|
|
|
def test_host_rejects_scope_not_offered_by_request():
|
|
from hermes_cli.approval_transport import ApprovalRequest, invoke_approval_transport
|
|
|
|
request = ApprovalRequest.create(
|
|
command="dangerous",
|
|
description="dangerous",
|
|
pattern_key="danger",
|
|
pattern_keys=("danger",),
|
|
session_key="session-a",
|
|
surface="cli",
|
|
allow_session=False,
|
|
allow_permanent=False,
|
|
)
|
|
|
|
result = invoke_approval_transport(
|
|
lambda received: received.respond("always"), request, timeout_seconds=1
|
|
)
|
|
|
|
assert result.choice == "deny"
|
|
assert result.failure == "invalid"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("present", "failure"),
|
|
[
|
|
(lambda request: {"choice": "once"}, "invalid"),
|
|
(lambda request: request.respond("bogus"), "invalid"),
|
|
(
|
|
lambda request: type(request.respond("once"))(
|
|
request_id="stale",
|
|
request_digest=request.digest,
|
|
choice="once",
|
|
),
|
|
"stale",
|
|
),
|
|
(
|
|
lambda request: type(request.respond("once"))(
|
|
request_id=request.request_id,
|
|
request_digest="changed",
|
|
choice="once",
|
|
),
|
|
"stale",
|
|
),
|
|
],
|
|
)
|
|
def test_host_rejects_invalid_or_stale_decisions(present, failure):
|
|
from hermes_cli.approval_transport import invoke_approval_transport
|
|
|
|
result = invoke_approval_transport(present, _request(), timeout_seconds=1)
|
|
|
|
assert result.choice == "deny"
|
|
assert result.failure == failure
|
|
|
|
|
|
def test_host_timeout_and_exception_deny_without_waiting_forever():
|
|
from hermes_cli.approval_transport import invoke_approval_transport
|
|
|
|
def hangs(_request):
|
|
time.sleep(1)
|
|
|
|
def crashes(_request):
|
|
raise RuntimeError("transport offline")
|
|
|
|
started = time.monotonic()
|
|
timeout = invoke_approval_transport(hangs, _request(), timeout_seconds=0.02)
|
|
exception = invoke_approval_transport(crashes, _request(), timeout_seconds=1)
|
|
|
|
assert time.monotonic() - started < 0.5
|
|
assert (timeout.choice, timeout.failure) == ("deny", "timeout")
|
|
assert (exception.choice, exception.failure) == ("deny", "error")
|
|
|
|
|
|
def test_host_transport_wait_is_interruptible_and_pollable():
|
|
from hermes_cli.approval_transport import invoke_approval_transport
|
|
|
|
polls = []
|
|
|
|
def hangs(_request):
|
|
time.sleep(1)
|
|
|
|
result = invoke_approval_transport(
|
|
hangs,
|
|
_request(),
|
|
timeout_seconds=1,
|
|
poll_interval=0.01,
|
|
on_poll=lambda: polls.append(1),
|
|
is_interrupted=lambda: len(polls) >= 1,
|
|
)
|
|
|
|
assert result.choice == "deny"
|
|
assert result.failure == "interrupted"
|
|
assert polls
|
|
|
|
|
|
def test_host_rejects_decision_completed_after_deadline(monkeypatch):
|
|
import hermes_cli.approval_transport as transport_module
|
|
|
|
main_thread = threading.get_ident()
|
|
|
|
def clock():
|
|
# The host starts at t=0 with a one-second budget. The worker reports
|
|
# completion at t=2 before the host dequeues the result. Acceptance is
|
|
# bound to completion time, not to a queue/scheduler race.
|
|
return 0.0 if threading.get_ident() == main_thread else 2.0
|
|
|
|
monkeypatch.setattr(transport_module.time, "monotonic", clock)
|
|
|
|
result = transport_module.invoke_approval_transport(
|
|
lambda request: request.respond("once"),
|
|
_request(),
|
|
timeout_seconds=1,
|
|
)
|
|
|
|
assert result.choice == "deny"
|
|
assert result.failure == "timeout"
|
|
|
|
|
|
def test_host_caps_hung_transport_workers():
|
|
from hermes_cli.approval_transport import (
|
|
_MAX_ACTIVE_TRANSPORT_WORKERS,
|
|
invoke_approval_transport,
|
|
)
|
|
|
|
release = threading.Event()
|
|
|
|
def hangs(_request):
|
|
release.wait()
|
|
|
|
try:
|
|
results = [
|
|
invoke_approval_transport(hangs, _request(), timeout_seconds=0.001)
|
|
for _ in range(_MAX_ACTIVE_TRANSPORT_WORKERS + 1)
|
|
]
|
|
finally:
|
|
release.set()
|
|
|
|
assert results[-1].choice == "deny"
|
|
assert results[-1].failure == "busy"
|
|
|
|
|
|
def _configure_manual_guard(monkeypatch, approval_module, manager, *, fallback=None):
|
|
monkeypatch.setattr(approval_context, "_get_approval_mode", lambda: "manual")
|
|
monkeypatch.setattr(approval_module, "_is_interactive_cli", lambda: True)
|
|
monkeypatch.setattr(approval_module, "_is_gateway_approval_context", lambda: False)
|
|
monkeypatch.setattr(approval_module, "detect_hardline_command", lambda command: (False, ""))
|
|
monkeypatch.setattr(approval_module, "_check_sudo_stdin_guard", lambda command: (False, ""))
|
|
monkeypatch.setattr(approval_module, "_match_user_deny_rule", lambda command: None)
|
|
monkeypatch.setattr(approval_module, "_command_matches_permanent_allowlist", lambda command: False)
|
|
monkeypatch.setattr(approval_module, "detect_dangerous_command", lambda command: (True, "danger", "dangerous"))
|
|
monkeypatch.setattr(
|
|
approval_module,
|
|
"get_current_session_key",
|
|
lambda *args, **kwargs: "session-a",
|
|
)
|
|
monkeypatch.setattr(approval_module, "is_approved", lambda *args: False)
|
|
monkeypatch.setattr(approval_prompt, "get_plugin_manager", lambda: manager)
|
|
monkeypatch.setattr(approval_context, "_get_approval_transport_config", lambda: ("phone", fallback))
|
|
monkeypatch.setattr("tools.tirith_security.check_command_security", lambda command: {"action": "allow"})
|
|
|
|
|
|
def test_cli_selected_transport_replaces_builtin_prompt(monkeypatch):
|
|
from tools import approval
|
|
|
|
manager = PluginManager()
|
|
seen = []
|
|
_context(manager).register_approval_transport(
|
|
"phone", lambda request: seen.append(request) or request.respond("once")
|
|
)
|
|
_configure_manual_guard(monkeypatch, approval, manager)
|
|
|
|
def builtin(*args, **kwargs):
|
|
raise AssertionError("builtin prompt must not materialize")
|
|
|
|
result = approval.check_all_command_guards(
|
|
"rm -rf /tmp/example", "local", approval_callback=builtin
|
|
)
|
|
|
|
assert result["approved"] is True
|
|
assert result["user_approved"] is True
|
|
assert len(seen) == 1
|
|
assert seen[0].command == "rm -rf /tmp/example"
|
|
assert seen[0].allowed_choices == ("once", "session", "always", "deny")
|
|
|
|
|
|
def test_gateway_selected_transport_does_not_require_gateway_notifier(monkeypatch):
|
|
from tools import approval
|
|
import tools.approval_context as tools_approval_context
|
|
|
|
manager = PluginManager()
|
|
seen = []
|
|
_context(manager).register_approval_transport(
|
|
"phone", lambda request: seen.append(request) or request.respond("once")
|
|
)
|
|
_configure_manual_guard(monkeypatch, approval, manager)
|
|
monkeypatch.setattr(approval, "_is_interactive_cli", lambda: False)
|
|
monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: True)
|
|
monkeypatch.setattr(tools_approval_context, "_is_gateway_approval_context", lambda: True)
|
|
monkeypatch.setattr(approval, "_gateway_notify_cbs", {})
|
|
|
|
result = approval.check_all_command_guards("rm -rf /tmp/example", "local")
|
|
|
|
assert result["approved"] is True
|
|
assert len(seen) == 1
|
|
assert seen[0].surface == "gateway"
|
|
|
|
|
|
def test_execute_code_gateway_uses_selected_transport(monkeypatch):
|
|
from tools import approval
|
|
import tools.approval_context as tools_approval_context
|
|
|
|
manager = PluginManager()
|
|
seen = []
|
|
_context(manager).register_approval_transport(
|
|
"phone", lambda request: seen.append(request) or request.respond("once")
|
|
)
|
|
monkeypatch.setattr(approval_context, "_get_approval_mode", lambda: "manual")
|
|
monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: True)
|
|
monkeypatch.setattr(tools_approval_context, "_is_gateway_approval_context", lambda: True)
|
|
monkeypatch.setattr(approval, "_is_cron_approval_context", lambda: False)
|
|
monkeypatch.setattr(tools_approval_context, "_is_cron_approval_context", lambda: False)
|
|
monkeypatch.setattr(
|
|
approval, "get_current_session_key", lambda *args, **kwargs: "session-a"
|
|
)
|
|
monkeypatch.setattr(
|
|
tools_approval_context, "get_current_session_key", lambda *args, **kwargs: "session-a"
|
|
)
|
|
monkeypatch.setattr(approval, "is_approved", lambda *args: False)
|
|
monkeypatch.setattr(approval_prompt, "get_plugin_manager", lambda: manager)
|
|
monkeypatch.setattr(
|
|
approval_context, "_get_approval_transport_config", lambda: ("phone", None)
|
|
)
|
|
monkeypatch.setattr(approval, "_gateway_notify_cbs", {})
|
|
|
|
result = approval.check_execute_code_guard("print('ok')", "local")
|
|
|
|
assert result["approved"] is True
|
|
assert len(seen) == 1
|
|
assert seen[0].pattern_key == "execute_code"
|
|
assert seen[0].surface == "gateway"
|
|
|
|
|
|
def test_transport_failure_denies_without_builtin_fallback(monkeypatch):
|
|
from tools import approval
|
|
|
|
manager = PluginManager()
|
|
_context(manager).register_approval_transport("phone", lambda request: {"choice": "once"})
|
|
_configure_manual_guard(monkeypatch, approval, manager)
|
|
builtin_calls = []
|
|
|
|
result = approval.check_all_command_guards(
|
|
"rm -rf /tmp/example",
|
|
"local",
|
|
approval_callback=lambda *args, **kwargs: builtin_calls.append(1) or "once",
|
|
)
|
|
|
|
assert result["approved"] is False
|
|
assert result["outcome"] == "transport_invalid"
|
|
assert builtin_calls == []
|
|
|
|
|
|
def test_transport_resolution_error_does_not_log_plugin_exception(monkeypatch, caplog):
|
|
from tools import approval
|
|
|
|
monkeypatch.setattr(
|
|
approval_context, "_get_approval_transport_config", lambda: ("phone", None)
|
|
)
|
|
|
|
def broken_manager():
|
|
raise RuntimeError("plugin-owned-secret-value")
|
|
|
|
monkeypatch.setattr(approval_prompt, "get_plugin_manager", broken_manager)
|
|
|
|
result = approval._present_with_selected_transport(
|
|
command="rm -rf /tmp/example",
|
|
description="dangerous",
|
|
pattern_key="rm_recursive",
|
|
pattern_keys=["rm_recursive"],
|
|
session_key="session-a",
|
|
surface="cli",
|
|
allow_session=True,
|
|
allow_permanent=True,
|
|
)
|
|
|
|
assert result["choice"] == "deny"
|
|
assert result["failure"] == "unavailable"
|
|
assert "plugin-owned-secret-value" not in caplog.text
|
|
|
|
|
|
def test_redaction_failure_denies_before_transport_callback(monkeypatch):
|
|
import agent.redact
|
|
from tools import approval
|
|
|
|
manager = PluginManager()
|
|
calls = []
|
|
_context(manager).register_approval_transport(
|
|
"phone", lambda request: calls.append(request) or request.respond("once")
|
|
)
|
|
_configure_manual_guard(monkeypatch, approval, manager)
|
|
|
|
def redaction_failed(text, *, force=False):
|
|
assert force is True
|
|
if text in {"rm -rf /tmp/example", "dangerous"}:
|
|
raise RuntimeError("redactor unavailable")
|
|
return text
|
|
|
|
monkeypatch.setattr(agent.redact, "redact_sensitive_text", redaction_failed)
|
|
|
|
result = approval.check_all_command_guards("rm -rf /tmp/example", "local")
|
|
|
|
assert result["approved"] is False
|
|
assert result["outcome"] == "transport_error"
|
|
assert calls == []
|
|
|
|
|
|
def test_explicit_builtin_fallback_uses_existing_surface(monkeypatch):
|
|
from tools import approval
|
|
|
|
manager = PluginManager()
|
|
_context(manager).register_approval_transport("phone", lambda request: {"choice": "once"})
|
|
_configure_manual_guard(monkeypatch, approval, manager, fallback="builtin")
|
|
builtin_calls = []
|
|
|
|
result = approval.check_all_command_guards(
|
|
"rm -rf /tmp/example",
|
|
"local",
|
|
approval_callback=lambda *args, **kwargs: builtin_calls.append(1) or "once",
|
|
)
|
|
|
|
assert result["approved"] is True
|
|
assert builtin_calls == [1]
|
|
|
|
|
|
def test_live_temp_home_fixture_plugin_routes_and_hardline_stays_core_owned(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""Real discovery + config + guard path under an isolated HERMES_HOME."""
|
|
import hermes_cli.plugins as plugins_module
|
|
from tools import approval
|
|
|
|
home = tmp_path / "hermes-home"
|
|
plugin_dir = home / "plugins" / "fixture-approval"
|
|
bundled = tmp_path / "empty-bundled"
|
|
plugin_dir.mkdir(parents=True)
|
|
bundled.mkdir()
|
|
(plugin_dir / "plugin.yaml").write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"name": "fixture-approval",
|
|
"version": "1.0.0",
|
|
"description": "approval transport fixture",
|
|
}
|
|
)
|
|
)
|
|
(plugin_dir / "__init__.py").write_text(
|
|
"""import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
|
|
def present(request):
|
|
output = Path(os.environ["HERMES_HOME"]) / "transport-invocations.jsonl"
|
|
with output.open("a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps({
|
|
"request_id": request.request_id,
|
|
"digest": request.digest,
|
|
"command": request.command,
|
|
"surface": request.surface,
|
|
"timeout_seconds": request.timeout_seconds,
|
|
}) + "\\n")
|
|
return request.respond("once")
|
|
|
|
|
|
def register(ctx):
|
|
ctx.register_approval_transport("fixture", present)
|
|
"""
|
|
)
|
|
home.mkdir(exist_ok=True)
|
|
(home / "config.yaml").write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"plugins": {"enabled": ["fixture-approval"]},
|
|
"approvals": {"mode": "manual", "timeout": 2},
|
|
"security": {
|
|
"tirith_enabled": False,
|
|
"approval": {"transport": "fixture"},
|
|
},
|
|
}
|
|
)
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(bundled))
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.setattr(approval, "_YOLO_MODE_FROZEN", False)
|
|
manager = PluginManager()
|
|
monkeypatch.setattr(plugins_module, "_plugin_manager", manager)
|
|
token = approval_context.set_hermes_interactive_context(True)
|
|
approval.clear_session("local")
|
|
approval._permanent_approved.clear()
|
|
try:
|
|
routed = approval.check_all_command_guards(
|
|
"rm -rf /tmp/hermes-approval-transport-fixture", "local"
|
|
)
|
|
manager.discover_and_load(force=True)
|
|
reloaded = approval.check_all_command_guards(
|
|
"rm -rf /tmp/hermes-approval-transport-fixture-reloaded", "local"
|
|
)
|
|
gateway_token = approval_context.set_hermes_interactive_context(False)
|
|
monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
|
|
try:
|
|
gateway_routed = approval.check_all_command_guards(
|
|
"rm -rf /tmp/hermes-approval-transport-fixture-gateway", "local"
|
|
)
|
|
finally:
|
|
approval_context.reset_hermes_interactive_context(gateway_token)
|
|
hardline = approval.check_all_command_guards("rm -rf /", "local")
|
|
finally:
|
|
approval_context.reset_hermes_interactive_context(token)
|
|
|
|
records = [
|
|
json.loads(line)
|
|
for line in (home / "transport-invocations.jsonl")
|
|
.read_text(encoding="utf-8")
|
|
.splitlines()
|
|
]
|
|
assert routed["approved"] is True
|
|
assert reloaded["approved"] is True
|
|
assert gateway_routed["approved"] is True
|
|
assert records[0]["request_id"]
|
|
assert records[0]["digest"]
|
|
assert records[0]["surface"] == "cli"
|
|
assert records[0]["timeout_seconds"] == 2
|
|
assert records[2]["surface"] == "gateway"
|
|
assert hardline["approved"] is False
|
|
assert len(records) == 3
|
|
|
|
|
|
def test_hardline_blocks_before_selected_transport(monkeypatch):
|
|
from tools import approval
|
|
import tools.approval_detection as approval_detection
|
|
|
|
manager = PluginManager()
|
|
calls = []
|
|
_context(manager).register_approval_transport(
|
|
"phone", lambda request: calls.append(request) or request.respond("once")
|
|
)
|
|
_configure_manual_guard(monkeypatch, approval, manager)
|
|
monkeypatch.setattr(
|
|
approval,
|
|
"detect_hardline_command",
|
|
lambda command: (True, "recursive delete of root filesystem"),
|
|
)
|
|
monkeypatch.setattr(
|
|
approval_detection,
|
|
"detect_hardline_command",
|
|
lambda command: (True, "recursive delete of root filesystem"),
|
|
)
|
|
|
|
result = approval.check_all_command_guards("rm -rf /", "local")
|
|
|
|
assert result["approved"] is False
|
|
assert calls == []
|