hermes(hux): close Wave A review findings in autonomy and the HTTP pipeline

F1 policy writes and allow grants are human-surface only; F2 worker trust is
confined to the hook allowlist and unexpected exceptions become audited 500
error records; F4 external side effects release only for the same run and
argument hash; F6 the gate honours budget exhaustion; F8 only the gateway
can vouch for an empty process registry and failed receipts can be
superseded; F11/F12 receipt revision and unshipped card routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
This commit is contained in:
jenkins 2026-08-24 00:45:13 -03:00
parent 74c6549965
commit 6964a9d8c8
10 changed files with 321 additions and 44 deletions

View File

@ -97,17 +97,36 @@ def hashes_of(approval: dict[str, Any]) -> set[str]:
return {e["hash"] for e in approval["request"].get("evidence", []) if e.get("kind") == "tool_call" and e.get("hash")}
def run_conversation(store: TenantStore, run_id: str) -> str | None:
"""The conversation a run belongs to, from its budget document or an approval it raised; never the body (F4)."""
doc_id = f"bud_{policy.run_key(run_id)}"
if store.exists(BUDGETS, doc_id) and store.get(BUDGETS, doc_id).get("_conversation_id"):
return store.get(BUDGETS, doc_id)["_conversation_id"]
for record in store.scan(policy.APPROVALS):
if record["run_id"] == run_id:
return record["conversation_id"]
return None
def matching_approval(store: TenantStore, run_id: str, capability: str, argument_hash: str, external: bool, conversation_id: str | None) -> tuple[dict[str, Any] | None, str, str | None]:
"""The approval that releases this side effect, or why none does, plus the run's conversation when an approval names it."""
"""The approval that releases this side effect, or why none does, plus the conversation the gate settled on.
External effects release only against an approval for the same run and
the same argument hash, whatever the choice (F4, SO-39). Non-external
session/always approvals stay reusable inside their conversation, which
is the run's own conversation when the run is known and otherwise the
approval's. A ``once`` approval names exactly one hash (SO-36, SO-37).
"""
reason = "no approval for this run and capability"
for record in store.scan(policy.APPROVALS):
record = policy.refresh(store, record)
if record["capability"] != capability:
continue
same_run = record["run_id"] == run_id
if same_run:
conversation_id = conversation_id or record["conversation_id"]
same_conv = bool(conversation_id) and record["conversation_id"] == conversation_id
choice = record.get("decision", {}).get("choice")
if record["capability"] != capability or not (same_run or (choice == "session" and same_conv)):
reusable = choice in ("session", "always") and not external and not record["request"]["external"]
same_conv = record["conversation_id"] == (conversation_id or record["conversation_id"])
if not (same_run or (reusable and same_conv)):
continue
if record["status"] != "approved":
reason = f"approval {record['id']} is {record['status']}"
@ -118,13 +137,13 @@ def matching_approval(store: TenantStore, run_id: str, capability: str, argument
if external and not record["request"]["external"]:
reason = f"approval {record['id']} was not requested as external"
continue
if choice == "once" and argument_hash not in hashes_of(record):
if (external or choice == "once") and argument_hash not in hashes_of(record):
reason = f"approval {record['id']} was for different arguments"
continue
if choice == "once" and record.get("_consumed_at"):
reason = f"approval {record['id']} was already consumed"
continue
return record, "released", conversation_id
return record, "released", conversation_id or record["conversation_id"]
return None, reason, conversation_id
@ -139,9 +158,12 @@ def gate(request: Request) -> Response:
if not isinstance(argument_hash, str) or not argument_hash.startswith("sha256:"):
raise Invalid("argument_hash must be sha256:<hex>")
external = bool(body.get("external", False))
conversation_id = body.get("conversation_id")
if conversation_id is not None:
conversation_id = check_id(conversation_id)
conversation_id = run_conversation(request.store, run_id)
state = budget_state(request.store, request.identity, run_id, conversation_id)
if state["exhausted"]: # F6: an exhausted run releases nothing, whatever was approved
request.audit("gate.check", f"{run_id}:{capability}", outcome="deny", reason="budget_exhausted")
policy.emit(request.store, request.identity, conversation_id, "budget.exhausted", f"Budget exhausted: {', '.join(state['exhausted'])}", run_id=run_id)
return Response(200, {"proceed": False, "reason": "budget_exhausted", "exhausted": state["exhausted"]})
with request.store.lock(policy.APPROVALS):
record, reason, conversation_id = matching_approval(request.store, run_id, capability, argument_hash, external, conversation_id)
if record is not None and record["decision"]["choice"] == "once":
@ -158,38 +180,49 @@ def gate(request: Request) -> Response:
# -- stop ----------------------------------------------------------------------
def stop_outcome(request: Request, body: dict[str, Any]) -> tuple[str, str]:
"""(outcome, reason) for a stop; only the gateway (worker trust) may vouch for an empty process registry (F8, SO-41)."""
if body.get("already_complete"):
return "already_complete", "already_complete"
if body.get("process_registry_empty") is not True:
return "failed_to_cancel", "process_registry_not_empty"
if request.identity.trust != "worker":
return "failed_to_cancel", "registry_state_not_from_gateway"
return "cancelled", "cancelled"
def stop(request: Request) -> Response:
"""``POST /hux/v1/runs/{id}/stop``: write the cancellation receipt; a repeat returns it."""
"""``POST /hux/v1/runs/{id}/stop``: write the cancellation receipt; a repeat returns it.
A ``failed_to_cancel`` receipt is the one non-terminal outcome: a later
stop that really cancels or finds the run complete supersedes it with a
revision bump; an identical repeat still replays (F8).
"""
body = policy.body_dict(request)
run_id = run_id_from(request)
receipt_id = f"rcpt_{policy.run_key(run_id)}"
side_effects = body.get("side_effects", [])
if not isinstance(side_effects, list):
raise Invalid("side_effects must be a list")
outcome, reason = stop_outcome(request, body)
with request.store.lock(RECEIPTS):
if request.store.exists(RECEIPTS, receipt_id):
existing = request.store.get(RECEIPTS, receipt_id)
existing = request.store.get(RECEIPTS, receipt_id) if request.store.exists(RECEIPTS, receipt_id) else None
if existing is not None and (existing["outcome"] != "failed_to_cancel" or outcome == "failed_to_cancel"):
request.audit("runs.stop", receipt_id, reason="replayed")
return Response(200, policy.public(existing))
side_effects = body.get("side_effects", [])
if not isinstance(side_effects, list):
raise Invalid("side_effects must be a list")
if body.get("already_complete"):
outcome = "already_complete"
elif body.get("process_registry_empty") is True:
outcome = "cancelled"
else:
outcome = "failed_to_cancel"
stamp = policy.iso(policy.now())
record: dict[str, Any] = {
"schema": "hux.cancel_receipt.v1", "id": receipt_id, "run_id": run_id, "requested_by": policy.actor_for(request.identity),
"requested_at": stamp, "acknowledged_at": stamp, "outcome": outcome, "side_effects": side_effects,
"requested_at": existing["requested_at"] if existing else stamp, "acknowledged_at": stamp, "outcome": outcome, "side_effects": side_effects,
}
if outcome != "failed_to_cancel":
record["completed_at"] = stamp
conversation_id = body.get("conversation_id")
conversation_id = body.get("conversation_id", existing.get("conversation_id") if existing else None)
if conversation_id is not None:
record["conversation_id"] = check_id(conversation_id)
stored = request.store.put(RECEIPTS, policy.checked(record))
request.audit("runs.stop", receipt_id, reason=outcome)
policy.emit(request.store, request.identity, record.get("conversation_id"), "run.cancelled", f"Run stopped: {outcome}",
stored = request.store.put(RECEIPTS, policy.checked(record), expected_revision=existing["revision"] if existing else None)
request.audit("runs.stop", receipt_id, reason=reason if existing is None else f"superseded:{reason}")
policy.emit(request.store, request.identity, record.get("conversation_id"), "run.cancelled", f"Run stopped: {outcome} ({reason})",
run_id=run_id, evidence=[{"kind": "run", "id": receipt_id}])
return Response(201, policy.public(stored))

View File

@ -3,7 +3,9 @@
Flags come from the ``HUX_FLAGS`` comma list. A card counts as enabled only
when it and every card it depends on are enabled, so a half-configured
deployment fails closed. Route ownership per card is declared here so the
capabilities record can tell a client exactly what it may call.
capabilities record can tell a client exactly what it may call, and the
worker allowlist (SO-08) says which of those a ``trust: worker`` caller may
reach at all.
"""
from __future__ import annotations
@ -23,13 +25,37 @@ CARD_ROUTES: dict[str, list[str]] = {
"HUX-03": ["/hux/v1/projects", "/hux/v1/projects/{id}", "/hux/v1/conversations", "/hux/v1/conversations/{id}", "/hux/v1/conversations/{id}/branch", "/hux/v1/conversations/{id}/lineage", "/hux/v1/search"],
"HUX-04": ["/hux/v1/artifacts", "/hux/v1/artifacts/{id}", "/hux/v1/artifacts/{id}/versions", "/hux/v1/artifacts/{id}/versions/{n}", "/hux/v1/artifacts/{id}/versions/{n}/diff", "/hux/v1/artifacts/{id}/promote"],
"HUX-05": ["/hux/v1/policy", "/hux/v1/approvals", "/hux/v1/approvals/{id}", "/hux/v1/runs/{id}/stop", "/hux/v1/runs/{id}/budget", "/hux/v1/runs/{id}/gate"],
"HUX-06": ["/hux/v1/modes", "/hux/v1/conversations/{id}/mode"],
"HUX-06": [],
"HUX-07": [],
"HUX-08": ["/hux/v1/sources", "/hux/v1/sources/{id}", "/hux/v1/passages", "/hux/v1/messages/{id}/citations", "/hux/v1/notebooks", "/hux/v1/notebooks/{id}"],
"HUX-09": ["/hux/v1/suggestions", "/hux/v1/suggestions/{id}/{action}"],
"HUX-09": [],
"HUX-10": ["/hux/v1/privacy/policy", "/hux/v1/privacy/notices", "/hux/v1/conversations/{id}/forget", "/hux/v1/privacy/audit"],
"HUX-12": ["/hux/v1/releases"],
"HUX-12": [],
}
# Cards whose routes are not shipped yet declare [] above until they land (F12).
# The only (method, template) pairs a ``trust: worker`` caller may reach (SO-08).
# Everything else is 403 before the flag check. These are the agent-hook
# routes: negotiation, the approval/gate/budget/stop loop, activity events,
# the privacy policy, memory retrieval and proposals, research inputs and
# artifact writes. The conversation read is there so the hook can honour
# private mode (SO-28) before proposing a memory.
WORKER_ROUTES: frozenset[tuple[str, str]] = frozenset({
("GET", "/hux/v1/capabilities"), ("GET", "/hux/v1/manifest"), ("GET", "/hux/v1/releases"),
("POST", "/hux/v1/approvals"),
("POST", "/hux/v1/runs/{id}/gate"), ("POST", "/hux/v1/runs/{id}/budget"), ("POST", "/hux/v1/runs/{id}/stop"),
("GET", "/hux/v1/runs/{id}/budget"),
("POST", "/hux/v1/conversations/{id}/events"), ("GET", "/hux/v1/conversations/{id}"),
("GET", "/hux/v1/privacy/policy"),
("GET", "/hux/v1/memory"), ("POST", "/hux/v1/memory"),
("POST", "/hux/v1/sources"), ("POST", "/hux/v1/passages"), ("POST", "/hux/v1/messages/{id}/citations"),
("POST", "/hux/v1/artifacts"), ("POST", "/hux/v1/artifacts/{id}/versions"),
})
def worker_may_call(method: str, template: str) -> bool:
"""True when a ``trust: worker`` caller is allowed on this route (SO-08)."""
return (method, template) in WORKER_ROUTES
class Flags:

View File

@ -18,8 +18,8 @@ from collections.abc import Mapping
from urllib.parse import parse_qs, urlsplit
from hux import audit
from hux.errors import HuxError, Invalid, NotFound, TooLarge
from hux.flags import Flags, build_from_environ
from hux.errors import Forbidden, HuxError, Invalid, NotFound, TooLarge
from hux.flags import CONTRACT_VERSION, Flags, build_from_environ, worker_may_call
from hux.identity import Identity, resolve
from hux.store import TenantStore
@ -137,6 +137,10 @@ class Router:
audit.record(store, identity, "http.route", parts.path, "not_found", error.message)
return Response(405 if known else 404, error.record())
try:
# SO-08: the worker allowlist is checked before the flag so a
# worker cannot even learn which cards are on.
if identity.trust == "worker" and not worker_may_call(method, route.template):
raise Forbidden("route is not available to worker trust")
self.flags.require(route.card)
payload = self._decode(body, route.max_body)
request = Request(method, parts.path, params, query, headers, payload, identity, store, self.flags, self.build)
@ -145,6 +149,10 @@ class Router:
outcome = {"flag_off": "flag_off", "conflict": "conflict", "not_found": "not_found"}.get(error.code, "deny")
audit.record(store, identity, route.action, parts.path, outcome, error.message)
return Response(error.status, error.record())
except Exception: # noqa: BLE001 - the pipeline never raises; anything else is a 500 with no detail leaked
error = HuxError("internal error")
audit.record(store, identity, route.action, parts.path, "deny", error.message)
return Response(error.status, error.record())
return response
@staticmethod
@ -175,7 +183,7 @@ def make_handler(router: Router) -> type[BaseHTTPRequestHandler]:
def _run(self) -> None:
if self.path == "/healthz":
self._send(Response(200, {"status": "ok", "contract_version": "1.0.0"}))
self._send(Response(200, {"status": "ok", "contract_version": CONTRACT_VERSION}))
return
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b""

View File

@ -61,9 +61,20 @@ def run_key(run_id: str) -> str:
return hashlib.sha256(run_id.encode()).hexdigest()[:32]
def is_human(identity: Identity) -> bool:
"""True when a person is behind the call: router/relay trust on a chat, telegram or voice surface."""
return identity.trust in HUMAN_TRUSTS and identity.surface in HUMAN_SURFACES
def require_human(identity: Identity, what: str) -> None:
"""Forbidden unless a human actor is behind the call (F1: policy writes and allow grants)."""
if not is_human(identity):
raise Forbidden(f"{what} only from a human surface")
def actor_for(identity: Identity) -> dict[str, str]:
"""The actor a record attributes to this caller: humans are users, hops are system."""
if identity.trust in HUMAN_TRUSTS and identity.surface in HUMAN_SURFACES:
if is_human(identity):
return {"type": "user", "id": identity.subject}
return {"type": "system", "id": identity.surface}
@ -163,6 +174,8 @@ def normalise_grants(grants: Any, identity: Identity) -> list[dict[str, Any]]:
raise Invalid("grant needs a known capability")
if grant.get("decision") not in ("allow", "ask", "deny"):
raise Invalid("grant decision must be allow, ask or deny")
if grant["decision"] == "allow":
require_human(identity, "allow grants")
expires = grant.get("expires_at")
if not isinstance(expires, str) or parse(expires) > ceiling:
expires = iso(ceiling)
@ -181,7 +194,8 @@ def get_policy(request: Request) -> Response:
def put_policy(request: Request) -> Response:
"""``PUT /hux/v1/policy``: replace the policy for the scope named in the body."""
"""``PUT /hux/v1/policy``: replace the policy for the scope named in the body; humans only (F1)."""
require_human(request.identity, "policy writes")
body = body_dict(request)
scope = body.get("scope") or {}
if not isinstance(scope, dict):
@ -189,6 +203,8 @@ def put_policy(request: Request) -> Response:
record_id = policy_id(scope.get("level", ""), scope.get("scope_id"))
if body.get("autonomy") not in ("ask_first", "safe", "autonomous"):
raise Invalid("autonomy must be ask_first, safe or autonomous")
if body["autonomy"] == "autonomous":
require_human(request.identity, "autonomy escalation")
budgets = body.get("budgets", dict(DEFAULT_BUDGETS))
record = {
"schema": "hux.policy.v1", "id": record_id, "owner": request.identity.subject, "scope": scope,
@ -205,7 +221,8 @@ def put_policy(request: Request) -> Response:
def add_grant(store: TenantStore, identity: Identity, level: str, scope_id: str | None, capability: str, ttl: timedelta) -> None:
"""Append an allow grant to a scope's policy after a session/always decision."""
"""Append an allow grant to a scope's policy after a session/always decision; the decider must be human."""
require_human(identity, "allow grants")
record_id = policy_id(level, scope_id)
with store.lock(FAMILY):
if store.exists(FAMILY, record_id):
@ -273,6 +290,9 @@ def create_approval(request: Request) -> Response:
policy = effective_policy(request.store, request.identity, "conversation", conversation_id)
req = body.get("request") if isinstance(body.get("request"), dict) else {}
external = bool(req.get("external", False))
evidence = req.get("evidence") if isinstance(req.get("evidence"), list) else []
if sum(1 for e in evidence if isinstance(e, dict) and e.get("kind") == "tool_call") > 1:
raise Invalid("an approval names exactly one tool_call; ask once per side effect (SO-37)")
decision = resolve_request(policy, capability, external)
stamp = now()
record: dict[str, Any] = {

View File

@ -71,6 +71,17 @@ argument hash a `once` approval is released against travels as
send it or the gate can never release. Memory rules skip content-free
statuses (`no_store`, `forgotten`, `rejected`).
Review follow-up (same revision, still additive): `cancellation_receipt`
gains optional `revision`. Stored documents in every revisioned family carry
the store's `revision`, and a `failed_to_cancel` receipt is the one
non-terminal outcome: a later stop that the gateway (worker trust) confirms
supersedes it with a revision bump; an identical repeat replays. Only a
human actor (router/relay trust on chat, telegram or voice) may write a
policy or hold an `allow` grant; `trust: worker` reaches only the routes in
`hux.flags.WORKER_ROUTES` (SO-08). External side effects release only
against an approval for the same run and argument hash. Cards HUX-06,
HUX-09 and HUX-12 declare no routes until they ship.
## Consequences
- Codex codes UI against the fixtures, not against the running service.

View File

@ -359,6 +359,11 @@
},
"conversation_id": {
"$ref": "common.schema.json#/$defs/id"
},
"revision": {
"type": "integer",
"minimum": 1,
"description": "Store revision; bumps when a failed_to_cancel receipt is superseded by a later stop."
}
}
},

