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:
parent
74c6549965
commit
6964a9d8c8
@ -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))
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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""
|
||||
|
||||
@ -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] = {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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, {}))
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user