From a260506983665745771b1819e6af770d3a2e59b1 Mon Sep 17 00:00:00 2001 From: jenkins Date: Mon, 24 Aug 2026 01:58:14 -0300 Subject: [PATCH] security(hux): converge backend trust boundaries --- .../hermes-hux-foundation/hux/__init__.py | 1 + .../hermes-hux-foundation/hux/budgets.py | 34 +++-- .../hermes-hux-foundation/hux/flags.py | 16 +- dockerfiles/hermes-hux-foundation/hux/http.py | 39 +++-- .../hermes-hux-foundation/hux/identity.py | 89 ++++++++++- .../hermes-hux-foundation/hux/policy.py | 63 +++++--- .../hermes-hux-foundation/hux/privacy.py | 4 + .../hermes-hux-foundation/hux/server.py | 8 +- .../hermes-worker-hux/hux_hook/client.py | 71 ++++++++- testing/quality_contract.json | 52 +++++++ .../test_hermes_hux_contract_foundation.py | 1 + .../tests/test_hermes_hux_contract_hook.py | 58 ++++++- .../tests/test_hermes_hux_http_security.py | 144 +++++++++++++++++- .../tests/test_hermes_hux_policy_approvals.py | 28 +++- .../tests/test_hermes_hux_policy_receipts.py | 22 +-- .../tests/test_hermes_hux_privacy_topics.py | 8 +- testing/tests/test_quality_contract.py | 13 ++ 17 files changed, 575 insertions(+), 76 deletions(-) diff --git a/dockerfiles/hermes-hux-foundation/hux/__init__.py b/dockerfiles/hermes-hux-foundation/hux/__init__.py index e69de29b..4fe11dcf 100644 --- a/dockerfiles/hermes-hux-foundation/hux/__init__.py +++ b/dockerfiles/hermes-hux-foundation/hux/__init__.py @@ -0,0 +1 @@ +"""Hermes User Experience foundation service package.""" diff --git a/dockerfiles/hermes-hux-foundation/hux/budgets.py b/dockerfiles/hermes-hux-foundation/hux/budgets.py index e86e903d..7858768a 100644 --- a/dockerfiles/hermes-hux-foundation/hux/budgets.py +++ b/dockerfiles/hermes-hux-foundation/hux/budgets.py @@ -9,6 +9,7 @@ actually happened to the run and its side effects (SO-41). from __future__ import annotations +import re from typing import Any from hux import policy, rules @@ -24,14 +25,19 @@ LIMIT_OF = { "tokens": "tokens_per_run", "tool_calls": "tool_calls_per_run", "wall_clock_seconds": "wall_clock_seconds", "delegations": "delegations_per_run", "spend_units": "spend_units", "subagents": "subagents_per_run", } +RUN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,120}$") + + +def checked_run_id(value: Any) -> str: + """Return a canonical run id accepted by both body and path APIs.""" + if not isinstance(value, str) or not RUN_ID_RE.fullmatch(value): + raise Invalid("run id is malformed or too long") + return value def run_id_from(request: Request) -> str: """The run id in the path; the router already bounded its alphabet.""" - run_id = request.params["id"] - if len(run_id) > 120: - raise Invalid("run id too long") - return run_id + return checked_run_id(request.params["id"]) # -- budgets ------------------------------------------------------------------- @@ -43,6 +49,7 @@ def exhausted(spent: dict[str, int], limits: dict[str, Any]) -> list[str]: def budget_state(store: TenantStore, identity: Identity, run_id: str, conversation_id: str | None = None) -> dict[str, Any]: """Current state for a run, enforced against its conversation's policy epoch aggregate.""" + run_id = checked_run_id(run_id) doc_id = f"bud_{policy.run_key(run_id)}" stored = store.get(BUDGETS, doc_id) if store.exists(BUDGETS, doc_id) else {"spent": {}, "_conversation_id": None} known_conversation = stored.get("_conversation_id") or run_conversation(store, run_id) @@ -53,7 +60,8 @@ def budget_state(store: TenantStore, identity: Identity, run_id: str, conversati effective = policy.effective_policy(store, identity, level, scope_id) limits = {k: v for k, v in effective["budgets"].items() if k != "scope"} epoch = str(effective.get("_budget_epoch") or f"{effective['id']}:{effective['revision']}") - run_spent = {k: int(stored["spent"].get(k, 0)) for k in SPEND_KEYS} + same_epoch = stored.get("_budget_epoch") == epoch + run_spent = {k: int(stored["spent"].get(k, 0)) if same_epoch else 0 for k in SPEND_KEYS} aggregate = {k: 0 for k in SPEND_KEYS} for record in store.scan(BUDGETS): if record.get("_conversation_id") != conversation_id or record.get("_budget_epoch") != epoch: @@ -76,6 +84,7 @@ def get_budget(request: Request) -> Response: def post_budget(request: Request) -> Response: """``POST /hux/v1/runs/{id}/budget``: add spend increments; emits budget.exhausted on the crossing.""" + policy.require_worker(request.identity, "budget reports") body = policy.body_dict(request) run_id = run_id_from(request) conversation_id = body.get("conversation_id") @@ -113,13 +122,10 @@ def hashes_of(approval: dict[str, Any]) -> set[str]: 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).""" + """The conversation a trusted Worker bound to this run in its budget document (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 @@ -162,6 +168,7 @@ def matching_approval(store: TenantStore, run_id: str, capability: str, argument def gate(request: Request) -> Response: """``POST /hux/v1/runs/{id}/gate``: may this side effect proceed right now?""" + policy.require_worker(request.identity, "gate checks") body = policy.body_dict(request) run_id = run_id_from(request) capability = body.get("capability") @@ -193,14 +200,12 @@ 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).""" +def stop_outcome(body: dict[str, Any]) -> tuple[str, str]: + """Return the cancellation outcome proven by the gateway's 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" @@ -211,13 +216,14 @@ def stop(request: Request) -> Response: stop that really cancels or finds the run complete supersedes it with a revision bump; an identical repeat still replays (F8). """ + policy.require_worker(request.identity, "stop receipts") 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) + outcome, reason = stop_outcome(body) with request.store.lock(RECEIPTS): 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"): diff --git a/dockerfiles/hermes-hux-foundation/hux/flags.py b/dockerfiles/hermes-hux-foundation/hux/flags.py index b37a273f..32fa6b47 100644 --- a/dockerfiles/hermes-hux-foundation/hux/flags.py +++ b/dockerfiles/hermes-hux-foundation/hux/flags.py @@ -64,11 +64,25 @@ class Flags: def __init__(self, environ: Mapping[str, str] | None = None) -> None: self._environ = dict(os.environ if environ is None else environ) self._registry = flag_registry() + self._route_cards = frozenset(card for card, routes in CARD_ROUTES.items() if routes) + + def bind_routes(self, routes: Mapping[str, set[str]]) -> None: + """Bind capability flags to the route templates actually registered by this process.""" + unknown = set(routes) - set(self._registry) + if unknown: + raise ValueError(f"routes registered for unknown cards: {sorted(unknown)}") + for card, actual in routes.items(): + undeclared = actual - set(CARD_ROUTES.get(card, [])) + if undeclared: + raise ValueError(f"undeclared routes for {card}: {sorted(undeclared)}") + self._route_cards = frozenset( + card for card, declared in CARD_ROUTES.items() if declared and set(declared) == routes.get(card, set()) + ) def enabled(self, card: str) -> bool: """True only for a route-backed card whose configured flag chain is on.""" entry = self._registry.get(card) - return bool(entry) and bool(CARD_ROUTES.get(card)) and flag_enabled(entry["flag"], self._environ) + return bool(entry) and card in self._route_cards and flag_enabled(entry["flag"], self._environ) def require(self, card: str) -> None: """Raise FlagOff unless the card is enabled.""" diff --git a/dockerfiles/hermes-hux-foundation/hux/http.py b/dockerfiles/hermes-hux-foundation/hux/http.py index a541566a..5e345fe0 100644 --- a/dockerfiles/hermes-hux-foundation/hux/http.py +++ b/dockerfiles/hermes-hux-foundation/hux/http.py @@ -28,6 +28,7 @@ from hux.identity import Identity, resolve from hux.store import TenantStore MAX_BODY_BYTES = 1024 * 1024 +MAX_QUERY_FIELDS = 64 DEFAULT_REQUEST_TIMEOUT_SECONDS = 10.0 DEFAULT_READS_PER_MINUTE = 300 DEFAULT_WRITES_PER_MINUTE = 30 @@ -161,6 +162,13 @@ class Router: """Register a handler; ``action`` is the audit action name (family.verb), ``max_body`` its byte cap.""" self.routes.append(Route(method, template, card, action, handler, max_body)) + def bind_capability_routes(self) -> None: + """Make feature negotiation depend on the route templates this process really registered.""" + routes: dict[str, set[str]] = {} + for route in self.routes: + routes.setdefault(route.card, set()).add(route.template) + self.flags.bind_routes(routes) + def match(self, method: str, path: str) -> tuple[Route | None, dict[str, str], bool]: """Return (route, params, path_known).""" known = False @@ -180,37 +188,44 @@ class Router: def dispatch(self, method: str, raw_path: str, headers: Mapping[str, str], body: bytes) -> Response: """Run the full pipeline and never raise.""" parts = urlsplit(raw_path) - query = {k: v[-1] for k, v in parse_qs(parts.query).items()} + route: Route | None = None try: identity = resolve(headers, self.environ) store = TenantStore(self.data_root, identity) except HuxError as error: return Response(error.status, error.record()) - route, params, known = self.match(method, parts.path) - if route is None: - error = Invalid("method not allowed") if known else NotFound("no such route") - audit.record(store, identity, "http.route", parts.path, "not_found", error.message) - return Response(405 if known else 404, error.record()) try: + retry_after = self.rate_limiter.check(identity.subject, method) + if retry_after is not None: + raise RateLimited(retry_after) + route, params, known = self.match(method, parts.path) + if route is None: + error = Invalid("method not allowed") if known else NotFound("no such route") + audit.record(store, identity, "http.route", parts.path, "not_found", error.message) + return Response(405 if known else 404, error.record()) # 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") - retry_after = self.rate_limiter.check(identity.subject, method) - if retry_after is not None: - raise RateLimited(retry_after) self.flags.require(route.card) + try: + parsed_query = parse_qs(parts.query, max_num_fields=MAX_QUERY_FIELDS) + except ValueError as error: + raise Invalid("query has too many fields") from error + query = {key: values[-1] for key, values in parsed_query.items()} payload = self._decode(body, route.max_body) request = Request(method, parts.path, params, query, headers, payload, identity, store, self.flags, self.build) response = route.handler(request) except HuxError as error: 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) + action = route.action if route is not None else "http.route" + audit.record(store, identity, action, parts.path, outcome, error.message) headers = {"Retry-After": str(error.retry_after)} if isinstance(error, RateLimited) else {} return Response(error.status, error.record(), headers) 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) + action = route.action if route is not None else "http.route" + audit.record(store, identity, action, parts.path, "deny", error.message) return Response(error.status, error.record()) return response @@ -274,6 +289,7 @@ def make_handler(router: Router) -> type[BaseHTTPRequestHandler]: self.send_response(response.status) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") for key, value in response.headers.items(): self.send_header(key, value) self.end_headers() @@ -286,6 +302,7 @@ def make_handler(router: Router) -> type[BaseHTTPRequestHandler]: self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") for key, value in response.headers.items(): self.send_header(key, value) self.end_headers() diff --git a/dockerfiles/hermes-hux-foundation/hux/identity.py b/dockerfiles/hermes-hux-foundation/hux/identity.py index c13a6f10..4a256d3c 100644 --- a/dockerfiles/hermes-hux-foundation/hux/identity.py +++ b/dockerfiles/hermes-hux-foundation/hux/identity.py @@ -11,6 +11,8 @@ from __future__ import annotations import hmac import os import re +import stat +import tempfile from dataclasses import dataclass from collections.abc import Mapping from pathlib import Path @@ -29,6 +31,8 @@ SURFACES = ("chat", "worker", "telegram", "voice", "api") TRUSTS = ("router", "relay", "worker") KEY_ENV = {"router": "HUX_ROUTER_KEY", "relay": "HUX_RELAY_KEY", "worker": "HUX_WORKER_KEY"} MAX_KEY_BYTES = 4096 +MAX_SUBJECT_BYTES = 128 +SUBJECT_BINDING_ENV = "HUX_SUBJECT_BINDING_FILE" @dataclass(frozen=True) @@ -58,7 +62,11 @@ def _expected_key(environ: Mapping[str, str], trust: str) -> str: key_file = environ.get(f"{name}_FILE", "") if key_file: try: - data = Path(key_file).read_bytes() + path = Path(key_file) + mode = path.stat().st_mode + if not stat.S_ISREG(mode) or stat.S_IMODE(mode) != 0o400: + return "" + data = path.read_bytes() except OSError: return "" if len(data) > MAX_KEY_BYTES: @@ -70,6 +78,84 @@ def _expected_key(environ: Mapping[str, str], trust: str) -> str: return environ.get(name, "") +def _read_subject_binding(path: Path) -> str | None: + """Read a complete, permission-constrained binding without following links.""" + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except FileNotFoundError: + return None + except OSError as error: + raise Unauthorized("subject binding is unavailable") from error + try: + mode = os.fstat(descriptor).st_mode + if not stat.S_ISREG(mode) or stat.S_IMODE(mode) not in {0o400, 0o440}: + raise Unauthorized("subject binding is invalid") + with os.fdopen(descriptor, "rb", closefd=False) as binding: + data = binding.read(MAX_SUBJECT_BYTES + 1) + except OSError as error: + raise Unauthorized("subject binding is unavailable") from error + finally: + os.close(descriptor) + if not data or len(data) > MAX_SUBJECT_BYTES: + raise Unauthorized("subject binding is invalid") + try: + value = data.decode("utf-8", errors="strict").strip() + except UnicodeDecodeError as error: + raise Unauthorized("subject binding is invalid") from error + if not SUBJECT_RE.fullmatch(value): + raise Unauthorized("subject binding is invalid") + return value + + +def _publish_subject_binding(path: Path, subject: str) -> None: + """Publish an immutable first-writer binding; concurrent writers never see partial data.""" + descriptor = -1 + temporary = "" + try: + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + os.fchmod(descriptor, 0o440) + with os.fdopen(descriptor, "wb", closefd=False) as binding: + binding.write((subject + "\n").encode("utf-8")) + binding.flush() + os.fsync(descriptor) + os.close(descriptor) + descriptor = -1 + try: + os.link(temporary, path, follow_symlinks=False) + except FileExistsError: + pass + except OSError as error: + raise Unauthorized("subject binding is unavailable") from error + finally: + if descriptor >= 0: + os.close(descriptor) + if temporary: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + except OSError: + # The binding decision is still verified by a fresh read below. + pass + + +def _enforce_subject_binding(environ: Mapping[str, str], trust: str, subject: str) -> None: + """Bind from an authenticated edge hop, then require every caller to match.""" + raw_path = environ.get(SUBJECT_BINDING_ENV, "") + if not raw_path: + return + path = Path(raw_path) + bound = _read_subject_binding(path) + if bound is None: + if trust == "worker": + raise Unauthorized("worker subject is not bound") + _publish_subject_binding(path, subject) + bound = _read_subject_binding(path) + if bound is None or not hmac.compare_digest(bound, subject): + raise Unauthorized("subject does not match trusted binding") + + def resolve(headers: Mapping[str, str], environ: Mapping[str, str] | None = None) -> Identity: """Build an Identity from request headers or raise Unauthorized. @@ -96,4 +182,5 @@ def resolve(headers: Mapping[str, str], environ: Mapping[str, str] | None = None raise Unauthorized(f"{trust} key missing or wrong") if trust == "router" and surface == "worker": raise Unauthorized("worker surface needs worker trust") + _enforce_subject_binding(environ, trust, subject) return Identity(slot, subject, surface, trust) diff --git a/dockerfiles/hermes-hux-foundation/hux/policy.py b/dockerfiles/hermes-hux-foundation/hux/policy.py index ca6b1172..eedeb5ff 100644 --- a/dockerfiles/hermes-hux-foundation/hux/policy.py +++ b/dockerfiles/hermes-hux-foundation/hux/policy.py @@ -11,6 +11,7 @@ this module registers their routes so the family stays one card. from __future__ import annotations import hashlib +import json from datetime import datetime, timedelta, timezone from typing import Any @@ -72,6 +73,12 @@ def require_human(identity: Identity, what: str) -> None: raise Forbidden(f"{what} only from a human surface") +def require_worker(identity: Identity, what: str) -> None: + """Forbidden unless the separately keyed Worker gateway is making the call.""" + if identity.trust != "worker" or identity.surface != "worker": + raise Forbidden(f"{what} only from worker trust") + + def actor_for(identity: Identity) -> dict[str, str]: """The actor a record attributes to this caller: humans are users, hops are system.""" if is_human(identity): @@ -258,11 +265,20 @@ def load_approval(store: TenantStore, approval_id: str) -> dict[str, Any]: return refresh(store, store.get(APPROVALS, check_id(approval_id))) -def replay(store: TenantStore, key: str) -> dict[str, Any] | None: - """The approval an Idempotency-Key already created, if any.""" +def request_fingerprint(body: dict[str, Any]) -> str: + """Canonical digest that binds an Idempotency-Key to exactly one approval request.""" + canonical = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + return "sha256:" + hashlib.sha256(canonical).hexdigest() + + +def replay(store: TenantStore, key: str, fingerprint: str) -> dict[str, Any] | None: + """The approval an Idempotency-Key already created, rejecting a changed request body.""" for row in store.read(APPROVALS, "idempotency"): if row["key"] == key: - return load_approval(store, row["id"]) + record = load_approval(store, row["id"]) + if record.get("_request_fingerprint") != fingerprint: + raise Conflict("Idempotency-Key was already used for a different approval request") + return record return None @@ -276,36 +292,39 @@ def resolve_request(policy: dict[str, Any], capability: str, external: bool) -> def create_approval(request: Request) -> Response: """``POST /hux/v1/approvals``: the agent hook asks before a gated action.""" + require_worker(request.identity, "approval requests") body = body_dict(request) key = request.idempotency_key() - if key: - existing = replay(request.store, key) - if existing is not None: - request.audit("approvals.create", existing["id"], reason="replayed") - return Response(200, public(existing)) conversation_id = check_id(body.get("conversation_id")) capability = body.get("capability") if capability not in rules.CAPABILITIES: raise Invalid("unknown capability") from hux import budgets # lazy: budgets imports this module - bound_conversation = budgets.run_conversation(request.store, str(body.get("run_id", ""))) - state = budgets.budget_state(request.store, request.identity, str(body.get("run_id", "")), conversation_id) + run_id = budgets.checked_run_id(body.get("run_id")) + bound_conversation = budgets.run_conversation(request.store, run_id) + if bound_conversation != conversation_id: + raise Invalid("run is not authoritatively bound to this conversation") + 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)") + fingerprint = request_fingerprint(body) + if key: + with request.store.lock(APPROVALS): + existing = replay(request.store, key, fingerprint) + if existing is not None: + request.audit("approvals.create", existing["id"], reason="replayed") + return Response(200, public(existing)) + state = budgets.budget_state(request.store, request.identity, run_id, conversation_id) if state["exhausted"]: emit(request.store, request.identity, conversation_id, "budget.exhausted", f"Budget exhausted: {', '.join(state['exhausted'])}", run_id=state["run_id"]) raise BudgetExhausted("run budget exhausted", state["exhausted"]) 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) - base_decision = resolve_request({**policy, "grants": []}, capability, external) - if decision == "allow" and base_decision != "allow" and bound_conversation != conversation_id: - decision = "ask" stamp = now() record: dict[str, Any] = { - "schema": "hux.approval.v1", "id": new_id("apr"), "run_id": body.get("run_id"), "conversation_id": conversation_id, + "schema": "hux.approval.v1", "id": new_id("apr"), "run_id": run_id, "conversation_id": conversation_id, "capability": capability, "request": {**req, "external": external}, "status": {"allow": "approved", "deny": "denied", "ask": "pending"}[decision], "requested_at": iso(stamp), "expires_at": iso(stamp + APPROVAL_TTL), @@ -314,7 +333,13 @@ def create_approval(request: Request) -> Response: record["decision"] = {"choice": "once" if decision == "allow" else "deny", "by": {"type": "system", "id": "policy"}, "at": iso(stamp)} if key: record["idempotency_key"] = key + record["_request_fingerprint"] = fingerprint with request.store.lock(APPROVALS): + if key: + existing = replay(request.store, key, fingerprint) + if existing is not None: + request.audit("approvals.create", existing["id"], reason="replayed") + return Response(200, public(existing)) stored = request.store.put(APPROVALS, checked(record)) if key: request.store.append(APPROVALS, "idempotency", {"key": key, "id": stored["id"]}) diff --git a/dockerfiles/hermes-hux-foundation/hux/privacy.py b/dockerfiles/hermes-hux-foundation/hux/privacy.py index de87b666..9291350b 100644 --- a/dockerfiles/hermes-hux-foundation/hux/privacy.py +++ b/dockerfiles/hermes-hux-foundation/hux/privacy.py @@ -267,7 +267,11 @@ PAGE_LIMIT = 200 def get_conversation_privacy(request: Request) -> Response: """``GET /hux/v1/conversations/{id}/privacy``: forget/disable state and topics, so the hook can stop proposing memory early.""" + from hux import events + conversation_id = check_id(request.params["id"]) + if not events.conversation_known(request.store, conversation_id): + raise NotFound("conversation not found") state = conversation_state(request.store, conversation_id) mode = None try: diff --git a/dockerfiles/hermes-hux-foundation/hux/server.py b/dockerfiles/hermes-hux-foundation/hux/server.py index a22a4eff..ee2c5476 100644 --- a/dockerfiles/hermes-hux-foundation/hux/server.py +++ b/dockerfiles/hermes-hux-foundation/hux/server.py @@ -17,14 +17,12 @@ FAMILIES = ("foundation", "events", "memory", "privacy", "organization", "artifa def build_router(data_root: Path, environ: dict[str, str] | None = None) -> Router: - """Create a router with every family module that is present registered.""" + """Create a router with every required family registered and route-bound to its flag.""" router = Router(data_root, environ) for name in FAMILIES: - try: - module = importlib.import_module(f"hux.{name}") - except ModuleNotFoundError: - continue + module = importlib.import_module(f"hux.{name}") module.register(router) + router.bind_capability_routes() return router diff --git a/dockerfiles/hermes-worker-hux/hux_hook/client.py b/dockerfiles/hermes-worker-hux/hux_hook/client.py index b0c232c2..6188069e 100644 --- a/dockerfiles/hermes-worker-hux/hux_hook/client.py +++ b/dockerfiles/hermes-worker-hux/hux_hook/client.py @@ -10,12 +10,17 @@ own message only. from __future__ import annotations import http.client +import hmac import json +import os +import re +import stat import threading import urllib.error import urllib.parse import urllib.request from collections.abc import Mapping +from pathlib import Path from typing import Any HEADER_SLOT = "X-Hermes-Tenant-Identity" @@ -26,7 +31,10 @@ HEADER_KEY = "X-Hux-Relay-Key" DEFAULT_BASE_URL = "http://127.0.0.1:8790" MESSAGE_MAX = 280 MAX_RESPONSE_BYTES = 4 * 1024 * 1024 +MAX_KEY_BYTES = 4096 +MAX_SUBJECT_BYTES = 128 LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1"}) +SUBJECT_RE = re.compile(r"^usr_[0-9a-f]{16,64}$") class _RejectRedirect(urllib.request.HTTPRedirectHandler): @@ -70,6 +78,55 @@ def _validated_path(path: str) -> str: return path +def _key_from_file(key_file: str | Path) -> str: + """Read one 0400 regular-file credential without accepting weak permissions or unbounded data.""" + path = Path(key_file) + try: + mode = path.stat().st_mode + if not stat.S_ISREG(mode) or stat.S_IMODE(mode) != 0o400: + raise ValueError("HUX key file must be a 0400 regular file") + data = path.read_bytes() + if not data or len(data) > MAX_KEY_BYTES: + raise ValueError("HUX key file is empty or oversized") + value = data.decode("utf-8", errors="strict").strip() + if not value: + raise ValueError("HUX key file is empty") + return value + except OSError as error: + raise ValueError("HUX key file is unavailable") from error + except UnicodeDecodeError as error: + raise ValueError("HUX key file is not UTF-8") from error + + +def _subject_from_file(subject_file: str | Path) -> str: + """Read one router-published subject binding from a read-only shared file.""" + path = Path(subject_file) + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as error: + raise ValueError("HUX subject file is unavailable") from error + try: + mode = os.fstat(descriptor).st_mode + if not stat.S_ISREG(mode) or stat.S_IMODE(mode) not in {0o400, 0o440}: + raise ValueError("HUX subject file must be a 0400 or 0440 regular file") + with os.fdopen(descriptor, "rb", closefd=False) as subject_stream: + data = subject_stream.read(MAX_SUBJECT_BYTES + 1) + except OSError as error: + raise ValueError("HUX subject file is unavailable") from error + finally: + os.close(descriptor) + if not data or len(data) > MAX_SUBJECT_BYTES: + raise ValueError("HUX subject file is empty or oversized") + try: + value = data.decode("utf-8", errors="strict").strip() + except UnicodeDecodeError as error: + raise ValueError("HUX subject file is not UTF-8") from error + if not SUBJECT_RE.fullmatch(value): + raise ValueError("HUX subject file is malformed") + return value + + class HuxServiceError(Exception): """The service answered with a ``hux.error.v1`` body (or a non-JSON failure).""" @@ -115,14 +172,24 @@ class HuxClient: """ def __init__(self, base_url: str = DEFAULT_BASE_URL, identity: Mapping[str, str] | None = None, - key: str | None = None, timeout: float = 5) -> None: + key: str | None = None, timeout: float = 5, *, key_file: str | Path | None = None, + subject_file: str | Path | None = None) -> None: identity = dict(identity or {}) + configured_subject_file = subject_file if subject_file is not None else os.environ.get("HUX_SUBJECT_FILE") + if configured_subject_file: + bound_subject = _subject_from_file(configured_subject_file) + asserted_subject = str(identity.get("subject", "")) + if asserted_subject and not hmac.compare_digest(asserted_subject, bound_subject): + raise ValueError("HUX identity subject conflicts with the trusted binding") + identity["subject"] = bound_subject self.base_url = _validated_base_url(base_url) self.identity = { "tenant_slot": str(identity.get("tenant_slot", "")), "subject": str(identity.get("subject", "")), "surface": str(identity.get("surface", "worker")), "trust": str(identity.get("trust", "worker")), } - self._key = key + if key is not None and key_file is not None: + raise ValueError("set either key or key_file, not both") + self._key = _key_from_file(key_file) if key_file is not None else key if isinstance(timeout, bool) or not isinstance(timeout, int | float) or not 0.1 <= float(timeout) <= 30: raise ValueError("timeout must be between 0.1 and 30 seconds") self.timeout = float(timeout) diff --git a/testing/quality_contract.json b/testing/quality_contract.json index d28d773f..1a01282c 100644 --- a/testing/quality_contract.json +++ b/testing/quality_contract.json @@ -104,15 +104,28 @@ "scripts/ops/hermes_handoff_rules.py", "scripts/ops/hermes_handoff_run.py", "testing/quality_handoff_mutation.py", + "dockerfiles/hermes-hux-foundation/hux/__init__.py", + "dockerfiles/hermes-hux-foundation/hux/artifacts.py", + "dockerfiles/hermes-hux-foundation/hux/audit.py", "dockerfiles/hermes-hux-foundation/hux/budgets.py", "dockerfiles/hermes-hux-foundation/hux/contracts.py", + "dockerfiles/hermes-hux-foundation/hux/diffs.py", "dockerfiles/hermes-hux-foundation/hux/errors.py", + "dockerfiles/hermes-hux-foundation/hux/events.py", "dockerfiles/hermes-hux-foundation/hux/flags.py", + "dockerfiles/hermes-hux-foundation/hux/foundation.py", "dockerfiles/hermes-hux-foundation/hux/http.py", "dockerfiles/hermes-hux-foundation/hux/identity.py", + "dockerfiles/hermes-hux-foundation/hux/memory.py", + "dockerfiles/hermes-hux-foundation/hux/organization.py", "dockerfiles/hermes-hux-foundation/hux/policy.py", + "dockerfiles/hermes-hux-foundation/hux/privacy.py", "dockerfiles/hermes-hux-foundation/hux/redaction.py", + "dockerfiles/hermes-hux-foundation/hux/research.py", "dockerfiles/hermes-hux-foundation/hux/rules.py", + "dockerfiles/hermes-hux-foundation/hux/server.py", + "dockerfiles/hermes-hux-foundation/hux/store.py", + "dockerfiles/hermes-worker-hux/hux_hook/__init__.py", "dockerfiles/hermes-worker-hux/hux_hook/client.py", "dockerfiles/hermes-worker-hux/hux_hook/hooks.py" ], @@ -189,15 +202,28 @@ "scripts/ops/hermes_handoff_redaction.py", "scripts/ops/hermes_handoff_rules.py", "scripts/ops/hermes_handoff_run.py", + "dockerfiles/hermes-hux-foundation/hux/__init__.py", + "dockerfiles/hermes-hux-foundation/hux/artifacts.py", + "dockerfiles/hermes-hux-foundation/hux/audit.py", "dockerfiles/hermes-hux-foundation/hux/budgets.py", "dockerfiles/hermes-hux-foundation/hux/contracts.py", + "dockerfiles/hermes-hux-foundation/hux/diffs.py", "dockerfiles/hermes-hux-foundation/hux/errors.py", + "dockerfiles/hermes-hux-foundation/hux/events.py", "dockerfiles/hermes-hux-foundation/hux/flags.py", + "dockerfiles/hermes-hux-foundation/hux/foundation.py", "dockerfiles/hermes-hux-foundation/hux/http.py", "dockerfiles/hermes-hux-foundation/hux/identity.py", + "dockerfiles/hermes-hux-foundation/hux/memory.py", + "dockerfiles/hermes-hux-foundation/hux/organization.py", "dockerfiles/hermes-hux-foundation/hux/policy.py", + "dockerfiles/hermes-hux-foundation/hux/privacy.py", "dockerfiles/hermes-hux-foundation/hux/redaction.py", + "dockerfiles/hermes-hux-foundation/hux/research.py", "dockerfiles/hermes-hux-foundation/hux/rules.py", + "dockerfiles/hermes-hux-foundation/hux/server.py", + "dockerfiles/hermes-hux-foundation/hux/store.py", + "dockerfiles/hermes-worker-hux/hux_hook/__init__.py", "dockerfiles/hermes-worker-hux/hux_hook/client.py", "dockerfiles/hermes-worker-hux/hux_hook/hooks.py" ], @@ -365,15 +391,28 @@ "ci/scripts/semgrep_report.py", "testing/quality_coverage.py", "testing/quality_handoff_mutation.py", + "dockerfiles/hermes-hux-foundation/hux/__init__.py", + "dockerfiles/hermes-hux-foundation/hux/artifacts.py", + "dockerfiles/hermes-hux-foundation/hux/audit.py", "dockerfiles/hermes-hux-foundation/hux/budgets.py", "dockerfiles/hermes-hux-foundation/hux/contracts.py", + "dockerfiles/hermes-hux-foundation/hux/diffs.py", "dockerfiles/hermes-hux-foundation/hux/errors.py", + "dockerfiles/hermes-hux-foundation/hux/events.py", "dockerfiles/hermes-hux-foundation/hux/flags.py", + "dockerfiles/hermes-hux-foundation/hux/foundation.py", "dockerfiles/hermes-hux-foundation/hux/http.py", "dockerfiles/hermes-hux-foundation/hux/identity.py", + "dockerfiles/hermes-hux-foundation/hux/memory.py", + "dockerfiles/hermes-hux-foundation/hux/organization.py", "dockerfiles/hermes-hux-foundation/hux/policy.py", + "dockerfiles/hermes-hux-foundation/hux/privacy.py", "dockerfiles/hermes-hux-foundation/hux/redaction.py", + "dockerfiles/hermes-hux-foundation/hux/research.py", "dockerfiles/hermes-hux-foundation/hux/rules.py", + "dockerfiles/hermes-hux-foundation/hux/server.py", + "dockerfiles/hermes-hux-foundation/hux/store.py", + "dockerfiles/hermes-worker-hux/hux_hook/__init__.py", "dockerfiles/hermes-worker-hux/hux_hook/client.py", "dockerfiles/hermes-worker-hux/hux_hook/hooks.py" ], @@ -460,15 +499,28 @@ "scripts/ops/hermes_handoff_rules.py", "scripts/ops/hermes_handoff_run.py", "testing/quality_handoff_mutation.py", + "dockerfiles/hermes-hux-foundation/hux/__init__.py", + "dockerfiles/hermes-hux-foundation/hux/artifacts.py", + "dockerfiles/hermes-hux-foundation/hux/audit.py", "dockerfiles/hermes-hux-foundation/hux/budgets.py", "dockerfiles/hermes-hux-foundation/hux/contracts.py", + "dockerfiles/hermes-hux-foundation/hux/diffs.py", "dockerfiles/hermes-hux-foundation/hux/errors.py", + "dockerfiles/hermes-hux-foundation/hux/events.py", "dockerfiles/hermes-hux-foundation/hux/flags.py", + "dockerfiles/hermes-hux-foundation/hux/foundation.py", "dockerfiles/hermes-hux-foundation/hux/http.py", "dockerfiles/hermes-hux-foundation/hux/identity.py", + "dockerfiles/hermes-hux-foundation/hux/memory.py", + "dockerfiles/hermes-hux-foundation/hux/organization.py", "dockerfiles/hermes-hux-foundation/hux/policy.py", + "dockerfiles/hermes-hux-foundation/hux/privacy.py", "dockerfiles/hermes-hux-foundation/hux/redaction.py", + "dockerfiles/hermes-hux-foundation/hux/research.py", "dockerfiles/hermes-hux-foundation/hux/rules.py", + "dockerfiles/hermes-hux-foundation/hux/server.py", + "dockerfiles/hermes-hux-foundation/hux/store.py", + "dockerfiles/hermes-worker-hux/hux_hook/__init__.py", "dockerfiles/hermes-worker-hux/hux_hook/client.py", "dockerfiles/hermes-worker-hux/hux_hook/hooks.py" ] diff --git a/testing/tests/test_hermes_hux_contract_foundation.py b/testing/tests/test_hermes_hux_contract_foundation.py index c9f513b2..76ae1bc9 100644 --- a/testing/tests/test_hermes_hux_contract_foundation.py +++ b/testing/tests/test_hermes_hux_contract_foundation.py @@ -89,6 +89,7 @@ def test_router_key_file_failures_are_unauthorized(tmp_path, payload): key_file = tmp_path / "router-key" if payload is not None: key_file.write_bytes(payload) + key_file.chmod(0o400) with pytest.raises(errors.Unauthorized) as denied: identity.resolve(HEADERS, {"HUX_ROUTER_KEY_FILE": str(key_file)}) assert "x" * 32 not in str(denied.value) and "\\xff" not in str(denied.value) diff --git a/testing/tests/test_hermes_hux_contract_hook.py b/testing/tests/test_hermes_hux_contract_hook.py index a71687a1..6678bd36 100644 --- a/testing/tests/test_hermes_hux_contract_hook.py +++ b/testing/tests/test_hermes_hux_contract_hook.py @@ -72,12 +72,66 @@ def test_headers_match_the_service_vocabulary(): assert HuxClient().identity == {"tenant_slot": "", "subject": "", "surface": "worker", "trust": "worker"} +def test_client_loads_only_a_bounded_0400_key_file(tmp_path): + """The hook can consume its projected worker key without an inline environment secret.""" + key_file = tmp_path / "worker-key" + key_file.write_text("wk\n") + key_file.chmod(0o400) + client = HuxClient("http://127.0.0.1:1", WORKER, key_file=key_file) + assert client.headers()[client_mod.HEADER_KEY] == "wk" + with pytest.raises(ValueError): + HuxClient("http://127.0.0.1:1", WORKER, key="wk", key_file=key_file) + key_file.chmod(0o444) + with pytest.raises(ValueError): + HuxClient("http://127.0.0.1:1", WORKER, key_file=key_file) + + +def test_client_loads_router_bound_subject_from_file(tmp_path, monkeypatch): + """The worker gets its subject only from the shared router binding and rejects conflicts.""" + subject_file = tmp_path / "subject" + subject_file.write_text(SUBJECT + "\n") + subject_file.chmod(0o440) + unbound_worker = {**WORKER, "subject": ""} + assert HuxClient(identity=unbound_worker, subject_file=subject_file).identity["subject"] == SUBJECT + assert HuxClient(identity=WORKER, subject_file=subject_file).identity["subject"] == SUBJECT + with pytest.raises(ValueError, match="conflicts"): + HuxClient(identity={**WORKER, "subject": OTHER["subject"]}, subject_file=subject_file) + monkeypatch.setenv("HUX_SUBJECT_FILE", str(subject_file)) + assert HuxClient(identity=unbound_worker).identity["subject"] == SUBJECT + + +def test_client_subject_file_failures_are_closed(tmp_path): + """Unavailable, linked, weak, malformed, oversized, and non-UTF-8 subject files are rejected.""" + with pytest.raises(ValueError, match="unavailable"): + HuxClient(identity=WORKER, subject_file=tmp_path / "missing") + for name, payload, mode, message in ( + ("empty", b"", 0o440, "empty or oversized"), + ("oversized", b"x" * (client_mod.MAX_SUBJECT_BYTES + 1), 0o440, "empty or oversized"), + ("unicode", b"\xff", 0o440, "not UTF-8"), + ("malformed", b"brad@example.test", 0o440, "malformed"), + ("weak", SUBJECT.encode(), 0o444, "0400 or 0440"), + ): + subject_file = tmp_path / name + subject_file.write_bytes(payload) + subject_file.chmod(mode) + with pytest.raises(ValueError, match=message): + HuxClient(identity=WORKER, subject_file=subject_file) + valid = tmp_path / "valid" + valid.write_text(SUBJECT) + valid.chmod(0o400) + linked = tmp_path / "linked" + linked.symlink_to(valid) + with pytest.raises(ValueError, match="unavailable"): + HuxClient(identity=WORKER, subject_file=linked) + + def test_error_mapping_and_no_body_leak(live): """SO-07: a hux.error.v1 answer becomes HuxServiceError(status, code, message) and the body never appears in it.""" base, _ = live human = HuxClient(base, HUMAN, key="rk") + worker = HuxClient(base, WORKER, key="wk") with pytest.raises(HuxServiceError) as bad: - human.post("/hux/v1/approvals", {"conversation_id": "conv_0001abcd", "capability": "nope", "secret": CANARY}) + worker.post("/hux/v1/approvals", {"conversation_id": "conv_0001abcd", "capability": "nope", "secret": CANARY}) assert (bad.value.status, bad.value.code) == (400, "invalid") and CANARY not in str(bad.value) with pytest.raises(HuxServiceError) as unauth: HuxClient(base, WORKER, key="wrong").get("/hux/v1/capabilities") @@ -293,4 +347,4 @@ def test_library_is_stdlib_only_small_and_documented(): assert ast.get_docstring(node), f"{path.name}:{node.name}" if isinstance(node, ast.Import | ast.ImportFrom): root = (node.names[0].name if isinstance(node, ast.Import) else node.module or "").split(".")[0] - assert root in {"http", "json", "threading", "urllib", "collections", "typing", "hashlib", "re", "dataclasses", "hux_hook", "__future__"}, root + assert root in {"http", "hmac", "json", "os", "stat", "threading", "urllib", "collections", "pathlib", "typing", "hashlib", "re", "dataclasses", "hux_hook", "__future__"}, root diff --git a/testing/tests/test_hermes_hux_http_security.py b/testing/tests/test_hermes_hux_http_security.py index 57bff367..4863ab5e 100644 --- a/testing/tests/test_hermes_hux_http_security.py +++ b/testing/tests/test_hermes_hux_http_security.py @@ -9,12 +9,14 @@ import threading from http.client import HTTPConnection from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[2] FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation" if str(FOUNDATION) not in sys.path: sys.path.insert(0, str(FOUNDATION)) -from hux import contracts # noqa: E402 +from hux import contracts, errors, flags, identity # noqa: E402 from hux import http as hux_http # noqa: E402 from hux.http import DEFAULT_REQUEST_TIMEOUT_SECONDS, RateLimiter, Router, serve # noqa: E402 from hux.server import build_router # noqa: E402 @@ -38,6 +40,132 @@ def _start(router: Router): return server +def test_each_trust_uses_its_own_0400_key_file(tmp_path): + """Router, relay and worker credentials are distinct files; weak permissions fail closed.""" + files = {} + for trust in ("router", "relay", "worker"): + path = tmp_path / f"{trust}-key" + path.write_text(f"{trust}-secret\n") + path.chmod(0o400) + files[trust] = path + base = {key: value for key, value in HEADERS.items() if key != "X-Hux-Relay-Key"} + for trust, surface in (("router", "chat"), ("relay", "telegram"), ("worker", "worker")): + headers = {**base, "X-Hux-Trust": trust, "X-Hux-Surface": surface, "X-Hux-Relay-Key": f"{trust}-secret"} + env = {f"HUX_{trust.upper()}_KEY_FILE": str(files[trust])} + assert identity.resolve(headers, env).trust == trust + wrong = files["router" if trust != "router" else "worker"] + with pytest.raises(errors.Unauthorized): + identity.resolve(headers, {f"HUX_{trust.upper()}_KEY_FILE": str(wrong)}) + files["router"].chmod(0o440) + with pytest.raises(errors.Unauthorized): + identity.resolve({**base, "X-Hux-Relay-Key": "router-secret"}, {"HUX_ROUTER_KEY_FILE": str(files["router"])}) + + +def _trusted_headers(trust: str, subject: str = "usr_0123456789abcdef") -> dict[str, str]: + """Build headers for one separately keyed authenticated hop.""" + surfaces = {"router": "chat", "relay": "telegram", "worker": "worker"} + return { + **HEADERS, + "X-Hux-Subject": subject, + "X-Hux-Trust": trust, + "X-Hux-Surface": surfaces[trust], + "X-Hux-Relay-Key": f"{trust}-key", + } + + +def _binding_env(path: Path) -> dict[str, str]: + """Return the three-key environment used by subject-binding tests.""" + return { + "HUX_ROUTER_KEY": "router-key", + "HUX_RELAY_KEY": "relay-key", + "HUX_WORKER_KEY": "worker-key", + "HUX_SUBJECT_BINDING_FILE": str(path), + } + + +def test_edge_hop_binds_subject_before_worker_can_assert_it(tmp_path): + """Only a keyed router/relay may initialize the file; all later callers must match it.""" + binding = tmp_path / "subject" + environ = _binding_env(binding) + with pytest.raises(errors.Unauthorized, match="not bound"): + identity.resolve(_trusted_headers("worker"), environ) + assert identity.resolve(_trusted_headers("router"), environ).subject == "usr_0123456789abcdef" + assert binding.read_text().strip() == "usr_0123456789abcdef" + assert binding.stat().st_mode & 0o777 == 0o440 + assert identity.resolve(_trusted_headers("worker"), environ).trust == "worker" + other = "usr_fedcba9876543210" + for trust in ("router", "relay", "worker"): + with pytest.raises(errors.Unauthorized, match="does not match"): + identity.resolve(_trusted_headers(trust, other), environ) + + +def test_relay_can_initialize_binding_and_invalid_files_fail_closed(tmp_path): + """Relay is an authoritative edge; malformed, linked, weak, and unavailable files never authenticate.""" + relay_binding = tmp_path / "relay-subject" + assert identity.resolve(_trusted_headers("relay"), _binding_env(relay_binding)).trust == "relay" + for name, payload, mode in ( + ("empty", b"", 0o440), + ("oversized", b"x" * (identity.MAX_SUBJECT_BYTES + 1), 0o440), + ("unicode", b"\xff", 0o440), + ("malformed", b"brad@example.test", 0o440), + ("weak", b"usr_0123456789abcdef", 0o444), + ): + binding = tmp_path / name + binding.write_bytes(payload) + binding.chmod(mode) + with pytest.raises(errors.Unauthorized): + identity.resolve(_trusted_headers("router"), _binding_env(binding)) + link = tmp_path / "linked" + link.symlink_to(relay_binding) + with pytest.raises(errors.Unauthorized): + identity.resolve(_trusted_headers("router"), _binding_env(link)) + with pytest.raises(errors.Unauthorized, match="unavailable"): + identity.resolve(_trusted_headers("router"), _binding_env(tmp_path / "missing" / "subject")) + + +def test_concurrent_edge_binding_has_exactly_one_subject(tmp_path): + """First-writer publication is complete and immutable even when two edge hops race.""" + binding = tmp_path / "subject" + environ = _binding_env(binding) + barrier = threading.Barrier(2) + outcomes: list[tuple[str, bool]] = [] + + def bind(trust: str, subject: str) -> None: + barrier.wait() + try: + identity.resolve(_trusted_headers(trust, subject), environ) + except errors.Unauthorized: + outcomes.append((subject, False)) + else: + outcomes.append((subject, True)) + + attempts = ( + threading.Thread(target=bind, args=("router", "usr_0123456789abcdef")), + threading.Thread(target=bind, args=("relay", "usr_fedcba9876543210")), + ) + for attempt in attempts: + attempt.start() + for attempt in attempts: + attempt.join() + bound = binding.read_text().strip() + assert sorted(ok for _, ok in outcomes) == [False, True] + assert bound in {subject for subject, ok in outcomes if ok} + + +def test_capability_flags_are_bound_to_the_routes_really_registered(): + """A configured card stays off when a declared route is absent; undeclared routes fail startup.""" + all_routes = {card: set(routes) for card, routes in flags.CARD_ROUTES.items() if routes} + bound = flags.Flags({"HUX_FLAGS": ALL_ON}) + missing = {card: set(routes) for card, routes in all_routes.items()} + missing["HUX-01"].remove("/hux/v1/conversations/{id}/events/stream") + bound.bind_routes(missing) + assert not bound.enabled("HUX-01") and bound.enabled("HUX-11") + with pytest.raises(ValueError, match="unknown cards"): + bound.bind_routes({"HUX-99": {"/hux/v1/nope"}}) + with pytest.raises(ValueError, match="undeclared routes"): + bound.bind_routes({"HUX-11": {"/hux/v1/nope"}}) + + def _raw_request(address: tuple[str, int], request: bytes) -> bytes: """Send one HTTP/1.0 request and return its complete close-delimited response.""" with socket.create_connection(address, timeout=1) as client: @@ -162,8 +290,22 @@ def test_health_and_json_are_no_store_and_health_refuses_bodies(tmp_path): reply = conn.getresponse() reply.read() assert reply.getheader("Cache-Control") == "no-store" + assert reply.getheader("X-Content-Type-Options") == "nosniff" conn.request("GET", "/healthz", body=b"x", headers={"Content-Length": "1"}) assert conn.getresponse().status == 413 finally: server.shutdown() server.server_close() + + +def test_unknown_routes_are_limited_and_query_fields_are_bounded(tmp_path): + """Authenticated misses consume their bucket and oversized query maps fail as typed 400s.""" + router = _router(tmp_path, HUX_READS_PER_MINUTE="1") + missing = router.dispatch("GET", "/hux/v1/nope", HEADERS, b"") + limited = router.dispatch("GET", "/hux/v1/nope", HEADERS, b"") + assert missing.status == 404 + assert (limited.status, limited.body["code"]) == (429, "rate_limited") + roomy = _router(tmp_path / "roomy") + query = "&".join(f"x{index}=1" for index in range(hux_http.MAX_QUERY_FIELDS + 1)) + response = roomy.dispatch("GET", f"/hux/v1/capabilities?{query}", HEADERS, b"") + assert (response.status, response.body["code"]) == (400, "invalid") diff --git a/testing/tests/test_hermes_hux_policy_approvals.py b/testing/tests/test_hermes_hux_policy_approvals.py index 23423d57..b546a563 100644 --- a/testing/tests/test_hermes_hux_policy_approvals.py +++ b/testing/tests/test_hermes_hux_policy_approvals.py @@ -42,7 +42,12 @@ OTHER_HASH = "sha256:" + "cd" * 32 @pytest.fixture def router(tmp_path): - return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk", "HUX_RELAY_KEY": "rk"}) + instance = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk", "HUX_RELAY_KEY": "rk"}) + response = instance.dispatch( + "POST", "/hux/v1/runs/run_9f/budget", WORKER, json.dumps({"conversation_id": CONV}).encode() + ) + assert response.status == 200 + return instance @pytest.fixture @@ -116,8 +121,10 @@ def test_idempotency_key_returns_the_original(router): key = {"Idempotency-Key": "run_9f:approval:call-7"} status, first = call(router, "POST", "/hux/v1/approvals", request_body(), {**WORKER, **key}) assert status == 201 and first["idempotency_key"] == "run_9f:approval:call-7" - status, again = call(router, "POST", "/hux/v1/approvals", request_body("shell"), {**WORKER, **key}) + status, again = call(router, "POST", "/hux/v1/approvals", request_body(), {**WORKER, **key}) assert (status, again) == (200, first) + status, conflict = call(router, "POST", "/hux/v1/approvals", request_body("shell"), {**WORKER, **key}) + assert (status, conflict["code"]) == (409, "conflict") status, other = call(router, "POST", "/hux/v1/approvals", request_body(), {**WORKER, "Idempotency-Key": "run_9f:approval:call-8"}) assert status == 201 and other["id"] != first["id"] assert call(router, "GET", f"/hux/v1/approvals/{first['id']}")[1] == first @@ -138,7 +145,11 @@ def test_create_rejects_bad_bodies(router, body, status): # --- deciding ----------------------------------------------------------------------- def pending(router, capability="write_files", **kw): - status, body = call(router, "POST", "/hux/v1/approvals", request_body(capability, **kw), WORKER) + body = request_body(capability, **kw) + run_id = body["run_id"] + if budgets.run_conversation(router_store(router), run_id) is None: + assert call(router, "POST", f"/hux/v1/runs/{run_id}/budget", {"conversation_id": body["conversation_id"]}, WORKER)[0] == 200 + status, body = call(router, "POST", "/hux/v1/approvals", body, WORKER) assert status == 201 and body["status"] == "pending", body return body @@ -233,6 +244,9 @@ def test_exhausted_budget_blocks_new_approvals(router, events): assert (status, error["code"]) == (429, "budget_exhausted") and error["details"] == ["tool_calls_per_run"] assert [e["kind"] for e in events] == ["budget.exhausted", "budget.exhausted"] status, body = call(router, "POST", "/hux/v1/approvals", request_body("read_files", run_id="run_fresh"), WORKER) + assert (status, body["code"]) == (400, "invalid"), "an unbound run cannot create an approval record" + call(router, "POST", "/hux/v1/runs/run_fresh/budget", {"conversation_id": CONV}, WORKER) + status, body = call(router, "POST", "/hux/v1/approvals", request_body("read_files", run_id="run_fresh"), WORKER) assert (status, body["code"]) == (429, "budget_exhausted"), "run rotation cannot reset conversation spend" @@ -255,7 +269,7 @@ def test_gate_blocks_before_approval_and_releases_once_exactly_once(router, even status, again = gate(router) assert again["proceed"] is False and "already consumed" in again["reason"], "once is once (SO-36)" kinds = [e["kind"] for e in events] - assert kinds.count("side_effect.released") == 1 and kinds.count("side_effect.blocked") == 4, "the first block predates any approval, so no conversation is known" + assert kinds.count("side_effect.released") == 1 and kinds.count("side_effect.blocked") == 5 released = next(e for e in events if e["kind"] == "side_effect.released") assert released["evidence"] == [{"kind": "approval", "id": record["id"]}] and released["run_id"] == "run_9f" served = call(router, "GET", f"/hux/v1/approvals/{record['id']}")[1] @@ -271,7 +285,7 @@ def test_gate_session_is_reusable_within_the_conversation(router): assert gate(router, capability="shell", argument_hash=OTHER_HASH)[1]["proceed"] is True assert gate(router, run_id="run_later", capability="shell")[1]["proceed"] is False, "unknown runs inherit no approval" status, unbound = call(router, "POST", "/hux/v1/approvals", request_body("shell", run_id="run_unbound"), WORKER) - assert status == 201 and unbound["status"] == "pending", "a scoped grant needs a prior authoritative binding" + assert (status, unbound["code"]) == (400, "invalid"), "an unknown run cannot mint any approval record" call(router, "POST", "/hux/v1/runs/run_later/budget", {"conversation_id": CONV}, WORKER) assert gate(router, run_id="run_later", capability="shell")[1]["proceed"] is False, "the old approval record never spans runs" status, fresh = call(router, "POST", "/hux/v1/approvals", request_body("shell", run_id="run_later"), WORKER) @@ -308,7 +322,7 @@ def test_gate_rejects_bad_bodies_and_other_tenants(router): record = pending(router) call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"}) status, body = call(router, "POST", "/hux/v1/runs/run_9f/gate", {"capability": "write_files", "argument_hash": HASH}, OTHER) - assert status == 200 and body["proceed"] is False + assert (status, body["code"]) == (403, "forbidden") def test_events_module_absence_is_tolerated(router, monkeypatch): @@ -389,6 +403,8 @@ def test_conversation_budget_aggregates_across_runs_and_policy_revision_resets(r assert revised["revision"] == 2 reset = call(router, "GET", "/hux/v1/runs/run_b/budget", headers=WORKER)[1] assert reset["spent"]["tool_calls"] == 0 and reset["exhausted"] == [] + restarted = call(router, "POST", "/hux/v1/runs/run_b/budget", {"conversation_id": CONV, "tool_calls": 1}, WORKER)[1] + assert restarted["spent"]["tool_calls"] == 1, "the old epoch's per-run spend does not leak into the new epoch" def router_store(router): diff --git a/testing/tests/test_hermes_hux_policy_receipts.py b/testing/tests/test_hermes_hux_policy_receipts.py index a073bd24..a2c04288 100644 --- a/testing/tests/test_hermes_hux_policy_receipts.py +++ b/testing/tests/test_hermes_hux_policy_receipts.py @@ -28,6 +28,7 @@ SCHEMAS = contracts.load_all() HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"} OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"} WORKER = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"} +OTHER_WORKER = {**WORKER, "X-Hux-Subject": OTHER["X-Hux-Subject"]} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) CONV = "conv_0001abcd" @@ -117,25 +118,24 @@ def test_stop_writes_a_receipt_and_a_second_stop_returns_it(router, events, tmp_ 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.""" + """F8 / SO-41: browser callers cannot write receipts; only the Worker records the proven outcome.""" 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"] + assert (status, body["code"]) == (403, "forbidden") 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" + assert (status, again["code"]) == (403, "forbidden") 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"} + assert status == 201 and valid(done)["outcome"] == "cancelled" and "completed_at" in done + assert "conversation_id" not in done and done["requested_by"] == {"type": "system", "id": "worker"} tenant = store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"})) stored = tenant.get("receipts", done["id"]) - assert stored["revision"] == 2 and contracts.validate_record(stored, SCHEMAS) == [], "stored receipts carry revision (F11)" + assert stored["revision"] == 1 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)"] + assert rows == ["stop receipts only from worker trust", "stop receipts only from worker trust", "cancelled", "replayed"] + assert events == [], "a receipt without an authoritative conversation binding emits no conversation event" 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" + assert (status, body["code"]) == (403, "forbidden") def test_stop_outcomes_follow_the_process_registry(router): @@ -156,5 +156,5 @@ def test_stop_rejects_bad_bodies(router, body): def test_receipts_are_per_tenant(router): call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True}) - status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False}, OTHER) + status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False}, OTHER_WORKER) assert status == 201 and body["outcome"] == "failed_to_cancel", "same run id, other subject, its own receipt" diff --git a/testing/tests/test_hermes_hux_privacy_topics.py b/testing/tests/test_hermes_hux_privacy_topics.py index ee1d0036..9d0645a4 100644 --- a/testing/tests/test_hermes_hux_privacy_topics.py +++ b/testing/tests/test_hermes_hux_privacy_topics.py @@ -217,7 +217,7 @@ def test_conversation_privacy_state_route(tmp_path): assert status == 200 and state["memory_writes_allowed"] is True and state["mode"] == "thoughtful" assert state["forgotten"] is False and state["memory_disabled"] is False and state["topics"] == [] from hux import privacy, store, identity as ident_mod - tenant = store.TenantStore(router.data_root, ident_mod.resolve(HEADERS, {})) + tenant = store.TenantStore(router.data_root, ident_mod.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"})) privacy.mark_topic(tenant, conv["id"], "health") privacy.set_flag(tenant, conv["id"], "memory_disabled", True) state = call(router, "GET", f"/hux/v1/conversations/{conv['id']}/privacy")[1] @@ -228,7 +228,9 @@ def test_conversation_privacy_state_route(tmp_path): assert state["forgotten"] is True status, private, _ = call(router, "POST", "/hux/v1/conversations", body={"title": "p", "mode": "private"}) assert call(router, "GET", f"/hux/v1/conversations/{private['id']}/privacy")[1]["memory_writes_allowed"] is False - assert call(router, "GET", "/hux/v1/conversations/conv_unknown00/privacy")[1]["mode"] is None + status, unknown, _ = call(router, "GET", "/hux/v1/conversations/conv_unknown00/privacy") + assert (status, unknown["code"]) == (404, "not_found") assert call(router, "GET", "/hux/v1/conversations/bad%20id/privacy")[0] in (400, 404) other = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"} - assert call(router, "GET", f"/hux/v1/conversations/{conv['id']}/privacy", other)[1]["forgotten"] is False + status, cross_tenant, _ = call(router, "GET", f"/hux/v1/conversations/{conv['id']}/privacy", other) + assert (status, cross_tenant["code"]) == (404, "not_found") diff --git a/testing/tests/test_quality_contract.py b/testing/tests/test_quality_contract.py index c5c4ca67..c71f4c10 100644 --- a/testing/tests/test_quality_contract.py +++ b/testing/tests/test_quality_contract.py @@ -37,15 +37,28 @@ def test_hux_replacement_modules_are_quality_managed(): """The deleted prototype helpers are replaced by the production foundation and hook modules.""" contract = load_contract() expected = { + "dockerfiles/hermes-hux-foundation/hux/__init__.py", + "dockerfiles/hermes-hux-foundation/hux/artifacts.py", + "dockerfiles/hermes-hux-foundation/hux/audit.py", "dockerfiles/hermes-hux-foundation/hux/budgets.py", "dockerfiles/hermes-hux-foundation/hux/contracts.py", + "dockerfiles/hermes-hux-foundation/hux/diffs.py", "dockerfiles/hermes-hux-foundation/hux/errors.py", + "dockerfiles/hermes-hux-foundation/hux/events.py", "dockerfiles/hermes-hux-foundation/hux/flags.py", + "dockerfiles/hermes-hux-foundation/hux/foundation.py", "dockerfiles/hermes-hux-foundation/hux/http.py", "dockerfiles/hermes-hux-foundation/hux/identity.py", + "dockerfiles/hermes-hux-foundation/hux/memory.py", + "dockerfiles/hermes-hux-foundation/hux/organization.py", "dockerfiles/hermes-hux-foundation/hux/policy.py", + "dockerfiles/hermes-hux-foundation/hux/privacy.py", "dockerfiles/hermes-hux-foundation/hux/redaction.py", + "dockerfiles/hermes-hux-foundation/hux/research.py", "dockerfiles/hermes-hux-foundation/hux/rules.py", + "dockerfiles/hermes-hux-foundation/hux/server.py", + "dockerfiles/hermes-hux-foundation/hux/store.py", + "dockerfiles/hermes-worker-hux/hux_hook/__init__.py", "dockerfiles/hermes-worker-hux/hux_hook/client.py", "dockerfiles/hermes-worker-hux/hux_hook/hooks.py", }