View File

@ -363,3 +363,74 @@ def test_server_main_wires_environment(tmp_path, monkeypatch):
monkeypatch.setenv("HUX_PORT", "8791")
server.main()
assert seen == {"root": tmp_path, "host": "127.0.0.1", "port": 8791, "served": True}
# --- F2: worker allowlist, internal errors, health -----------------------------------
WORKER = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
def test_worker_trust_reaches_only_the_allowlisted_routes(tmp_path):
"""F2 (high) / SO-08: ``trust: worker`` gets 403 on every route outside ``flags.WORKER_ROUTES``, before the flag check."""
router = _router(tmp_path, environ={"HUX_WORKER_KEY": "wk"})
denied = [
("GET", "/hux/v1/memory/mem_0001aaaa"), ("GET", "/hux/v1/memory/export"), ("GET", "/hux/v1/conversations"),
("GET", "/hux/v1/conversations/conv_0001abcd/events"), ("GET", "/hux/v1/approvals"), ("PUT", "/hux/v1/policy"),
("GET", "/hux/v1/artifacts"), ("POST", "/hux/v1/conversations/conv_0001abcd/forget"), ("GET", "/hux/v1/privacy/audit"),
]
for method, path in denied:
status, body = _call(router, method, path, WORKER, b"{}" if method != "GET" else b"")
assert (status, body["code"]) == (403, "forbidden"), (method, path)
for method, path in [("GET", "/hux/v1/capabilities"), ("GET", "/hux/v1/runs/run_1/budget"), ("GET", "/hux/v1/privacy/policy"), ("GET", "/hux/v1/memory")]:
assert _call(router, method, path, WORKER)[0] == 200, (method, path)
rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(WORKER, {"HUX_WORKER_KEY": "wk"})))
assert [r["outcome"] for r in rows if r["action"] == "memory.read" or r["action"].startswith("policy")][:1] == ["deny"]
assert all(r["outcome"] == "deny" for r in rows if r["action"] == "approvals.list")
off = _router(tmp_path, flags_value="", environ={"HUX_WORKER_KEY": "wk"})
assert _call(off, "GET", "/hux/v1/approvals", WORKER)[0] == 403, "the allowlist answers before the flag does"
assert _call(off, "GET", "/hux/v1/capabilities", HEADERS)[0] == 404, "humans still see flag-off as not found"
assert flags.worker_may_call("GET", "/hux/v1/releases") and not flags.worker_may_call("GET", "/hux/v1/policy")
assert all(any(t == route.template for _, t in flags.WORKER_ROUTES if route.method == _) or (route.method, route.template) not in flags.WORKER_ROUTES for route in router.routes)
def test_unshipped_cards_declare_no_routes():
"""F12 (low): HUX-06, HUX-09 and HUX-12 own no routes until they ship, so capabilities never advertises a 404."""
assert flags.CARD_ROUTES["HUX-06"] == flags.CARD_ROUTES["HUX-09"] == flags.CARD_ROUTES["HUX-12"] == []
def test_unexpected_handler_exceptions_become_a_500_error_record(tmp_path):
"""F2: a non-HuxError in a handler never propagates; the caller sees ``hux.error.v1`` 500 with no detail and the audit says deny."""
router = _router(tmp_path)
def boom(request):
raise RuntimeError("secret stack detail")
router.add("GET", "/hux/v1/boom", "HUX-11", "test.boom", boom)
status, body = _call(router, "GET", "/hux/v1/boom")
assert status == 500 and body == {"schema": "hux.error.v1", "status": 500, "code": "invalid", "message": "internal error"}
assert contracts.validate_record(body, SCHEMAS) == []
rows = [(r["action"], r["outcome"], r.get("reason")) for r in audit.recent(store.TenantStore(tmp_path, ident()))]
assert rows[-1] == ("test.boom", "deny", "internal error")
def test_healthz_reports_the_contract_version(tmp_path):
"""F2: /healthz carries ``flags.CONTRACT_VERSION`` rather than a hard-coded string."""
server = serve(_router(tmp_path), "127.0.0.1", 0)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
conn = HTTPConnection("127.0.0.1", server.server_address[1], timeout=5)
conn.request("GET", "/healthz")
assert json.loads(conn.getresponse().read()) == {"status": "ok", "contract_version": flags.CONTRACT_VERSION}
finally:
server.shutdown()
server.server_close()
def test_no_outbound_network_client_in_the_service(tmp_path):
"""F13 / SO-29: no hux module imports urllib.request, requests, httpx or socket; http.server is the only http.* import."""
import re
banned = re.compile(r"^\s*(?:import|from)\s+(urllib\.request|requests|httpx|socket|http\.client)\b", re.M)
for path in sorted((FOUNDATION / "hux").glob("*.py")):
assert banned.search(path.read_text()) is None, path
http_imports = re.findall(r"^\s*from\s+(http\.\w+)\s+import", path.read_text(), re.M)
assert set(http_imports) <= {"http.server"}, (path, http_imports)

