2026-08-24 00:21:51 -03:00
|
|
|
"""HUX-05 approval queue and the pre-side-effect gate.
|
|
|
|
|
|
|
|
|
|
Security obligations exercised: SO-35 (only a human surface with router or
|
|
|
|
|
relay trust decides; ``decision.by`` is the asserted user), SO-36 (``once``
|
|
|
|
|
is consumed exactly once), SO-37 (the gate must present the argument hash
|
|
|
|
|
recorded at request time), SO-39 (external side effects always need an
|
|
|
|
|
approval record), SO-40 (an exhausted budget blocks new approvals), SO-42
|
|
|
|
|
(pending approvals expire 24 h after ``requested_at``).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import importlib
|
|
|
|
|
import json
|
|
|
|
|
import sys
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
|
|
|
|
|
if str(FOUNDATION) not in sys.path:
|
|
|
|
|
sys.path.insert(0, str(FOUNDATION))
|
|
|
|
|
|
|
|
|
|
import hux # noqa: E402
|
|
|
|
|
from hux import audit, budgets, contracts, identity, policy, store # noqa: E402
|
|
|
|
|
from hux import events as hux_events # noqa: E402
|
|
|
|
|
from hux.server import build_router # noqa: E402
|
|
|
|
|
|
|
|
|
|
SCHEMAS = contracts.load_all()
|
2026-08-24 01:24:14 -03:00
|
|
|
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"}
|
2026-08-24 00:21:51 -03:00
|
|
|
OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
|
|
|
|
|
WORKER = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
|
|
|
|
|
RELAY = {**HEADERS, "X-Hux-Surface": "telegram", "X-Hux-Trust": "relay", "X-Hux-Relay-Key": "rk"}
|
|
|
|
|
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
|
|
|
|
T0 = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc)
|
|
|
|
|
CONV = "conv_0001abcd"
|
|
|
|
|
HASH = "sha256:" + "ab" * 32
|
|
|
|
|
OTHER_HASH = "sha256:" + "cd" * 32
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def router(tmp_path):
|
2026-08-24 01:58:14 -03:00
|
|
|
instance = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk", "HUX_RELAY_KEY": "rk"})
|
|
|
|
|
response = instance.dispatch(
|
|
|
|
|
"POST", "/hux/v1/runs/run_9f/budget", WORKER, json.dumps({"conversation_id": CONV}).encode()
|
|
|
|
|
)
|
|
|
|
|
assert response.status == 200
|
|
|
|
|
return instance
|
2026-08-24 00:21:51 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def frozen(monkeypatch):
|
|
|
|
|
state = {"now": T0}
|
|
|
|
|
monkeypatch.setattr(policy, "clock", lambda: state["now"])
|
|
|
|
|
return state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def events(monkeypatch):
|
|
|
|
|
"""Stand-in for the events lane; records every emit call."""
|
|
|
|
|
seen: list[dict] = []
|
|
|
|
|
monkeypatch.setattr(hux_events, "emit", lambda store, identity, conversation_id, kind, summary, **extra: seen.append({"conversation_id": conversation_id, "kind": kind, "summary": summary, **extra}))
|
|
|
|
|
return seen
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def call(router, method, path, body=None, headers=HEADERS):
|
|
|
|
|
raw = b"" if body is None else json.dumps(body).encode()
|
|
|
|
|
response = router.dispatch(method, path, headers, raw)
|
|
|
|
|
return response.status, response.body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def valid(record):
|
|
|
|
|
problems = contracts.validate_record(record, SCHEMAS)
|
|
|
|
|
assert problems == [], problems
|
|
|
|
|
return record
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def request_body(capability="write_files", external=False, run_id="run_9f", **extra):
|
|
|
|
|
evidence = [{"kind": "tool_call", "id": "call-7", "hash": HASH}]
|
|
|
|
|
return {"run_id": run_id, "conversation_id": CONV, "capability": capability,
|
|
|
|
|
"request": {"summary": f"do {capability}", "risk": "low", "external": external, "evidence": evidence, **extra}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_autonomy(router, level):
|
|
|
|
|
status, body = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": level})
|
|
|
|
|
assert status == 200, body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- creation resolves against the matrix ------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_safe_policy_auto_allows_reads_and_queues_mutations(router, frozen, events):
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/approvals", request_body("read_files"), WORKER)
|
|
|
|
|
assert status == 201 and valid(body)["status"] == "approved"
|
|
|
|
|
assert body["decision"] == {"choice": "once", "by": {"type": "system", "id": "policy"}, "at": policy.iso(T0)}
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/approvals", request_body("write_files"), WORKER)
|
|
|
|
|
assert status == 201 and valid(body)["status"] == "pending" and "decision" not in body
|
|
|
|
|
assert body["expires_at"] == policy.iso(T0 + timedelta(hours=24))
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/approvals", request_body("network"), WORKER)
|
2026-08-24 13:53:00 -03:00
|
|
|
assert status == 201 and valid(body)["status"] == "pending" and "decision" not in body, "safe parks network visibly"
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/approvals", request_body("web_search"), WORKER)
|
|
|
|
|
assert status == 201 and valid(body)["status"] == "pending", "safe parks web_search visibly"
|
|
|
|
|
assert [e["kind"] for e in events] == ["approval.resolved", "approval.requested", "approval.requested", "approval.requested"]
|
2026-08-24 00:21:51 -03:00
|
|
|
assert events[0]["evidence"] == [{"kind": "approval", "id": events[0]["evidence"][0]["id"]}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_external_side_effects_always_need_a_human_regardless_of_autonomy(router, events):
|
|
|
|
|
set_autonomy(router, "autonomous")
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/approvals", request_body("send_message"), WORKER)
|
|
|
|
|
assert status == 201 and body["status"] == "approved"
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/approvals", request_body("send_message", external=True), WORKER)
|
|
|
|
|
assert status == 201 and body["status"] == "pending"
|
|
|
|
|
for always_ask in ("deploy", "external_side_effect"):
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/approvals", request_body(always_ask), WORKER)
|
|
|
|
|
assert body["status"] == "pending", always_ask
|
|
|
|
|
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "autonomous", "grants": [{"capability": "network", "decision": "deny"}]})
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/approvals", request_body("network", external=True), WORKER)
|
|
|
|
|
assert body["status"] == "denied", "deny beats external ask"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_idempotency_key_returns_the_original(router):
|
|
|
|
|
key = {"Idempotency-Key": "run_9f:approval:call-7"}
|
|
|
|
|
status, first = call(router, "POST", "/hux/v1/approvals", request_body(), {**WORKER, **key})
|
|
|
|
|
assert status == 201 and first["idempotency_key"] == "run_9f:approval:call-7"
|
2026-08-24 01:58:14 -03:00
|
|
|
status, again = call(router, "POST", "/hux/v1/approvals", request_body(), {**WORKER, **key})
|
2026-08-24 00:21:51 -03:00
|
|
|
assert (status, again) == (200, first)
|
2026-08-24 01:58:14 -03:00
|
|
|
status, conflict = call(router, "POST", "/hux/v1/approvals", request_body("shell"), {**WORKER, **key})
|
|
|
|
|
assert (status, conflict["code"]) == (409, "conflict")
|
2026-08-24 00:21:51 -03:00
|
|
|
status, other = call(router, "POST", "/hux/v1/approvals", request_body(), {**WORKER, "Idempotency-Key": "run_9f:approval:call-8"})
|
|
|
|
|
assert status == 201 and other["id"] != first["id"]
|
|
|
|
|
assert call(router, "GET", f"/hux/v1/approvals/{first['id']}")[1] == first
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("body,status", [
|
|
|
|
|
(None, 400), ([], 400), ({"conversation_id": "nope", "capability": "shell"}, 400),
|
|
|
|
|
({"conversation_id": CONV, "capability": "teleport"}, 400),
|
|
|
|
|
({"conversation_id": CONV, "capability": "shell", "run_id": "r"}, 400),
|
|
|
|
|
({"conversation_id": CONV, "capability": "shell", "run_id": "r", "request": {"summary": "", "risk": "low"}}, 400),
|
|
|
|
|
({"conversation_id": CONV, "capability": "shell", "run_id": "r", "request": {"summary": "x", "risk": "silly"}}, 400),
|
|
|
|
|
])
|
|
|
|
|
def test_create_rejects_bad_bodies(router, body, status):
|
|
|
|
|
got, error = call(router, "POST", "/hux/v1/approvals", body, WORKER)
|
|
|
|
|
assert got == status and valid(error)["code"] == "invalid"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- deciding -----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def pending(router, capability="write_files", **kw):
|
2026-08-24 01:58:14 -03:00
|
|
|
body = request_body(capability, **kw)
|
|
|
|
|
run_id = body["run_id"]
|
|
|
|
|
if budgets.run_conversation(router_store(router), run_id) is None:
|
|
|
|
|
assert call(router, "POST", f"/hux/v1/runs/{run_id}/budget", {"conversation_id": body["conversation_id"]}, WORKER)[0] == 200
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/approvals", body, WORKER)
|
2026-08-24 00:21:51 -03:00
|
|
|
assert status == 201 and body["status"] == "pending", body
|
|
|
|
|
return body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_only_humans_on_router_or_relay_decide(router, tmp_path):
|
|
|
|
|
record = pending(router)
|
|
|
|
|
status, error = call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"}, WORKER)
|
|
|
|
|
assert (status, error["code"]) == (403, "forbidden")
|
|
|
|
|
api = {**HEADERS, "X-Hux-Surface": "api"}
|
|
|
|
|
assert call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"}, api)[0] == 403
|
|
|
|
|
status, body = call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"}, RELAY)
|
|
|
|
|
assert status == 200 and valid(body)["status"] == "approved"
|
|
|
|
|
assert body["decision"]["by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]}
|
2026-08-24 01:24:14 -03:00
|
|
|
rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"})))
|
2026-08-24 00:21:51 -03:00
|
|
|
assert [(r["action"], r["outcome"]) for r in rows if r["action"] == "approvals.decide"] == [("approvals.decide", "deny")] * 2 + [("approvals.decide", "allow")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_terminal_states_never_change_again(router, events):
|
|
|
|
|
record = pending(router)
|
|
|
|
|
path = f"/hux/v1/approvals/{record['id']}"
|
|
|
|
|
assert call(router, "POST", path, {"choice": "maybe"})[0] == 400
|
|
|
|
|
assert call(router, "POST", path, [])[0] == 400
|
|
|
|
|
status, body = call(router, "POST", path, {"choice": "deny"})
|
|
|
|
|
assert status == 200 and body["status"] == "denied" and body["decision"]["choice"] == "deny"
|
|
|
|
|
status, error = call(router, "POST", path, {"choice": "once"})
|
|
|
|
|
assert (status, error["code"]) == (409, "conflict")
|
|
|
|
|
assert call(router, "GET", path)[1] == body
|
|
|
|
|
assert events[-1]["kind"] == "approval.resolved" and "denied" in events[-1]["summary"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_pending_approvals_expire_after_24h(router, frozen):
|
|
|
|
|
record = pending(router)
|
|
|
|
|
frozen["now"] = T0 + timedelta(hours=24)
|
|
|
|
|
status, body = call(router, "GET", f"/hux/v1/approvals/{record['id']}")
|
|
|
|
|
assert status == 200 and valid(body)["status"] == "expired"
|
|
|
|
|
assert call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})[0] == 409
|
|
|
|
|
assert call(router, "GET", "/hux/v1/approvals?status=expired")[1]["items"] == [body]
|
|
|
|
|
assert call(router, "GET", "/hux/v1/approvals?status=pending")[1]["items"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_session_and_always_create_grants_at_their_scope(router, frozen):
|
|
|
|
|
record = pending(router, "shell")
|
|
|
|
|
status, body = call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
|
|
|
|
|
assert status == 200 and body["decision"]["choice"] == "session"
|
|
|
|
|
conv_policy = call(router, "GET", f"/hux/v1/policy?scope=conversation&scope_id={CONV}")[1]
|
|
|
|
|
assert valid(conv_policy)["scope"] == {"level": "conversation", "scope_id": CONV}
|
|
|
|
|
assert conv_policy["grants"] == [{"capability": "shell", "decision": "allow", "expires_at": policy.iso(T0 + policy.SESSION_TTL), "granted_by": {"type": "user", "id": HEADERS["X-Hux-Subject"]}}]
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/approvals", request_body("shell"), WORKER)
|
|
|
|
|
assert body["status"] == "approved", "later shell requests in this conversation auto-approve"
|
|
|
|
|
assert call(router, "POST", "/hux/v1/approvals", request_body("write_files"), WORKER)[1]["status"] == "pending"
|
|
|
|
|
record = pending(router, "write_files")
|
|
|
|
|
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "always"})
|
|
|
|
|
global_policy = call(router, "GET", "/hux/v1/policy")[1]
|
|
|
|
|
assert [g["capability"] for g in global_policy["grants"]] == ["write_files"]
|
|
|
|
|
assert global_policy["grants"][0]["expires_at"] == policy.iso(T0 + policy.ALWAYS_TTL)
|
|
|
|
|
frozen["now"] = T0 + timedelta(hours=25)
|
|
|
|
|
record = pending(router, "shell", run_id="run_other")
|
|
|
|
|
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
|
|
|
|
|
conv_policy = call(router, "GET", f"/hux/v1/policy?scope=conversation&scope_id={CONV}")[1]
|
|
|
|
|
assert len(conv_policy["grants"]) == 1 and conv_policy["revision"] == 2, "a repeat grant replaces, never duplicates"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_list_filters_and_orders_by_request_time(router, frozen):
|
|
|
|
|
first = pending(router, "shell")
|
|
|
|
|
frozen["now"] = T0 + timedelta(seconds=5)
|
|
|
|
|
second = pending(router, "write_files")
|
|
|
|
|
status, body = call(router, "GET", "/hux/v1/approvals")
|
|
|
|
|
assert status == 200 and [i["id"] for i in body["items"]] == [first["id"], second["id"]] and body["next"] is None
|
|
|
|
|
assert call(router, "GET", "/hux/v1/approvals?status=pending")[1]["items"] == body["items"]
|
|
|
|
|
assert call(router, "GET", "/hux/v1/approvals?status=approved")[1]["items"] == []
|
|
|
|
|
assert call(router, "GET", "/hux/v1/approvals?status=bogus")[0] == 400
|
|
|
|
|
for item in body["items"]:
|
|
|
|
|
valid(item)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_second_subject_cannot_see_or_decide(router):
|
|
|
|
|
record = pending(router)
|
|
|
|
|
assert call(router, "GET", f"/hux/v1/approvals/{record['id']}", headers=OTHER)[0] == 404
|
|
|
|
|
assert call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"}, OTHER)[0] == 404
|
|
|
|
|
assert call(router, "GET", "/hux/v1/approvals", headers=OTHER)[1]["items"] == []
|
|
|
|
|
assert call(router, "GET", "/hux/v1/approvals/apr_doesnotexist")[0] == 404
|
|
|
|
|
assert call(router, "GET", "/hux/v1/approvals/..")[0] == 400
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- budgets block new approvals -------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_exhausted_budget_blocks_new_approvals(router, events):
|
|
|
|
|
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe", "budgets": {"tool_calls_per_run": 2}})
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/runs/run_9f/budget", {"conversation_id": CONV, "tool_calls": 2}, WORKER)
|
|
|
|
|
assert status == 200 and valid(body)["exhausted"] == ["tool_calls_per_run"]
|
|
|
|
|
status, error = call(router, "POST", "/hux/v1/approvals", request_body("read_files"), WORKER)
|
|
|
|
|
assert (status, error["code"]) == (429, "budget_exhausted") and error["details"] == ["tool_calls_per_run"]
|
|
|
|
|
assert [e["kind"] for e in events] == ["budget.exhausted", "budget.exhausted"]
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/approvals", request_body("read_files", run_id="run_fresh"), WORKER)
|
2026-08-24 01:58:14 -03:00
|
|
|
assert (status, body["code"]) == (400, "invalid"), "an unbound run cannot create an approval record"
|
|
|
|
|
call(router, "POST", "/hux/v1/runs/run_fresh/budget", {"conversation_id": CONV}, WORKER)
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/approvals", request_body("read_files", run_id="run_fresh"), WORKER)
|
2026-08-24 01:24:14 -03:00
|
|
|
assert (status, body["code"]) == (429, "budget_exhausted"), "run rotation cannot reset conversation spend"
|
2026-08-24 00:21:51 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- gate --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def gate(router, run_id="run_9f", capability="write_files", argument_hash=HASH, external=False, **extra):
|
|
|
|
|
return call(router, "POST", f"/hux/v1/runs/{run_id}/gate", {"capability": capability, "argument_hash": argument_hash, "external": external, **extra}, WORKER)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gate_blocks_before_approval_and_releases_once_exactly_once(router, events, tmp_path):
|
|
|
|
|
status, body = gate(router)
|
|
|
|
|
assert status == 200 and body == {"proceed": False, "reason": "no approval for this run and capability"}
|
|
|
|
|
record = pending(router)
|
|
|
|
|
assert gate(router)[1]["proceed"] is False and "pending" in gate(router)[1]["reason"]
|
|
|
|
|
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})
|
|
|
|
|
status, wrong = gate(router, argument_hash=OTHER_HASH)
|
|
|
|
|
assert wrong["proceed"] is False and "different arguments" in wrong["reason"], "TOCTOU (SO-37)"
|
|
|
|
|
status, body = gate(router)
|
|
|
|
|
assert body == {"proceed": True, "approval_id": record["id"], "reason": "released"}
|
|
|
|
|
status, again = gate(router)
|
|
|
|
|
assert again["proceed"] is False and "already consumed" in again["reason"], "once is once (SO-36)"
|
|
|
|
|
kinds = [e["kind"] for e in events]
|
2026-08-24 01:58:14 -03:00
|
|
|
assert kinds.count("side_effect.released") == 1 and kinds.count("side_effect.blocked") == 5
|
2026-08-24 00:21:51 -03:00
|
|
|
released = next(e for e in events if e["kind"] == "side_effect.released")
|
|
|
|
|
assert released["evidence"] == [{"kind": "approval", "id": record["id"]}] and released["run_id"] == "run_9f"
|
|
|
|
|
served = call(router, "GET", f"/hux/v1/approvals/{record['id']}")[1]
|
|
|
|
|
assert valid(served) and "_consumed_at" not in served
|
2026-08-24 01:24:14 -03:00
|
|
|
rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"})))
|
2026-08-24 00:21:51 -03:00
|
|
|
assert [r["outcome"] for r in rows if r["action"] == "gate.check"] == ["deny", "deny", "deny", "deny", "allow", "deny"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gate_session_is_reusable_within_the_conversation(router):
|
|
|
|
|
record = pending(router, "shell")
|
|
|
|
|
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
|
|
|
|
|
assert gate(router, capability="shell")[1]["proceed"] is True
|
|
|
|
|
assert gate(router, capability="shell", argument_hash=OTHER_HASH)[1]["proceed"] is True
|
2026-08-24 01:24:14 -03:00
|
|
|
assert gate(router, run_id="run_later", capability="shell")[1]["proceed"] is False, "unknown runs inherit no approval"
|
|
|
|
|
status, unbound = call(router, "POST", "/hux/v1/approvals", request_body("shell", run_id="run_unbound"), WORKER)
|
2026-08-24 01:58:14 -03:00
|
|
|
assert (status, unbound["code"]) == (400, "invalid"), "an unknown run cannot mint any approval record"
|
2026-08-24 01:24:14 -03:00
|
|
|
call(router, "POST", "/hux/v1/runs/run_later/budget", {"conversation_id": CONV}, WORKER)
|
|
|
|
|
assert gate(router, run_id="run_later", capability="shell")[1]["proceed"] is False, "the old approval record never spans runs"
|
|
|
|
|
status, fresh = call(router, "POST", "/hux/v1/approvals", request_body("shell", run_id="run_later"), WORKER)
|
|
|
|
|
assert status == 201 and fresh["status"] == "approved" and fresh["id"] != record["id"]
|
|
|
|
|
assert gate(router, run_id="run_later", capability="shell")[1]["approval_id"] == fresh["id"]
|
2026-08-24 00:45:13 -03:00
|
|
|
call(router, "POST", "/hux/v1/runs/run_elsewhere/budget", {"conversation_id": "conv_elsewhere01", "tokens": 1}, WORKER)
|
|
|
|
|
status, body = gate(router, run_id="run_elsewhere", capability="shell", conversation_id=CONV)
|
|
|
|
|
assert body["proceed"] is False, "the run's own conversation wins over the body's claim (F4)"
|
2026-08-24 01:24:14 -03:00
|
|
|
elsewhere = request_body("shell", run_id="run_elsewhere")
|
|
|
|
|
elsewhere["conversation_id"] = "conv_elsewhere01"
|
|
|
|
|
status, scoped = call(router, "POST", "/hux/v1/approvals", elsewhere, WORKER)
|
|
|
|
|
assert status == 201 and scoped["status"] == "pending", "a session grant never crosses conversations"
|
2026-08-24 00:21:51 -03:00
|
|
|
assert gate(router, capability="write_files")[1]["proceed"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gate_requires_external_approvals_for_external_effects_and_respects_expiry(router, frozen):
|
|
|
|
|
record = pending(router, "send_message")
|
|
|
|
|
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})
|
|
|
|
|
status, body = gate(router, capability="send_message", external=True)
|
|
|
|
|
assert body["proceed"] is False and "not requested as external" in body["reason"]
|
|
|
|
|
record = pending(router, "send_message", external=True)
|
|
|
|
|
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})
|
|
|
|
|
frozen["now"] = T0 + timedelta(hours=25)
|
|
|
|
|
status, body = gate(router, capability="send_message", external=True)
|
|
|
|
|
assert body["proceed"] is False and "expired" in body["reason"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gate_rejects_bad_bodies_and_other_tenants(router):
|
|
|
|
|
assert gate(router, capability="teleport")[0] == 400
|
|
|
|
|
assert gate(router, argument_hash="md5:zz")[0] == 400
|
2026-08-24 00:45:13 -03:00
|
|
|
assert gate(router, conversation_id="nope")[0] == 200, "the body's conversation_id is ignored, never validated (F4)"
|
2026-08-24 00:21:51 -03:00
|
|
|
assert call(router, "POST", "/hux/v1/runs/run_9f/gate", [], WORKER)[0] == 400
|
|
|
|
|
assert call(router, "POST", f"/hux/v1/runs/{'r' * 121}/gate", {"capability": "shell", "argument_hash": HASH}, WORKER)[0] == 400
|
|
|
|
|
record = pending(router)
|
|
|
|
|
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})
|
|
|
|
|
status, body = call(router, "POST", "/hux/v1/runs/run_9f/gate", {"capability": "write_files", "argument_hash": HASH}, OTHER)
|
2026-08-24 01:58:14 -03:00
|
|
|
assert (status, body["code"]) == (403, "forbidden")
|
2026-08-24 00:21:51 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_events_module_absence_is_tolerated(router, monkeypatch):
|
|
|
|
|
monkeypatch.setitem(sys.modules, "hux.events", None)
|
|
|
|
|
monkeypatch.delattr(hux, "events")
|
|
|
|
|
with pytest.raises(ImportError):
|
|
|
|
|
importlib.import_module("hux.events")
|
|
|
|
|
assert call(router, "POST", "/hux/v1/approvals", request_body("read_files"), WORKER)[0] == 201
|
|
|
|
|
policy.emit(None, None, None, "x", "no conversation means no event")
|
|
|
|
|
assert budgets.hashes_of({"request": {}}) == set()
|
2026-08-24 00:45:13 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- F4 / F6: external effects, run conversation and budgets at the gate --------------
|
|
|
|
|
|
|
|
|
|
def test_external_effects_release_only_for_the_same_run_and_arguments(router):
|
|
|
|
|
"""F4 (high) / SO-39: a session or always approval for an external effect never releases another run or other arguments."""
|
|
|
|
|
record = pending(router, "send_message", external=True)
|
|
|
|
|
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
|
|
|
|
|
assert gate(router, capability="send_message", external=True)[1]["proceed"] is True
|
|
|
|
|
assert gate(router, capability="send_message", external=True)[1]["proceed"] is True, "session is reusable for the same run and hash"
|
|
|
|
|
status, body = gate(router, capability="send_message", external=True, argument_hash=OTHER_HASH)
|
|
|
|
|
assert body["proceed"] is False and "different arguments" in body["reason"]
|
|
|
|
|
status, body = gate(router, run_id="run_z", capability="send_message", external=True, conversation_id=CONV)
|
|
|
|
|
assert body["proceed"] is False and body["reason"] == "no approval for this run and capability"
|
|
|
|
|
assert gate(router, run_id="run_z", capability="send_message", external=False)[1]["proceed"] is False, "an external approval is not a general one"
|
|
|
|
|
always = pending(router, "send_message", external=True, run_id="run_y")
|
|
|
|
|
call(router, "POST", f"/hux/v1/approvals/{always['id']}", {"choice": "always"})
|
|
|
|
|
assert gate(router, run_id="run_y", capability="send_message", external=True)[1]["proceed"] is True
|
|
|
|
|
assert gate(router, run_id="run_w", capability="send_message", external=True)[1]["proceed"] is False, "always never spans runs for external effects"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_once_approvals_name_exactly_one_tool_call(router):
|
|
|
|
|
"""F4 / SO-37: a request carrying two tool_call hashes is refused so a once approval designates one hash."""
|
|
|
|
|
body = request_body("shell")
|
|
|
|
|
body["request"]["evidence"] = [{"kind": "tool_call", "id": "a", "hash": HASH}, {"kind": "tool_call", "id": "b", "hash": OTHER_HASH}]
|
|
|
|
|
status, error = call(router, "POST", "/hux/v1/approvals", body, WORKER)
|
|
|
|
|
assert (status, error["code"]) == (400, "invalid") and "exactly one tool_call" in error["message"]
|
|
|
|
|
body["request"]["evidence"] = [{"kind": "tool_call", "id": "a", "hash": HASH}, {"kind": "file", "id": "notes.md"}]
|
|
|
|
|
assert call(router, "POST", "/hux/v1/approvals", body, WORKER)[0] == 201, "other evidence kinds do not count"
|
|
|
|
|
body["request"]["evidence"] = "not-a-list"
|
|
|
|
|
assert call(router, "POST", "/hux/v1/approvals", body, WORKER)[0] == 400, "the contract still rejects it, without a 500"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gate_refuses_when_the_run_budget_is_exhausted(router, events):
|
|
|
|
|
"""F6 (medium) / SO-40: an approved effect still does not proceed once any limit is spent."""
|
|
|
|
|
record = pending(router, "shell")
|
|
|
|
|
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
|
|
|
|
|
assert gate(router, capability="shell")[1]["proceed"] is True
|
|
|
|
|
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "conversation", "scope_id": CONV}, "autonomy": "safe", "budgets": {"tool_calls_per_run": 2}})
|
|
|
|
|
call(router, "POST", "/hux/v1/runs/run_9f/budget", {"conversation_id": CONV, "tool_calls": 2}, WORKER)
|
|
|
|
|
status, body = gate(router, capability="shell")
|
|
|
|
|
assert body == {"proceed": False, "reason": "budget_exhausted", "exhausted": ["tool_calls_per_run"]}
|
|
|
|
|
assert events[-1]["kind"] == "budget.exhausted" and events[-1]["run_id"] == "run_9f" and events[-1]["conversation_id"] == CONV
|
2026-08-24 01:24:14 -03:00
|
|
|
assert gate(router, run_id="run_fresh", capability="shell")[1]["proceed"] is False, "unknown runs inherit nothing"
|
|
|
|
|
call(router, "POST", "/hux/v1/runs/run_fresh/budget", {"conversation_id": CONV}, WORKER)
|
|
|
|
|
assert gate(router, run_id="run_fresh", capability="shell")[1]["reason"] == "budget_exhausted"
|
|
|
|
|
status, error = call(router, "POST", "/hux/v1/approvals", request_body("shell", run_id="run_fresh"), WORKER)
|
|
|
|
|
assert (status, error["code"]) == (429, "budget_exhausted")
|
|
|
|
|
call(router, "POST", "/hux/v1/runs/run_other/budget", {"conversation_id": "conv_other0001"}, WORKER)
|
|
|
|
|
assert gate(router, run_id="run_other", capability="shell")[1]["proceed"] is False
|
2026-08-24 00:45:13 -03:00
|
|
|
assert budgets.run_conversation(router_store(router), "run_nobody") is None
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 01:24:14 -03:00
|
|
|
def test_conversation_budget_aggregates_across_runs_and_policy_revision_resets(router):
|
|
|
|
|
"""Run rotation cannot reset spend; an explicit human policy revision starts a new budget epoch."""
|
|
|
|
|
policy_body = {
|
|
|
|
|
"scope": {"level": "conversation", "scope_id": CONV},
|
|
|
|
|
"autonomy": "safe",
|
|
|
|
|
"budgets": {"tool_calls_per_run": 3},
|
|
|
|
|
}
|
|
|
|
|
assert call(router, "PUT", "/hux/v1/policy", policy_body)[0] == 200
|
|
|
|
|
first = call(router, "POST", "/hux/v1/runs/run_a/budget", {"conversation_id": CONV, "tool_calls": 2}, WORKER)[1]
|
|
|
|
|
second = call(router, "POST", "/hux/v1/runs/run_b/budget", {"conversation_id": CONV, "tool_calls": 1}, WORKER)[1]
|
|
|
|
|
assert first["spent"]["tool_calls"] == 2
|
|
|
|
|
assert second["spent"]["tool_calls"] == 3 and second["exhausted"] == ["tool_calls_per_run"]
|
|
|
|
|
assert gate(router, run_id="run_b", capability="shell")[1]["reason"] == "budget_exhausted"
|
|
|
|
|
revised = call(router, "PUT", "/hux/v1/policy", policy_body)[1]
|
|
|
|
|
assert revised["revision"] == 2
|
|
|
|
|
reset = call(router, "GET", "/hux/v1/runs/run_b/budget", headers=WORKER)[1]
|
|
|
|
|
assert reset["spent"]["tool_calls"] == 0 and reset["exhausted"] == []
|
2026-08-24 01:58:14 -03:00
|
|
|
restarted = call(router, "POST", "/hux/v1/runs/run_b/budget", {"conversation_id": CONV, "tool_calls": 1}, WORKER)[1]
|
|
|
|
|
assert restarted["spent"]["tool_calls"] == 1, "the old epoch's per-run spend does not leak into the new epoch"
|
2026-08-24 01:24:14 -03:00
|
|
|
|
|
|
|
|
|
2026-08-24 00:45:13 -03:00
|
|
|
def router_store(router):
|
2026-08-24 01:24:14 -03:00
|
|
|
return store.TenantStore(router.data_root, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))
|