diff --git a/dockerfiles/hermes-hux-foundation/hux/budgets.py b/dockerfiles/hermes-hux-foundation/hux/budgets.py index 7e16e35b..8df1afd1 100644 --- a/dockerfiles/hermes-hux-foundation/hux/budgets.py +++ b/dockerfiles/hermes-hux-foundation/hux/budgets.py @@ -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:") 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)) diff --git a/dockerfiles/hermes-hux-foundation/hux/flags.py b/dockerfiles/hermes-hux-foundation/hux/flags.py index 0dc138bd..53a40fd5 100644 --- a/dockerfiles/hermes-hux-foundation/hux/flags.py +++ b/dockerfiles/hermes-hux-foundation/hux/flags.py @@ -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: diff --git a/dockerfiles/hermes-hux-foundation/hux/http.py b/dockerfiles/hermes-hux-foundation/hux/http.py index 27528609..c4332125 100644 --- a/dockerfiles/hermes-hux-foundation/hux/http.py +++ b/dockerfiles/hermes-hux-foundation/hux/http.py @@ -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"" diff --git a/dockerfiles/hermes-hux-foundation/hux/policy.py b/dockerfiles/hermes-hux-foundation/hux/policy.py index 048b7d81..7606277b 100644 --- a/dockerfiles/hermes-hux-foundation/hux/policy.py +++ b/dockerfiles/hermes-hux-foundation/hux/policy.py @@ -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] = { diff --git a/docs/hux/ADR-0001-hux-v1-contract-freeze.md b/docs/hux/ADR-0001-hux-v1-contract-freeze.md index cf72db0c..6151f59a 100644 --- a/docs/hux/ADR-0001-hux-v1-contract-freeze.md +++ b/docs/hux/ADR-0001-hux-v1-contract-freeze.md @@ -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. diff --git a/services/hermes/contracts/hux/permission.schema.json b/services/hermes/contracts/hux/permission.schema.json index b383b2b9..a5906823 100644 --- a/services/hermes/contracts/hux/permission.schema.json +++ b/services/hermes/contracts/hux/permission.schema.json @@ -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." } } }, diff --git a/testing/tests/test_hermes_hux_contract_foundation.py b/testing/tests/test_hermes_hux_contract_foundation.py index 412c34a6..8a35f2ec 100644 --- a/testing/tests/test_hermes_hux_contract_foundation.py +++ b/testing/tests/test_hermes_hux_contract_foundation.py @@ -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) diff --git a/testing/tests/test_hermes_hux_policy_approvals.py b/testing/tests/test_hermes_hux_policy_approvals.py index a371f74e..932a8d16 100644 --- a/testing/tests/test_hermes_hux_policy_approvals.py +++ b/testing/tests/test_hermes_hux_policy_approvals.py @@ -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, {})) diff --git a/testing/tests/test_hermes_hux_policy_matrix.py b/testing/tests/test_hermes_hux_policy_matrix.py index 7d6a66a8..895f12a8 100644 --- a/testing/tests/test_hermes_hux_policy_matrix.py +++ b/testing/tests/test_hermes_hux_policy_matrix.py @@ -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")) diff --git a/testing/tests/test_hermes_hux_policy_receipts.py b/testing/tests/test_hermes_hux_policy_receipts.py index 8158e398..55f1fb3c 100644 --- a/testing/tests/test_hermes_hux_policy_receipts.py +++ b/testing/tests/test_hermes_hux_policy_receipts.py @@ -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