View File

@ -269,9 +269,10 @@ def test_gate_session_is_reusable_within_the_conversation(router):
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
assert gate(router, run_id="run_later", capability="shell", conversation_id=CONV)[1]["proceed"] is True
assert gate(router, run_id="run_later", capability="shell")[1]["proceed"] is False
assert gate(router, run_id="run_later", capability="shell", conversation_id="conv_elsewhere01")[1]["proceed"] is False
assert gate(router, run_id="run_later", capability="shell")[1]["proceed"] is True, "unknown run: the approval's conversation applies (F4)"
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)"
assert gate(router, capability="write_files")[1]["proceed"] is False
@ -290,7 +291,7 @@ def test_gate_requires_external_approvals_for_external_effects_and_respects_expi
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
assert gate(router, conversation_id="nope")[0] == 400
assert gate(router, conversation_id="nope")[0] == 200, "the body's conversation_id is ignored, never validated (F4)"
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)
@ -307,3 +308,52 @@ def test_events_module_absence_is_tolerated(router, monkeypatch):
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()
# --- 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
assert gate(router, run_id="run_fresh", capability="shell")[1]["proceed"] is True, "the budget is per run"
assert budgets.run_conversation(router_store(router), "run_nobody") is None
def router_store(router):
return store.TenantStore(router.data_root, identity.resolve(HEADERS, {}))

View File

@ -20,7 +20,7 @@ FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import audit, contracts, identity, policy, rules, store # noqa: E402
from hux import audit, contracts, errors, identity, policy, rules, store # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
@ -189,3 +189,34 @@ def test_helpers_round_trip_time_and_actors():
assert policy.run_key("run_9f") == policy.run_key("run_9f") and len(policy.run_key("x")) == 32
assert policy.public({"a": 1, "_b": 2, "revision": 3}) == {"a": 1}
assert policy.public({"a": 1, "_b": 2, "revision": 3}, revisioned=True) == {"a": 1, "revision": 3}
# --- F1: only humans write policy ---------------------------------------------------
def test_only_a_human_actor_may_write_policy_or_hold_allow_grants(tmp_path):
"""F1 (critical): worker and api surfaces cannot rewrite the policy, escalate autonomy or plant allow grants."""
router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk", "HUX_RELAY_KEY": "rk"})
worker = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
api = {**HEADERS, "X-Hux-Surface": "api"}
relay = {**HEADERS, "X-Hux-Surface": "telegram", "X-Hux-Trust": "relay", "X-Hux-Relay-Key": "rk"}
escalate = {"scope": {"level": "global"}, "autonomy": "autonomous", "grants": [{"capability": "network", "decision": "allow"}]}
for headers in (worker, api):
status, body, _ = call(router, "PUT", "/hux/v1/policy", escalate, headers)
assert (status, body["code"]) == (403, "forbidden"), headers
status, body, _ = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe"}, headers)
assert status == 403, "even a harmless-looking write is a policy write"
status, body, _ = call(router, "GET", "/hux/v1/policy", headers=api)
assert status == 200 and body["autonomy"] == "safe" and body["grants"] == [], "nothing leaked through"
status, body, _ = call(router, "PUT", "/hux/v1/policy", escalate, relay)
assert status == 200 and body["autonomy"] == "autonomous" and body["grants"][0]["granted_by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]}
rows = [(r["action"], r["outcome"]) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))) if r["action"] == "policy.write"]
assert rows == [("policy.write", "deny")] * 4 + [("policy.write", "allow")]
# The helpers assert the invariant even when a caller reaches them without the route.
system = identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "worker", "worker")
with pytest.raises(errors.Forbidden):
policy.normalise_grants([{"capability": "shell", "decision": "allow"}], system)
assert policy.normalise_grants([{"capability": "shell", "decision": "deny"}], system)[0]["granted_by"] == {"type": "system", "id": "worker"}
with pytest.raises(errors.Forbidden):
policy.add_grant(store.TenantStore(tmp_path, system), system, "global", None, "shell", timedelta(hours=1))
assert policy.is_human(identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "chat", "router"))
assert not policy.is_human(identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "api", "router"))

View File

@ -103,9 +103,9 @@ def test_budget_rejects_bad_bodies(router, body):
def test_stop_writes_a_receipt_and_a_second_stop_returns_it(router, events, tmp_path):
side_effects = [{"description": "Partial file left in workspace", "reverted": True, "evidence": {"kind": "file", "id": "notes.md"}}]
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "side_effects": side_effects, "conversation_id": CONV}, HEADERS)
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "side_effects": side_effects, "conversation_id": CONV})
assert status == 201 and valid(body)["outcome"] == "cancelled"
assert body["requested_by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]}
assert body["requested_by"] == {"type": "system", "id": "worker"}
assert body["requested_at"] == body["acknowledged_at"] == body["completed_at"]
assert body["side_effects"] == side_effects and body["conversation_id"] == CONV and "revision" not in body
status, again = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False})
@ -116,6 +116,28 @@ def test_stop_writes_a_receipt_and_a_second_stop_returns_it(router, events, tmp_
assert rows == [("runs.stop", "cancelled"), ("runs.stop", "replayed")]
def test_only_the_gateway_may_vouch_for_an_empty_registry_and_a_failed_receipt_can_be_superseded(router, events, tmp_path):
"""F8 / SO-41: a human surface asserting ``process_registry_empty`` gets ``failed_to_cancel``; the worker's later real cancel supersedes it."""
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "conversation_id": CONV}, HEADERS)
assert status == 201 and valid(body)["outcome"] == "failed_to_cancel" and "completed_at" not in body
first_requested = body["requested_at"]
status, again = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True}, HEADERS)
assert (status, again) == (200, body), "an identical failed stop replays"
status, done = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "side_effects": [{"description": "x", "reverted": True}]})
assert status == 201 and valid(done)["outcome"] == "cancelled" and done["requested_at"] == first_requested and "completed_at" in done
assert done["conversation_id"] == CONV and done["requested_by"] == {"type": "system", "id": "worker"}
tenant = store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))
stored = tenant.get("receipts", done["id"])
assert stored["revision"] == 2 and contracts.validate_record(stored, SCHEMAS) == [], "stored receipts carry revision (F11)"
status, third = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False})
assert (status, third) == (200, done), "a terminal receipt never changes again"
rows = [r.get("reason") for r in audit.recent(tenant) if r["action"] == "runs.stop"]
assert rows == ["registry_state_not_from_gateway", "replayed", "superseded:cancelled", "replayed"]
assert [e["summary"] for e in events] == ["Run stopped: failed_to_cancel (registry_state_not_from_gateway)", "Run stopped: cancelled (cancelled)"]
status, body = call(router, "POST", "/hux/v1/runs/run_h/stop", {"already_complete": True}, HEADERS)
assert valid(body)["outcome"] == "already_complete", "already_complete needs no registry claim"
def test_stop_outcomes_follow_the_process_registry(router):
status, body = call(router, "POST", "/hux/v1/runs/run_a/stop", {"process_registry_empty": False, "side_effects": []})
assert status == 201 and valid(body)["outcome"] == "failed_to_cancel" and "completed_at" not in body