diff --git a/dockerfiles/hermes-hux-foundation/hux/budgets.py b/dockerfiles/hermes-hux-foundation/hux/budgets.py index 8df1afd1..e86e903d 100644 --- a/dockerfiles/hermes-hux-foundation/hux/budgets.py +++ b/dockerfiles/hermes-hux-foundation/hux/budgets.py @@ -42,16 +42,28 @@ 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 ``hux.budget_state.v1`` for a run measured against the effective policy.""" + """Current state for a run, enforced against its conversation's policy epoch aggregate.""" 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} - conversation_id = conversation_id or stored.get("_conversation_id") + known_conversation = stored.get("_conversation_id") or run_conversation(store, run_id) + if known_conversation and conversation_id and known_conversation != conversation_id: + raise Invalid("run is already bound to another conversation") + conversation_id = known_conversation or conversation_id level, scope_id = ("conversation", conversation_id) if conversation_id else ("global", None) - limits = {k: v for k, v in policy.effective_policy(store, identity, level, scope_id)["budgets"].items() if k != "scope"} - spent = {k: int(stored["spent"].get(k, 0)) for k in SPEND_KEYS} + 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} + 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: + continue + for key in SPEND_KEYS: + aggregate[key] += int(record.get("spent", {}).get(key, 0)) return policy.checked({ - "schema": "hux.budget_state.v1", "run_id": run_id[:120], "spent": spent, "limits": limits, - "exhausted": exhausted(spent, limits), "_conversation_id": conversation_id, "_id": doc_id, + "schema": "hux.budget_state.v1", "run_id": run_id[:120], "spent": aggregate, "limits": limits, + "exhausted": exhausted(aggregate, limits), "_conversation_id": conversation_id, "_id": doc_id, + "_run_spent": run_spent, "_budget_epoch": epoch, }) @@ -78,8 +90,11 @@ def post_budget(request: Request) -> Response: with request.store.lock(BUDGETS): before = budget_state(request.store, request.identity, run_id, conversation_id) known = before["exhausted"] if request.store.exists(BUDGETS, before["_id"]) else [] - spent = {k: before["spent"][k] + increments[k] for k in SPEND_KEYS} - doc = {"id": before["_id"], "run_id": run_id, "spent": spent, "_conversation_id": before["_conversation_id"]} + spent = {k: before["_run_spent"][k] + increments[k] for k in SPEND_KEYS} + doc = { + "id": before["_id"], "run_id": run_id, "spent": spent, + "_conversation_id": before["_conversation_id"], "_budget_epoch": before["_budget_epoch"], + } request.store.put(BUDGETS, doc) after = budget_state(request.store, request.identity, run_id, conversation_id) request.audit("budgets.write", after["_id"]) @@ -111,11 +126,11 @@ def run_conversation(store: TenantStore, run_id: str) -> str | 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 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). + Every release uses an approval record created for this exact run. A + session/always grant may let a later run create a new approved record, + but the old record itself never crosses run ids. External effects also + require the same argument hash; ``once`` names exactly one hash (SO-36, + SO-37, SO-39). """ reason = "no approval for this run and capability" for record in store.scan(policy.APPROVALS): @@ -124,9 +139,7 @@ def matching_approval(store: TenantStore, run_id: str, capability: str, argument continue same_run = record["run_id"] == run_id choice = record.get("decision", {}).get("choice") - 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)): + if not same_run: continue if record["status"] != "approved": reason = f"approval {record['id']} is {record['status']}" @@ -233,4 +246,3 @@ def register_routes(router: Router) -> None: router.add("POST", "/hux/v1/runs/{id}/budget", policy.CARD, "budgets.write", post_budget) router.add("POST", "/hux/v1/runs/{id}/gate", policy.CARD, "gate.check", gate) router.add("POST", "/hux/v1/runs/{id}/stop", policy.CARD, "runs.stop", stop) - diff --git a/dockerfiles/hermes-hux-foundation/hux/errors.py b/dockerfiles/hermes-hux-foundation/hux/errors.py index 126fd3bf..12a098b8 100644 --- a/dockerfiles/hermes-hux-foundation/hux/errors.py +++ b/dockerfiles/hermes-hux-foundation/hux/errors.py @@ -70,6 +70,16 @@ class TooLarge(HuxError): status, code = 413, "too_large" +class RateLimited(HuxError): + """The caller exceeded the bounded per-subject request rate.""" + + status, code = 429, "rate_limited" + + def __init__(self, retry_after: int) -> None: + super().__init__("request rate limit exceeded") + self.retry_after = max(1, int(retry_after)) + + class ApprovalRequired(HuxError): """An action needs an approval record before it may proceed.""" diff --git a/dockerfiles/hermes-hux-foundation/hux/flags.py b/dockerfiles/hermes-hux-foundation/hux/flags.py index 300142ff..b37a273f 100644 --- a/dockerfiles/hermes-hux-foundation/hux/flags.py +++ b/dockerfiles/hermes-hux-foundation/hux/flags.py @@ -41,7 +41,7 @@ CARD_ROUTES: dict[str, list[str]] = { # 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"), + ("GET", "/hux/v1/capabilities"), ("GET", "/hux/v1/manifest"), ("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"), @@ -66,9 +66,9 @@ class Flags: self._registry = flag_registry() def enabled(self, card: str) -> bool: - """True when the card and its whole dependency chain are on.""" + """True only for a route-backed card whose configured flag chain is on.""" entry = self._registry.get(card) - return bool(entry) and flag_enabled(entry["flag"], self._environ) + return bool(entry) and bool(CARD_ROUTES.get(card)) 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 c4332125..a541566a 100644 --- a/dockerfiles/hermes-hux-foundation/hux/http.py +++ b/dockerfiles/hermes-hux-foundation/hux/http.py @@ -9,7 +9,11 @@ Errors always leave as ``hux.error.v1``. from __future__ import annotations import json +import math +import os import re +import threading +import time from dataclasses import dataclass, field from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -18,15 +22,55 @@ from collections.abc import Mapping from urllib.parse import parse_qs, urlsplit from hux import audit -from hux.errors import Forbidden, HuxError, Invalid, NotFound, TooLarge +from hux.errors import Forbidden, HuxError, Invalid, NotFound, RateLimited, 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 MAX_BODY_BYTES = 1024 * 1024 +DEFAULT_REQUEST_TIMEOUT_SECONDS = 10.0 +DEFAULT_READS_PER_MINUTE = 300 +DEFAULT_WRITES_PER_MINUTE = 30 +MAX_RATE_BUCKETS = 4096 Handler = Callable[["Request"], "Response"] +def _bounded_number(environ: Mapping[str, str], name: str, default: float, low: float, high: float) -> float: + """Read one numeric setting, using its safe default when malformed or outside bounds.""" + try: + value = float(environ.get(name, default)) + except (TypeError, ValueError): + return default + return value if math.isfinite(value) and low <= value <= high else default + + +class RateLimiter: + """Small fixed-window limiter bounded by active subjects in the current minute.""" + + def __init__(self, reads: int, writes: int, clock: Callable[[], float] = time.monotonic) -> None: + self.limits = {"read": reads, "write": writes} + self.clock = clock + self._windows: dict[tuple[str, str], tuple[float, int]] = {} + self._lock = threading.Lock() + + def check(self, subject: str, method: str) -> int | None: + """Consume one request and return Retry-After seconds when its bucket is full.""" + now = self.clock() + bucket = "read" if method in {"GET", "HEAD", "OPTIONS"} else "write" + key = (subject, bucket) + with self._lock: + # Each accepted identity can leave at most two current entries; + # expired identities disappear whenever any request arrives. + self._windows = {item: value for item, value in self._windows.items() if value[0] > now} + reset, used = self._windows.get(key, (now + 60.0, 0)) + if key not in self._windows and len(self._windows) >= MAX_RATE_BUCKETS: + return 60 + if used >= self.limits[bucket]: + return max(1, math.ceil(reset - now)) + self._windows[key] = (reset, used + 1) + return None + + @dataclass class Request: """Everything a handler needs; no raw socket access.""" @@ -102,10 +146,16 @@ class Router: def __init__(self, data_root: Path, environ: Mapping[str, str] | None = None) -> None: self.data_root = Path(data_root) - self.environ = environ - self.flags = Flags(environ) - self.build = build_from_environ(environ) + self.environ = dict(os.environ if environ is None else environ) + self.flags = Flags(self.environ) + self.build = build_from_environ(self.environ) self.routes: list[Route] = [] + reads = int(_bounded_number(self.environ, "HUX_READS_PER_MINUTE", DEFAULT_READS_PER_MINUTE, 1, 10_000)) + writes = int(_bounded_number(self.environ, "HUX_WRITES_PER_MINUTE", DEFAULT_WRITES_PER_MINUTE, 1, 10_000)) + self.request_timeout = _bounded_number( + self.environ, "HUX_REQUEST_TIMEOUT_SECONDS", DEFAULT_REQUEST_TIMEOUT_SECONDS, 0.1, 60.0 + ) + self.rate_limiter = RateLimiter(reads, writes) def add(self, method: str, template: str, card: str, action: str, handler: Handler, max_body: int = MAX_BODY_BYTES) -> None: """Register a handler; ``action`` is the audit action name (family.verb), ``max_body`` its byte cap.""" @@ -122,6 +172,11 @@ class Router: return route, found.groupdict(), True return None, {}, known + def body_limit(self, method: str, raw_path: str) -> int: + """Maximum declared body for a matching route, before a socket read allocates it.""" + route, _, _ = self.match(method, urlsplit(raw_path).path) + return route.max_body if route is not None else MAX_BODY_BYTES + 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) @@ -141,6 +196,9 @@ class Router: # 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) payload = self._decode(body, route.max_body) request = Request(method, parts.path, params, query, headers, payload, identity, store, self.flags, self.build) @@ -148,7 +206,8 @@ class Router: 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) - return Response(error.status, error.record()) + 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) @@ -182,18 +241,39 @@ def make_handler(router: Router) -> type[BaseHTTPRequestHandler]: return def _run(self) -> None: - if self.path == "/healthz": - 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"" - self._send(router.dispatch(self.command, self.path, dict(self.headers.items()), body)) + try: + if self.headers.get("Transfer-Encoding"): + raise Invalid("Transfer-Encoding is not supported") + lengths = self.headers.get_all("Content-Length", []) + if len(lengths) > 1: + raise Invalid("duplicate Content-Length is not supported") + raw_length = lengths[0] if lengths else "0" + if not raw_length.isascii() or not raw_length.isdigit(): + raise Invalid("Content-Length must be a non-negative integer") + if len(raw_length) > 10: + raise TooLarge("declared body length is too large") + length = int(raw_length) + limit = 0 if self.path == "/healthz" else router.body_limit(self.command, self.path) + if length > limit: + raise TooLarge(f"body exceeds {limit} bytes") + body = self.rfile.read(length) if length else b"" + if len(body) != length: + raise Invalid("request body is incomplete or timed out") + if self.path == "/healthz": + self._send(Response(200, {"status": "ok", "contract_version": CONTRACT_VERSION})) + return + self._send(router.dispatch(self.command, self.path, dict(self.headers.items()), body)) + except HuxError as error: + self._send(Response(error.status, error.record())) + except (OSError, TimeoutError): + error = Invalid("request body is incomplete or timed out") + self._send(Response(error.status, error.record())) def _send(self, response: Response) -> None: if response.stream is not None: self.send_response(response.status) self.send_header("Content-Type", "text/event-stream") - self.send_header("Cache-Control", "no-cache") + self.send_header("Cache-Control", "no-store") for key, value in response.headers.items(): self.send_header(key, value) self.end_headers() @@ -205,6 +285,7 @@ def make_handler(router: Router) -> type[BaseHTTPRequestHandler]: self.send_response(response.status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") for key, value in response.headers.items(): self.send_header(key, value) self.end_headers() @@ -215,8 +296,22 @@ def make_handler(router: Router) -> type[BaseHTTPRequestHandler]: return HuxHandler +class BoundedHTTPServer(ThreadingHTTPServer): + """Threading server that bounds header and body socket reads from accept onward.""" + + def __init__(self, address: tuple[str, int], handler: type[BaseHTTPRequestHandler], timeout: float) -> None: + self.request_timeout = timeout + super().__init__(address, handler) + + def get_request(self) -> tuple[Any, Any]: + """Accept one connection and apply its per-request socket deadline.""" + request, address = super().get_request() + request.settimeout(self.request_timeout) + return request, address + + def serve(router: Router, host: str = "127.0.0.1", port: int = 8790) -> ThreadingHTTPServer: """Create (but do not start) the server; callers call serve_forever().""" - server = ThreadingHTTPServer((host, port), make_handler(router)) + server = BoundedHTTPServer((host, port), make_handler(router), router.request_timeout) server.daemon_threads = True return server diff --git a/dockerfiles/hermes-hux-foundation/hux/identity.py b/dockerfiles/hermes-hux-foundation/hux/identity.py index 6012bea2..c13a6f10 100644 --- a/dockerfiles/hermes-hux-foundation/hux/identity.py +++ b/dockerfiles/hermes-hux-foundation/hux/identity.py @@ -2,8 +2,8 @@ The chat router, the Telegram relay and the Worker are the only callers. Each asserts identity in headers; the request body is never trusted for identity. -Relay and worker callers must also present their shared key, compared in -constant time against the value the pod was started with. +Router, relay, and worker callers must each present their distinct shared key, +compared in constant time against an inline or projected secret value. """ from __future__ import annotations @@ -13,6 +13,7 @@ import os import re from dataclasses import dataclass from collections.abc import Mapping +from pathlib import Path from hux.errors import Unauthorized @@ -26,7 +27,8 @@ SLOT_RE = re.compile(r"^slot-[0-9]{1,3}$") SUBJECT_RE = re.compile(r"^usr_[0-9a-f]{16,64}$") SURFACES = ("chat", "worker", "telegram", "voice", "api") TRUSTS = ("router", "relay", "worker") -KEY_ENV = {"relay": "HUX_RELAY_KEY", "worker": "HUX_WORKER_KEY"} +KEY_ENV = {"router": "HUX_ROUTER_KEY", "relay": "HUX_RELAY_KEY", "worker": "HUX_WORKER_KEY"} +MAX_KEY_BYTES = 4096 @dataclass(frozen=True) @@ -50,11 +52,29 @@ def _header(headers: Mapping[str, str], name: str) -> str: return "" +def _expected_key(environ: Mapping[str, str], trust: str) -> str: + """Read a caller key, preferring a bounded Kubernetes secret file.""" + name = KEY_ENV[trust] + key_file = environ.get(f"{name}_FILE", "") + if key_file: + try: + data = Path(key_file).read_bytes() + except OSError: + return "" + if len(data) > MAX_KEY_BYTES: + return "" + try: + return data.decode("utf-8", errors="strict").strip() + except UnicodeDecodeError: + return "" + return environ.get(name, "") + + def resolve(headers: Mapping[str, str], environ: Mapping[str, str] | None = None) -> Identity: """Build an Identity from request headers or raise Unauthorized. - ``trust`` defaults to ``router``; relay and worker trust require the - matching shared key to be configured and presented. + ``trust`` defaults to ``router``; every trusted hop requires its distinct + shared key to be configured and presented. """ environ = os.environ if environ is None else environ slot = _header(headers, HEADER_SLOT) @@ -70,11 +90,10 @@ def resolve(headers: Mapping[str, str], environ: Mapping[str, str] | None = None raise Unauthorized("missing or malformed subject") if surface not in SURFACES or trust not in TRUSTS: raise Unauthorized("unknown surface or trust") - if trust in KEY_ENV: - expected = environ.get(KEY_ENV[trust], "") - presented = _header(headers, HEADER_KEY) - if not expected or not presented or not hmac.compare_digest(expected, presented): - raise Unauthorized(f"{trust} key missing or wrong") + expected = _expected_key(environ, trust) + presented = _header(headers, HEADER_KEY) + if not expected or not presented or not hmac.compare_digest(expected, presented): + raise Unauthorized(f"{trust} key missing or wrong") if trust == "router" and surface == "worker": raise Unauthorized("worker surface needs worker trust") 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 7606277b..ca6b1172 100644 --- a/dockerfiles/hermes-hux-foundation/hux/policy.py +++ b/dockerfiles/hermes-hux-foundation/hux/policy.py @@ -137,7 +137,7 @@ def default_policy(identity: Identity) -> dict[str, Any]: return { "schema": "hux.policy.v1", "id": "pol_global", "owner": identity.subject, "scope": {"level": "global"}, "autonomy": "safe", "grants": [], "budgets": dict(DEFAULT_BUDGETS), - "provenance": provenance(identity), "updated_at": iso(now()), + "provenance": provenance(identity), "updated_at": iso(now()), "_budget_epoch": new_id("bep"), } @@ -210,6 +210,7 @@ def put_policy(request: Request) -> Response: "schema": "hux.policy.v1", "id": record_id, "owner": request.identity.subject, "scope": scope, "autonomy": body["autonomy"], "grants": normalise_grants(body.get("grants", []), request.identity), "budgets": budgets, "provenance": provenance(request.identity), "updated_at": iso(now()), + "_budget_epoch": new_id("bep"), } expected = request.if_match() with request.store.lock(FAMILY): @@ -232,9 +233,13 @@ def add_grant(store: TenantStore, identity: Identity, level: str, scope_id: str record = {**global_policy(store, identity), "id": record_id, "scope": scope} record.pop("revision") expected = record.pop("revision", None) + budget_epoch = record.get("_budget_epoch") or f"{record['id']}:{expected or 1}" grants = [g for g in record["grants"] if g["capability"] != capability] grants.append({"capability": capability, "decision": "allow", "expires_at": iso(now() + min(ttl, ALWAYS_TTL)), "granted_by": actor_for(identity)}) - record = {**record, "grants": grants[-64:], "provenance": provenance(identity), "updated_at": iso(now())} + record = { + **record, "grants": grants[-64:], "provenance": provenance(identity), + "updated_at": iso(now()), "_budget_epoch": budget_epoch, + } store.put(FAMILY, checked(record), expected_revision=expected) @@ -283,6 +288,7 @@ def create_approval(request: Request) -> Response: 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) if state["exhausted"]: emit(request.store, request.identity, conversation_id, "budget.exhausted", f"Budget exhausted: {', '.join(state['exhausted'])}", run_id=state["run_id"]) @@ -294,6 +300,9 @@ def create_approval(request: Request) -> Response: 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, @@ -371,4 +380,3 @@ def register(router: Router) -> None: router.add("GET", "/hux/v1/approvals/{id}", CARD, "approvals.read", get_approval) router.add("POST", "/hux/v1/approvals/{id}", CARD, "approvals.decide", decide_approval) budgets.register_routes(router) - diff --git a/dockerfiles/hermes-hux-foundation/hux/redaction.py b/dockerfiles/hermes-hux-foundation/hux/redaction.py index a0bb40d8..b59226fe 100644 --- a/dockerfiles/hermes-hux-foundation/hux/redaction.py +++ b/dockerfiles/hermes-hux-foundation/hux/redaction.py @@ -81,7 +81,13 @@ DETAIL_ALLOWLIST: dict[str, frozenset[str]] = { def canaries(environ: dict[str, str] | None = None) -> list[str]: """Literal secrets the pod was started with (SO-07); every one is scrubbed wherever it appears.""" environ = os.environ if environ is None else environ - values = [environ.get(name, "") for name in ("HUX_RELAY_KEY", "HUX_WORKER_KEY")] + names = ("HUX_ROUTER_KEY", "HUX_RELAY_KEY", "HUX_WORKER_KEY") + values = [environ.get(name, "") for name in names] + for name in names: + key_file = environ.get(f"{name}_FILE", "") + if key_file and os.path.exists(key_file): + with open(key_file, encoding="utf-8", errors="replace") as handle: + values.append(handle.read(4097).strip()) path = environ.get("HUX_CANARY_FILE", "") if path and os.path.exists(path): with open(path, encoding="utf-8", errors="replace") as handle: diff --git a/dockerfiles/hermes-worker-hux/hux_hook/client.py b/dockerfiles/hermes-worker-hux/hux_hook/client.py index a54b9e6d..b0c232c2 100644 --- a/dockerfiles/hermes-worker-hux/hux_hook/client.py +++ b/dockerfiles/hermes-worker-hux/hux_hook/client.py @@ -13,6 +13,7 @@ import http.client import json import threading import urllib.error +import urllib.parse import urllib.request from collections.abc import Mapping from typing import Any @@ -24,6 +25,49 @@ HEADER_TRUST = "X-Hux-Trust" HEADER_KEY = "X-Hux-Relay-Key" DEFAULT_BASE_URL = "http://127.0.0.1:8790" MESSAGE_MAX = 280 +MAX_RESPONSE_BYTES = 4 * 1024 * 1024 +LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1"}) + + +class _RejectRedirect(urllib.request.HTTPRedirectHandler): + """Never replay tenant identity or worker credentials to a redirect target.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, ANN201, ARG002 + """Refuse every redirect rather than replaying the original headers.""" + return None + + +def _validated_base_url(raw: str) -> str: + """Return a canonical literal-loopback HTTP origin or reject it.""" + parts = urllib.parse.urlsplit(raw) + if ( + parts.scheme != "http" + or parts.hostname not in LOOPBACK_HOSTS + or parts.username is not None + or parts.password is not None + or parts.query + or parts.fragment + or parts.path not in {"", "/"} + or parts.port is None + ): + raise ValueError("HUX base URL must be a literal loopback HTTP origin with an explicit port") + host = f"[{parts.hostname}]" if parts.hostname == "::1" else parts.hostname + return f"http://{host}:{parts.port}" + + +def _validated_path(path: str) -> str: + """Accept only canonical relative HUX API paths owned by this client.""" + if not isinstance(path, str): + raise HuxServiceError(400, "invalid", "malformed request path") + decoded = urllib.parse.unquote(path) + if ( + not path.startswith("/hux/v1/") + or path.startswith("//") + or any(char in path for char in "?#\\\r\n") + or any(segment in {".", ".."} for segment in decoded.split("/")) + ): + raise HuxServiceError(400, "invalid", "malformed request path") + return path class HuxServiceError(Exception): @@ -73,13 +117,16 @@ 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: identity = dict(identity or {}) - self.base_url = base_url.rstrip("/") + 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 - self.timeout = timeout + 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) + self._opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _RejectRedirect()) self._capabilities: dict[str, Any] | None = None self._guard = threading.Lock() @@ -111,15 +158,15 @@ class HuxClient: if body is not None: data = json.dumps(body, sort_keys=True, separators=(",", ":")).encode() extra["Content-Type"] = "application/json" - url = self.base_url + path + url = self.base_url + _validated_path(path) if query: - url += "?" + "&".join(f"{k}={urllib.request.quote(str(v), safe='')}" for k, v in query.items()) + url += "?" + urllib.parse.urlencode({str(k): str(v) for k, v in query.items()}) req = urllib.request.Request(url, data=data, method=method, headers=self.headers(extra)) try: - with urllib.request.urlopen(req, timeout=self.timeout) as raw: # noqa: S310 - loopback only - response = HuxResponse(raw.status, _decode(raw.read()), dict(raw.headers.items())) + with self._opener.open(req, timeout=self.timeout) as raw: # noqa: S310 - validated literal loopback only + response = HuxResponse(raw.status, _decode(_bounded_read(raw)), dict(raw.headers.items())) except urllib.error.HTTPError as error: - payload = _decode(error.read()) + payload = _decode(_bounded_read(error)) raise _error_from(error.code, payload) from None except (urllib.error.URLError, OSError, TimeoutError) as error: raise HuxUnavailable(f"hux service unreachable: {type(error).__name__}") from None @@ -176,3 +223,11 @@ def _decode(raw: bytes) -> Any: return json.loads(raw) except ValueError: return None + + +def _bounded_read(raw: Any) -> bytes: + """Read one bounded response so a compromised sidecar cannot exhaust the worker.""" + data = raw.read(MAX_RESPONSE_BYTES + 1) + if len(data) > MAX_RESPONSE_BYTES: + raise HuxUnavailable("hux service returned an oversized response") + return data diff --git a/dockerfiles/hermes-worker-hux/hux_hook/hooks.py b/dockerfiles/hermes-worker-hux/hux_hook/hooks.py index 4b08ea4e..3c949e87 100644 --- a/dockerfiles/hermes-worker-hux/hux_hook/hooks.py +++ b/dockerfiles/hermes-worker-hux/hux_hook/hooks.py @@ -72,6 +72,12 @@ def before_tool(client: HuxClient, run_id: str, conversation_id: str, tool_name: return Decision(False, None, "invalid_run_id") if not client.card_enabled(CARD_AUTONOMY): return Decision(False, None, "hux_unavailable" if not client.capabilities()["reachable"] else "autonomy_off") + try: + # A zero-spend write pins the trusted worker's run -> conversation + # binding before a scoped session grant can create a new approval. + client.post(f"/hux/v1/runs/{run_id}/budget", {"conversation_id": conversation_id}) + except HuxServiceError as error: + return Decision(False, None, _failure_reason(error)) argument_hash = canonical_argument_hash(tool_name, arguments) size = len(canonical_json(arguments)) body = { @@ -182,12 +188,6 @@ def memory_gate(client: HuxClient, conversation_id: str) -> bool: """ try: state = client.get(f"/hux/v1/conversations/{conversation_id}/privacy").body - except HuxServiceError as error: - if error.status != 404: - return False - try: - client.get("/hux/v1/privacy/policy") - except HuxServiceError: - return False - return True + except HuxServiceError: + return False return bool(isinstance(state, dict) and state.get("memory_writes_allowed")) diff --git a/testing/quality_contract.json b/testing/quality_contract.json index 4706755e..d28d773f 100644 --- a/testing/quality_contract.json +++ b/testing/quality_contract.json @@ -104,8 +104,17 @@ "scripts/ops/hermes_handoff_rules.py", "scripts/ops/hermes_handoff_run.py", "testing/quality_handoff_mutation.py", - "services/hermes/scripts/hux_contracts.py", - "services/hermes/scripts/hux_policy.py" + "dockerfiles/hermes-hux-foundation/hux/budgets.py", + "dockerfiles/hermes-hux-foundation/hux/contracts.py", + "dockerfiles/hermes-hux-foundation/hux/errors.py", + "dockerfiles/hermes-hux-foundation/hux/flags.py", + "dockerfiles/hermes-hux-foundation/hux/http.py", + "dockerfiles/hermes-hux-foundation/hux/identity.py", + "dockerfiles/hermes-hux-foundation/hux/policy.py", + "dockerfiles/hermes-hux-foundation/hux/redaction.py", + "dockerfiles/hermes-hux-foundation/hux/rules.py", + "dockerfiles/hermes-worker-hux/hux_hook/client.py", + "dockerfiles/hermes-worker-hux/hux_hook/hooks.py" ], "lint_paths": [ "ci/scripts/hermes_image_release.py", @@ -179,7 +188,18 @@ "scripts/ops/hermes_handoff_policy.py", "scripts/ops/hermes_handoff_redaction.py", "scripts/ops/hermes_handoff_rules.py", - "scripts/ops/hermes_handoff_run.py" + "scripts/ops/hermes_handoff_run.py", + "dockerfiles/hermes-hux-foundation/hux/budgets.py", + "dockerfiles/hermes-hux-foundation/hux/contracts.py", + "dockerfiles/hermes-hux-foundation/hux/errors.py", + "dockerfiles/hermes-hux-foundation/hux/flags.py", + "dockerfiles/hermes-hux-foundation/hux/http.py", + "dockerfiles/hermes-hux-foundation/hux/identity.py", + "dockerfiles/hermes-hux-foundation/hux/policy.py", + "dockerfiles/hermes-hux-foundation/hux/redaction.py", + "dockerfiles/hermes-hux-foundation/hux/rules.py", + "dockerfiles/hermes-worker-hux/hux_hook/client.py", + "dockerfiles/hermes-worker-hux/hux_hook/hooks.py" ], "pytest_suites": { "unit": { @@ -280,7 +300,8 @@ "services/hermes/scripts/stage_runtime_access.py", "services/hermes/scripts/node_polkit_audit.py", "scripts/ops/hermes_handoff_*.py", - "services/hermes/scripts/hux_*.py" + "dockerfiles/hermes-hux-foundation/hux/*.py", + "dockerfiles/hermes-worker-hux/hux_hook/*.py" ], "naming_rules": [ { @@ -343,7 +364,18 @@ "scripts/ops/hermes_handoff_run.py", "ci/scripts/semgrep_report.py", "testing/quality_coverage.py", - "testing/quality_handoff_mutation.py" + "testing/quality_handoff_mutation.py", + "dockerfiles/hermes-hux-foundation/hux/budgets.py", + "dockerfiles/hermes-hux-foundation/hux/contracts.py", + "dockerfiles/hermes-hux-foundation/hux/errors.py", + "dockerfiles/hermes-hux-foundation/hux/flags.py", + "dockerfiles/hermes-hux-foundation/hux/http.py", + "dockerfiles/hermes-hux-foundation/hux/identity.py", + "dockerfiles/hermes-hux-foundation/hux/policy.py", + "dockerfiles/hermes-hux-foundation/hux/redaction.py", + "dockerfiles/hermes-hux-foundation/hux/rules.py", + "dockerfiles/hermes-worker-hux/hux_hook/client.py", + "dockerfiles/hermes-worker-hux/hux_hook/hooks.py" ], "tracked_files": [ "services/hermes/scripts/voice_route_preflight.py", @@ -427,7 +459,18 @@ "scripts/ops/hermes_handoff_redaction.py", "scripts/ops/hermes_handoff_rules.py", "scripts/ops/hermes_handoff_run.py", - "testing/quality_handoff_mutation.py" + "testing/quality_handoff_mutation.py", + "dockerfiles/hermes-hux-foundation/hux/budgets.py", + "dockerfiles/hermes-hux-foundation/hux/contracts.py", + "dockerfiles/hermes-hux-foundation/hux/errors.py", + "dockerfiles/hermes-hux-foundation/hux/flags.py", + "dockerfiles/hermes-hux-foundation/hux/http.py", + "dockerfiles/hermes-hux-foundation/hux/identity.py", + "dockerfiles/hermes-hux-foundation/hux/policy.py", + "dockerfiles/hermes-hux-foundation/hux/redaction.py", + "dockerfiles/hermes-hux-foundation/hux/rules.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_artifact_auth.py b/testing/tests/test_hermes_hux_artifact_auth.py index c38b1602..cc694583 100644 --- a/testing/tests/test_hermes_hux_artifact_auth.py +++ b/testing/tests/test_hermes_hux_artifact_auth.py @@ -23,14 +23,14 @@ from hux import artifacts, audit, contracts, errors, identity, store # noqa: E4 from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() -OWNER = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"} +OWNER = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"} OTHER = {**OWNER, "X-Hux-Subject": "usr_fedcba9876543210"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) @pytest.fixture def router(tmp_path): - return build_router(tmp_path, {"HUX_FLAGS": ALL_ON}) + return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) def call(router, method, path, body=None, headers=OWNER): @@ -105,7 +105,7 @@ def test_contract_validation_guards_every_write(router, artifact): def test_flag_off_hides_the_card(tmp_path, artifact): - router = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation"}) + router = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation", "HUX_ROUTER_KEY": "rk"}) status, error = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c"}) assert (status, error["code"]) == (404, "flag_off") status, error = call(router, "GET", "/hux/v1/artifacts") @@ -142,5 +142,5 @@ def test_secret_bearing_content_is_kept_but_forced_restricted(router): status, bumped = call(router, "POST", f"/hux/v1/artifacts/{clean['id']}/versions", {"content": f"key: {token}"}, {**OWNER, "If-Match": "1"}) assert status == 201 and bumped["sensitivity"] == "restricted" from hux import audit, store - reasons = [r.get("reason") for r in audit.recent(store.TenantStore(router.data_root, identity.resolve(OWNER, {}))) if r["action"].startswith("artifacts.")] + reasons = [r.get("reason") for r in audit.recent(store.TenantStore(router.data_root, identity.resolve(OWNER, {"HUX_ROUTER_KEY": "rk"}))) if r["action"].startswith("artifacts.")] assert reasons.count("secret_pattern_restricted") == 2 diff --git a/testing/tests/test_hermes_hux_artifact_versions.py b/testing/tests/test_hermes_hux_artifact_versions.py index 9a217b00..bcecc0b5 100644 --- a/testing/tests/test_hermes_hux_artifact_versions.py +++ b/testing/tests/test_hermes_hux_artifact_versions.py @@ -28,7 +28,7 @@ from hux import artifacts, contracts, diffs, errors, identity, store # noqa: E4 from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() -HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"} +HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) @@ -38,7 +38,7 @@ def ident() -> identity.Identity: @pytest.fixture def router(tmp_path): - return build_router(tmp_path, {"HUX_FLAGS": ALL_ON}) + return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) def call(router, method, path, body=None, headers=None, raw=None): @@ -102,7 +102,7 @@ def test_create_accepts_base64_and_verifies_claimed_hash(router): def test_worker_trust_is_recorded_as_system_actor(tmp_path): - router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk"}) + router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk"}) headers = {"X-Hux-Trust": "worker", "X-Hux-Surface": "worker", "X-Hux-Relay-Key": "wk"} status, record, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "gen.py", "content": "print(1)\n"}, headers) assert status == 201 and record["versions"][0]["created_by"] == {"type": "system", "id": "worker"} diff --git a/testing/tests/test_hermes_hux_contract_events.py b/testing/tests/test_hermes_hux_contract_events.py index aa096727..63d50662 100644 --- a/testing/tests/test_hermes_hux_contract_events.py +++ b/testing/tests/test_hermes_hux_contract_events.py @@ -23,7 +23,7 @@ from hux import audit, contracts, errors, events, identity, redaction, store # from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() -HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"} +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"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) CONV = "conv_0001abcd" @@ -35,7 +35,7 @@ def ident(**overrides) -> identity.Identity: def router_for(tmp_path): - return build_router(tmp_path, {"HUX_FLAGS": ALL_ON}) + return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) def call(router, method, path, headers=HEADERS, body=None): @@ -357,7 +357,7 @@ def test_stream_redacts_for_voice_surface(tmp_path): def test_flag_off_hides_event_routes(tmp_path): - router = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation"}) + router = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation", "HUX_ROUTER_KEY": "rk"}) assert call(router, "GET", f"/hux/v1/conversations/{CONV}/events")[1]["code"] == "flag_off" diff --git a/testing/tests/test_hermes_hux_contract_foundation.py b/testing/tests/test_hermes_hux_contract_foundation.py index 8a35f2ec..c9f513b2 100644 --- a/testing/tests/test_hermes_hux_contract_foundation.py +++ b/testing/tests/test_hermes_hux_contract_foundation.py @@ -27,7 +27,7 @@ from hux.http import Router, Response, page, serve # noqa: E402 from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() -HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"} +HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) @@ -39,7 +39,7 @@ def ident(**overrides) -> identity.Identity: # --- identity ------------------------------------------------------------------- def test_identity_from_router_headers(): - who = identity.resolve(HEADERS, {}) + who = identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}) assert who == ident() assert contracts.validate("common.schema.json", who.record(), SCHEMAS, "/$defs/identity") == [] @@ -50,7 +50,7 @@ def test_identity_from_router_headers(): ]) def test_identity_rejects_bad_headers(bad): with pytest.raises(errors.Unauthorized): - identity.resolve({**HEADERS, **bad}, {}) + identity.resolve({**HEADERS, **bad}, {"HUX_ROUTER_KEY": "rk"}) def test_relay_and_worker_need_their_keys(): @@ -62,13 +62,48 @@ def test_relay_and_worker_need_their_keys(): assert identity.resolve({**relay, "X-Hux-Relay-Key": "secret"}, {"HUX_RELAY_KEY": "secret"}).trust == "relay" worker = {**HEADERS, "X-Hux-Trust": "worker", "X-Hux-Surface": "worker", "X-Hux-Relay-Key": "wk"} assert identity.resolve(worker, {"HUX_WORKER_KEY": "wk"}).surface == "worker" - assert identity.resolve(HEADERS).trust == "router" + assert identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}).trust == "router" + + +def test_router_key_is_required_and_secret_file_is_supported(tmp_path): + """Same-pod callers cannot forge router trust; a projected 0400 secret file authenticates it.""" + bare = {key: value for key, value in HEADERS.items() if key != "X-Hux-Relay-Key"} + with pytest.raises(errors.Unauthorized) as missing: + identity.resolve(bare, {"HUX_ROUTER_KEY": "router-secret"}) + assert "router-secret" not in str(missing.value) + with pytest.raises(errors.Unauthorized): + identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "router-secret", "HUX_RELAY_KEY": "rk"}) + key_file = tmp_path / "router-key" + key_file.write_text("router-secret\n") + key_file.chmod(0o400) + authenticated = {**bare, "X-Hux-Relay-Key": "router-secret"} + assert identity.resolve(authenticated, {"HUX_ROUTER_KEY_FILE": str(key_file)}).trust == "router" + router = build_router(tmp_path / "data", {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "router-secret"}) + assert router.dispatch("GET", "/hux/v1/capabilities", bare, b"").status == 401 + assert not (tmp_path / "data" / "hux").exists() + + +@pytest.mark.parametrize("payload", [None, b"x" * (identity.MAX_KEY_BYTES + 1), b"\xff"]) +def test_router_key_file_failures_are_unauthorized(tmp_path, payload): + """Missing, oversized, and non-UTF-8 projected secrets fail closed without exceptions or values.""" + key_file = tmp_path / "router-key" + if payload is not None: + key_file.write_bytes(payload) + 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) + + +def test_router_trust_cannot_claim_worker_surface(): + """Even an authenticated BFF key cannot impersonate the separately keyed worker hop.""" + with pytest.raises(errors.Unauthorized, match="worker surface"): + identity.resolve({**HEADERS, "X-Hux-Surface": "worker"}, {"HUX_ROUTER_KEY": "rk"}) def test_slot_must_match_the_pod_it_reaches(): with pytest.raises(errors.Unauthorized): - identity.resolve(HEADERS, {"HUX_TENANT_SLOT": "slot-4"}) - assert identity.resolve(HEADERS, {"HUX_TENANT_SLOT": "slot-3"}).tenant_slot == "slot-3" + identity.resolve(HEADERS, {"HUX_TENANT_SLOT": "slot-4", "HUX_ROUTER_KEY": "rk"}) + assert identity.resolve(HEADERS, {"HUX_TENANT_SLOT": "slot-3", "HUX_ROUTER_KEY": "rk"}).tenant_slot == "slot-3" def test_server_refuses_non_loopback_bind(): @@ -90,7 +125,9 @@ def test_flags_fail_closed_and_capabilities_validate(): on = flags.Flags({"HUX_FLAGS": ALL_ON}) record = on.capabilities(ident(), {"commit": "d3cbeb06" * 5, "image_digest": "sha256:" + "4a" * 32, "junk": "x"}) assert contracts.validate_record(record, SCHEMAS) == [] - assert all(card["enabled"] for card in record["cards"]) + enabled = {card["card"]: card["enabled"] for card in record["cards"]} + assert all(enabled[card] for card, routes in flags.CARD_ROUTES.items() if routes) + assert all(not enabled[card] for card, routes in flags.CARD_ROUTES.items() if not routes) assert {card["card"] for card in record["cards"]} == {f"HUX-{n:02d}" for n in range(1, 13)} assert not on.enabled("HUX-99") assert flags.build_from_environ({"HUX_BUILD_COMMIT": "abc"}) == {"commit": "abc", "image_digest": ""} @@ -224,7 +261,7 @@ def test_audit_rows_validate_and_never_carry_bodies(tmp_path): # --- http pipeline --------------------------------------------------------------- def _router(tmp_path, flags_value=ALL_ON, environ=None) -> Router: - env = {"HUX_FLAGS": flags_value, **(environ or {})} + env = {"HUX_FLAGS": flags_value, "HUX_ROUTER_KEY": "rk", **(environ or {})} return build_router(tmp_path, env) @@ -323,6 +360,7 @@ def test_real_server_serves_json_and_sse(tmp_path): conn.request("GET", "/hux/v1/s", headers=HEADERS) reply = conn.getresponse() assert reply.getheader("Content-Type") == "text/event-stream" + assert reply.getheader("Cache-Control") == "no-store" assert reply.read() == b"id: 1\ndata: {}\n\nid: 2\ndata: {}\n\n" finally: server.shutdown() @@ -333,6 +371,8 @@ def test_all_errors_serialise_to_contract(): for cls in (errors.Unauthorized, errors.Forbidden, errors.NotFound, errors.FlagOff, errors.Conflict, errors.Invalid, errors.TooLarge, errors.ApprovalRequired, errors.BudgetExhausted): record = cls("m" * 300, ["d" * 300] * 40).record() assert contracts.validate_record(record, SCHEMAS) == [], cls + limited = errors.RateLimited(7) + assert limited.retry_after == 7 and contracts.validate_record(limited.record(), SCHEMAS) == [] def test_foundation_sources_stay_under_500_lines(): @@ -389,13 +429,16 @@ def test_worker_trust_reaches_only_the_allowlisted_routes(tmp_path): 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 not 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_unshipped_cards_declare_no_routes_and_cannot_enable(): + """F12: cards without a server implementation stay disabled even when every raw flag is configured.""" + unshipped = {"HUX-06", "HUX-07", "HUX-09", "HUX-12"} + configured = flags.Flags({"HUX_FLAGS": ALL_ON}) + assert all(flags.CARD_ROUTES[card] == [] for card in unshipped) + assert all(not configured.enabled(card) for card in unshipped) def test_unexpected_handler_exceptions_become_a_500_error_record(tmp_path): diff --git a/testing/tests/test_hermes_hux_contract_hook.py b/testing/tests/test_hermes_hux_contract_hook.py index 59a667e5..a71687a1 100644 --- a/testing/tests/test_hermes_hux_contract_hook.py +++ b/testing/tests/test_hermes_hux_contract_hook.py @@ -45,7 +45,7 @@ CANARY = "CANARY-9c1d-SECRET" def start(tmp_path: Path, flags: str = ALL_ON): - router = build_router(tmp_path, {"HUX_FLAGS": flags, "HUX_WORKER_KEY": "wk"}) + router = build_router(tmp_path, {"HUX_FLAGS": flags, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk"}) server = serve(router, "127.0.0.1", 0) threading.Thread(target=server.serve_forever, daemon=True).start() return f"http://127.0.0.1:{server.server_address[1]}", server @@ -75,7 +75,7 @@ def test_headers_match_the_service_vocabulary(): 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) + human = HuxClient(base, HUMAN, key="rk") with pytest.raises(HuxServiceError) as bad: human.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) @@ -96,7 +96,7 @@ def test_transport_edge_cases(monkeypatch): def raise_http(*args, **kwargs): raise urllib.error.HTTPError("u", 502, "bad gateway", {}, io.BytesIO(b"")) - monkeypatch.setattr(urllib.request, "urlopen", raise_http) + monkeypatch.setattr(client._opener, "open", raise_http) with pytest.raises(HuxServiceError) as html: client.get("/hux/v1/capabilities") assert (html.value.status, html.value.code) == (502, "invalid") @@ -105,7 +105,7 @@ def test_transport_edge_cases(monkeypatch): status = 418 headers = {"X-Test": "1"} - def read(self): + def read(self, *_args): return b"{bad json" def __enter__(self): @@ -114,7 +114,7 @@ def test_transport_edge_cases(monkeypatch): def __exit__(self, *exc): return False - monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **k: Raw()) + monkeypatch.setattr(client._opener, "open", lambda *a, **k: Raw()) with pytest.raises(HuxServiceError) as teapot: client.put("/hux/v1/policy", {"x": 1}, if_match=3) assert teapot.value.status == 418 @@ -122,22 +122,62 @@ def test_transport_edge_cases(monkeypatch): def raise_socket(*args, **kwargs): raise TimeoutError("slow") - monkeypatch.setattr(urllib.request, "urlopen", raise_socket) + monkeypatch.setattr(client._opener, "open", raise_socket) with pytest.raises(HuxUnavailable): client.get("/hux/v1/capabilities", query={"a": "b c"}) def raise_half_closed(*args, **kwargs): raise http.client.BadStatusLine("gone") - monkeypatch.setattr(urllib.request, "urlopen", raise_half_closed) + monkeypatch.setattr(client._opener, "open", raise_half_closed) with pytest.raises(HuxUnavailable): client.get("/hux/v1/capabilities") +@pytest.mark.parametrize("base", [ + "https://127.0.0.1:8790", "http://localhost:8790", "http://10.0.0.1:8790", + "http://user:secret@127.0.0.1:8790", "http://127.0.0.1:8790/path", + "http://127.0.0.1:8790?next=x", "http://127.0.0.1", +]) +def test_client_accepts_only_literal_loopback_origin(base): + """The worker client refuses non-loopback, credentialed, ambiguous and TLS origins.""" + with pytest.raises(ValueError): + HuxClient(base, WORKER) + assert HuxClient("http://[::1]:8790", WORKER).base_url == "http://[::1]:8790" + with pytest.raises(ValueError): + HuxClient("http://127.0.0.1:8790", WORKER, timeout=0) + + +@pytest.mark.parametrize("path", ["https://evil.invalid/hux/v1/x", "//evil.invalid/x", "/hux/v1/../x", "/hux/v1/%2e%2e/x", "/hux/v1/x?y=1", "/healthz"]) +def test_client_rejects_noncanonical_paths_and_redirects(path, monkeypatch): + """Paths stay same-origin and the opener never creates a redirected credential-bearing request.""" + monkeypatch.setattr(urllib.request, "getproxies", lambda: {"http": "http://proxy.invalid:8080"}) + client = HuxClient("http://127.0.0.1:8790", WORKER, key=CANARY) + with pytest.raises(HuxServiceError) as invalid: + client.get(path) + assert invalid.value.status == 400 + assert client_mod._RejectRedirect().redirect_request(None, None, 302, "moved", {}, "https://evil.invalid") is None + proxy_handlers = [handler for handler in client._opener.handlers if isinstance(handler, urllib.request.ProxyHandler)] + assert proxy_handlers == [], "loopback credentials never enter an environment proxy" + + +def test_client_bounds_sidecar_responses_and_non_string_paths(): + """A compromised local sidecar cannot allocate an unbounded response or smuggle a non-string URL.""" + class Oversized: + def read(self, amount): + return b"x" * amount + + with pytest.raises(HuxUnavailable, match="oversized response"): + client_mod._bounded_read(Oversized()) + with pytest.raises(HuxServiceError) as malformed: + HuxClient("http://127.0.0.1:8790", WORKER).get(None) # type: ignore[arg-type] + assert malformed.value.status == 400 + + def test_put_with_if_match_and_get_with_query(live): """Revisioned writes send If-Match; a stale revision is a conflict; queries reach the service.""" base, _ = live - human = HuxClient(base, HUMAN) + human = HuxClient(base, HUMAN, key="rk") first = human.put("/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe"}) assert first.header("ETag") == "1" and first.header("Missing") == "" second = human.put("/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "autonomous"}, if_match=1) @@ -176,7 +216,7 @@ def test_capabilities_cached_per_process(live, monkeypatch): def test_emit_is_best_effort_and_redaction_safe(live): """SO-11: detail outside the allowlist is dropped by the service; failures return None and never raise.""" base, root = live - human = HuxClient(base, HUMAN) + human = HuxClient(base, HUMAN, key="rk") conv = human.post("/hux/v1/conversations", {"title": "t"}).body["id"] worker = HuxClient(base, WORKER, key="wk") record = emit(worker, conv, "tool.call", "shell call", {"tool": "shell", "arguments": {"cmd": CANARY}, "argument_bytes": 7}, @@ -187,7 +227,7 @@ def test_emit_is_best_effort_and_redaction_safe(live): assert emit(worker, conv, "not.a.kind", "x") is None assert emit(worker, "conv_unknown0001", "tool.call", "x") is None assert emit(worker, conv, "tool.call", "x", {"tool": object()}) is None - assert emit(HuxClient(base, OTHER), conv, "tool.call", "cross tenant") is None + assert emit(HuxClient(base, OTHER, key="rk"), conv, "tool.call", "cross tenant") is None private = human.post("/hux/v1/conversations", {"title": "p", "mode": "private"}).body["id"] assert emit(worker, private, "tool.call", "private mode writes nothing") is None assert CANARY not in "\n".join(p.read_text(errors="ignore") for p in root.rglob("*") if p.is_file()) @@ -198,13 +238,13 @@ def test_emit_is_best_effort_and_redaction_safe(live): def test_memory_gate(live, tmp_path): """SO-27, SO-28: allowed only when the privacy card answers and the conversation is not private.""" base, _ = live - human = HuxClient(base, HUMAN) + human = HuxClient(base, HUMAN, key="rk") worker = HuxClient(base, WORKER, key="wk") normal = human.post("/hux/v1/conversations", {"title": "n", "mode": "thoughtful"}).body["id"] private = human.post("/hux/v1/conversations", {"title": "p", "mode": "private"}).body["id"] assert memory_gate(worker, normal) is True assert memory_gate(worker, private) is False - assert memory_gate(worker, "conv_notknown01") is True + assert memory_gate(worker, "conv_notknown01") is False assert memory_gate(worker, "bad id") is False human.post(f"/hux/v1/conversations/{normal}/forget", {}) assert memory_gate(worker, normal) is False diff --git a/testing/tests/test_hermes_hux_contract_organization.py b/testing/tests/test_hermes_hux_contract_organization.py index 5f2e473d..4f12ffb4 100644 --- a/testing/tests/test_hermes_hux_contract_organization.py +++ b/testing/tests/test_hermes_hux_contract_organization.py @@ -24,14 +24,14 @@ from hux import audit, contracts, identity, organization, store # noqa: E402 from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() -HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"} +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"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) @pytest.fixture def router(tmp_path): - return build_router(tmp_path, {"HUX_FLAGS": ALL_ON}) + return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) def call(router, method, path, body=None, headers=HEADERS): @@ -90,7 +90,7 @@ def test_project_patch_needs_if_match_and_audits_unconditional_writes(router, tm assert call(router, "PATCH", path, {"name": ""}, {**HEADERS, "If-Match": "3"})[0] == 400 assert call(router, "PATCH", path, {"default_mode": "turbo"}, {**HEADERS, "If-Match": "3"})[0] == 400 assert call(router, "PATCH", path, [], {**HEADERS, "If-Match": "3"})[0] == 400 - rows = [(r["action"], r["outcome"], r.get("reason", "")) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))) if r["action"] == "projects.update"] + rows = [(r["action"], r["outcome"], r.get("reason", "")) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))) if r["action"] == "projects.update"] assert rows[0] == ("projects.update", "allow", "") and rows[1][1] == "conflict" and rows[2] == ("projects.update", "allow", "unconditional_write") @@ -147,8 +147,8 @@ def test_conversation_create_list_filters_and_patch(router): def test_helpers_for_other_lanes(router, tmp_path): project = make_project(router) conversation = make_conversation(router, project_id=project["id"]) - mine = store.TenantStore(tmp_path, identity.resolve(HEADERS, {})) - theirs = store.TenantStore(tmp_path, identity.resolve(OTHER, {})) + mine = store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"})) + theirs = store.TenantStore(tmp_path, identity.resolve(OTHER, {"HUX_ROUTER_KEY": "rk"})) assert organization.project_exists(mine, project["id"]) and not organization.project_exists(theirs, project["id"]) assert organization.conversation_exists(mine, conversation["id"]) and not organization.conversation_exists(theirs, conversation["id"]) assert not organization.project_exists(mine, "../escape") and not organization.conversation_exists(mine, None) @@ -181,7 +181,7 @@ def test_branch_copies_project_tags_and_mode_and_lineage_walks_both_ways(router) def test_lineage_survives_a_missing_or_cyclic_parent(router, tmp_path): orphan = make_conversation(router) - mine = store.TenantStore(tmp_path, identity.resolve(HEADERS, {})) + mine = store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"})) mine.put("conversations", {**orphan, "branch": {"parent_conversation_id": "conv_gone00000001", "branch_point_message_id": "m"}}) assert call(router, "GET", f"/hux/v1/conversations/{orphan['id']}/lineage")[1]["ancestors"] == [] mine.put("conversations", {**mine.get("conversations", orphan["id"]), "branch": {"parent_conversation_id": orphan["id"], "branch_point_message_id": "m"}}) @@ -220,7 +220,7 @@ def test_f13c_search_finds_conversations_by_artifact_title(router, monkeypatch): assert status == 201, artifact status, body, _ = call(router, "GET", "/hux/v1/search?q=supplier") assert [c["id"] for c in body["items"]] == [conversation["id"]] and body["scores"][conversation["id"]] == 1 - s = store.TenantStore(router.data_root, identity.resolve(HEADERS, {})) + s = store.TenantStore(router.data_root, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"})) assert organization.artifact_titles(s, conversation) == ["Supplier comparison sheet"] and organization.artifact_titles(s, other) == [] # The conversation's own artifact_ids list is indexed as well, without duplicates. s.put(organization.CONVERSATIONS, {**s.get(organization.CONVERSATIONS, other["id"]), "artifact_ids": [artifact["id"], "art_missing0001"]}, 1) @@ -249,6 +249,6 @@ def test_f9_titles_tags_names_and_descriptions_are_secret_scrubbed(router): def test_flag_off_hides_the_card(tmp_path): - off = build_router(tmp_path, {"HUX_FLAGS": ""}) + off = build_router(tmp_path, {"HUX_FLAGS": "", "HUX_ROUTER_KEY": "rk"}) status, body, _ = call(off, "GET", "/hux/v1/projects") assert (status, body["code"]) == (404, "flag_off") diff --git a/testing/tests/test_hermes_hux_http_security.py b/testing/tests/test_hermes_hux_http_security.py new file mode 100644 index 00000000..57bff367 --- /dev/null +++ b/testing/tests/test_hermes_hux_http_security.py @@ -0,0 +1,169 @@ +"""Adversarial HTTP boundary tests for the loopback HUX foundation service.""" + +from __future__ import annotations + +import json +import socket +import sys +import threading +from http.client import HTTPConnection +from pathlib import Path + +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 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 + +ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) +HEADERS = { + "X-Hermes-Tenant-Identity": "slot-3", + "X-Hux-Subject": "usr_0123456789abcdef", + "X-Hux-Surface": "chat", + "X-Hux-Relay-Key": "rk", +} + + +def _router(tmp_path: Path, **extra: str) -> Router: + return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", **extra}) + + +def _start(router: Router): + server = serve(router, "127.0.0.1", 0) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server + + +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: + client.sendall(request) + chunks = [] + while chunk := client.recv(4096): + chunks.append(chunk) + return b"".join(chunks) + + +def test_declared_body_is_rejected_before_socket_read(tmp_path): + """An oversized Content-Length receives 413 without waiting for the claimed body.""" + server = _start(_router(tmp_path)) + try: + conn = HTTPConnection("127.0.0.1", server.server_address[1], timeout=1) + conn.putrequest("POST", "/hux/v1/capabilities") + conn.putheader("Content-Length", str(1024 * 1024 + 1)) + conn.endheaders() + reply = conn.getresponse() + body = json.loads(reply.read()) + assert (reply.status, body["code"]) == (413, "too_large") + assert reply.getheader("Cache-Control") == "no-store" + finally: + server.shutdown() + server.server_close() + + +def test_incomplete_body_times_out_and_transfer_encoding_is_rejected(tmp_path): + """Accepted sockets have a deadline, and unsupported framing fails closed.""" + server = _start(_router(tmp_path, HUX_REQUEST_TIMEOUT_SECONDS="0.1")) + try: + reply = _raw_request( + server.server_address, + b"POST /hux/v1/capabilities HTTP/1.0\r\nContent-Length: 2\r\n\r\n{", + ) + assert b" 400 " in reply and b"request body is incomplete or timed out" in reply + conn = HTTPConnection("127.0.0.1", server.server_address[1], timeout=1) + conn.putrequest("POST", "/hux/v1/capabilities") + conn.putheader("Transfer-Encoding", "chunked") + conn.endheaders() + assert conn.getresponse().status == 400 + finally: + server.shutdown() + server.server_close() + + +def test_malformed_duplicate_and_absurd_content_lengths_fail_closed(tmp_path): + """Ambiguous or computationally large length headers never reach ``int`` or a body allocation.""" + server = _start(_router(tmp_path)) + try: + malformed = _raw_request( + server.server_address, + b"POST /hux/v1/capabilities HTTP/1.0\r\nContent-Length: nope\r\n\r\n", + ) + duplicate = _raw_request( + server.server_address, + b"POST /hux/v1/capabilities HTTP/1.0\r\nContent-Length: 0\r\nContent-Length: 0\r\n\r\n", + ) + absurd = _raw_request( + server.server_address, + b"POST /hux/v1/capabilities HTTP/1.0\r\nContent-Length: 99999999999\r\n\r\n", + ) + assert b" 400 " in malformed and b" 400 " in duplicate + assert b" 413 " in absurd + finally: + server.shutdown() + server.server_close() + + +def test_rate_limit_is_per_subject_and_method_class(tmp_path): + """Read and write buckets are distinct and return a bounded Retry-After.""" + router = _router(tmp_path, HUX_READS_PER_MINUTE="1", HUX_WRITES_PER_MINUTE="1") + first = router.dispatch("GET", "/hux/v1/capabilities", HEADERS, b"") + limited = router.dispatch("GET", "/hux/v1/capabilities", HEADERS, b"") + write = router.dispatch("POST", "/hux/v1/capabilities", HEADERS, b"{}") + assert first.status == 200 + assert (limited.status, limited.body["code"]) == (429, "rate_limited") + assert 1 <= int(limited.headers["Retry-After"]) <= 60 + assert write.status == 405, "method routing precedes the write rate bucket" + live = _start(_router(tmp_path / "live", HUX_READS_PER_MINUTE="1")) + try: + conn = HTTPConnection("127.0.0.1", live.server_address[1], timeout=1) + conn.request("GET", "/hux/v1/capabilities", headers=HEADERS) + conn.getresponse().read() + conn.request("GET", "/hux/v1/capabilities", headers=HEADERS) + reply = conn.getresponse() + reply.read() + assert reply.status == 429 and reply.getheader("Retry-After") + assert reply.getheader("Cache-Control") == "no-store" + finally: + live.shutdown() + live.server_close() + + +def test_limiter_expires_windows_and_invalid_settings_use_safe_defaults(tmp_path, monkeypatch): + """Expired subjects are evicted and malformed operator settings cannot disable bounds.""" + moments = iter((0.0, 0.0, 61.0)) + limiter = RateLimiter(1, 1, clock=lambda: next(moments)) + assert limiter.check("a", "GET") is None + assert limiter.check("a", "GET") == 60 + assert limiter.check("a", "GET") is None + monkeypatch.setattr(hux_http, "MAX_RATE_BUCKETS", 1) + bounded = RateLimiter(2, 2, clock=lambda: 0.0) + assert bounded.check("a", "GET") is None + assert bounded.check("b", "GET") == 60 + router = _router( + tmp_path, + HUX_READS_PER_MINUTE="nan", + HUX_WRITES_PER_MINUTE="0", + HUX_REQUEST_TIMEOUT_SECONDS="forever", + ) + assert router.rate_limiter.limits == {"read": 300, "write": 30} + assert router.request_timeout == DEFAULT_REQUEST_TIMEOUT_SECONDS + + +def test_health_and_json_are_no_store_and_health_refuses_bodies(tmp_path): + """Sensitive JSON never enters browser caches; health cannot be used for body smuggling.""" + server = _start(_router(tmp_path)) + try: + conn = HTTPConnection("127.0.0.1", server.server_address[1], timeout=1) + conn.request("GET", "/healthz") + reply = conn.getresponse() + reply.read() + assert reply.getheader("Cache-Control") == "no-store" + conn.request("GET", "/healthz", body=b"x", headers={"Content-Length": "1"}) + assert conn.getresponse().status == 413 + finally: + server.shutdown() + server.server_close() diff --git a/testing/tests/test_hermes_hux_memory_ledger.py b/testing/tests/test_hermes_hux_memory_ledger.py index 6dcaf4b6..e6d415a1 100644 --- a/testing/tests/test_hermes_hux_memory_ledger.py +++ b/testing/tests/test_hermes_hux_memory_ledger.py @@ -24,7 +24,7 @@ from hux import audit, contracts, events, identity, memory, store # noqa: E402 from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() -HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"} +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"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) CONV = "conv_0001abcd" @@ -37,7 +37,7 @@ def ident(**overrides) -> identity.Identity: def router_for(tmp_path): - return build_router(tmp_path, {"HUX_FLAGS": ALL_ON}) + return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) def call(router, method, path, headers=HEADERS, body=None): @@ -256,7 +256,7 @@ def test_retrieval_removal_and_restore(tmp_path): def test_decisions_need_a_human_surface(tmp_path): - router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk"}) + router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk"}) _, proposed, _ = propose(router) worker = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"} status, body, _ = call(router, "POST", f"/hux/v1/memory/{proposed['id']}/approve", worker) diff --git a/testing/tests/test_hermes_hux_memory_repairs.py b/testing/tests/test_hermes_hux_memory_repairs.py index f2e6f449..e3c8c4da 100644 --- a/testing/tests/test_hermes_hux_memory_repairs.py +++ b/testing/tests/test_hermes_hux_memory_repairs.py @@ -23,7 +23,7 @@ from hux.errors import Conflict # noqa: E402 from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() -HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"} +HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) USER = {"proposed_by": "user", "approval_mode": "automatic"} @@ -33,7 +33,7 @@ def ident() -> identity.Identity: def router_for(tmp_path): - return build_router(tmp_path, {"HUX_FLAGS": ALL_ON}) + return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) def call(router, method, path, body=None, headers=None): diff --git a/testing/tests/test_hermes_hux_memory_retrieval.py b/testing/tests/test_hermes_hux_memory_retrieval.py index ea72cadd..5b9436ec 100644 --- a/testing/tests/test_hermes_hux_memory_retrieval.py +++ b/testing/tests/test_hermes_hux_memory_retrieval.py @@ -21,7 +21,7 @@ from hux import contracts, events, identity, memory, privacy, store # noqa: E40 from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() -HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"} +HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) CONV = "conv_0001abcd" @@ -31,7 +31,7 @@ def ident() -> identity.Identity: def router_for(tmp_path): - return build_router(tmp_path, {"HUX_FLAGS": ALL_ON}) + return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) def call(router, method, path, headers=HEADERS, body=None): diff --git a/testing/tests/test_hermes_hux_policy_approvals.py b/testing/tests/test_hermes_hux_policy_approvals.py index 932a8d16..23423d57 100644 --- a/testing/tests/test_hermes_hux_policy_approvals.py +++ b/testing/tests/test_hermes_hux_policy_approvals.py @@ -29,7 +29,7 @@ from hux import events as hux_events # noqa: E402 from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() -HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"} +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"} RELAY = {**HEADERS, "X-Hux-Surface": "telegram", "X-Hux-Trust": "relay", "X-Hux-Relay-Key": "rk"} @@ -42,7 +42,7 @@ OTHER_HASH = "sha256:" + "cd" * 32 @pytest.fixture def router(tmp_path): - return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk", "HUX_RELAY_KEY": "rk"}) + return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk", "HUX_RELAY_KEY": "rk"}) @pytest.fixture @@ -152,7 +152,7 @@ def test_only_humans_on_router_or_relay_decide(router, tmp_path): status, body = call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"}, RELAY) assert status == 200 and valid(body)["status"] == "approved" assert body["decision"]["by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]} - rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))) + rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))) assert [(r["action"], r["outcome"]) for r in rows if r["action"] == "approvals.decide"] == [("approvals.decide", "deny")] * 2 + [("approvals.decide", "allow")] @@ -233,7 +233,7 @@ 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 == 201, "another run keeps its own budget" + assert (status, body["code"]) == (429, "budget_exhausted"), "run rotation cannot reset conversation spend" # --- gate -------------------------------------------------------------------------- @@ -260,7 +260,7 @@ def test_gate_blocks_before_approval_and_releases_once_exactly_once(router, even assert released["evidence"] == [{"kind": "approval", "id": record["id"]}] and released["run_id"] == "run_9f" served = call(router, "GET", f"/hux/v1/approvals/{record['id']}")[1] assert valid(served) and "_consumed_at" not in served - rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))) + rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))) assert [r["outcome"] for r in rows if r["action"] == "gate.check"] == ["deny", "deny", "deny", "deny", "allow", "deny"] @@ -269,10 +269,21 @@ 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")[1]["proceed"] is True, "unknown run: the approval's conversation applies (F4)" + 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" + call(router, "POST", "/hux/v1/runs/run_later/budget", {"conversation_id": CONV}, WORKER) + assert gate(router, run_id="run_later", capability="shell")[1]["proceed"] is False, "the old approval record never spans runs" + status, fresh = call(router, "POST", "/hux/v1/approvals", request_body("shell", run_id="run_later"), WORKER) + assert status == 201 and fresh["status"] == "approved" and fresh["id"] != record["id"] + assert gate(router, run_id="run_later", capability="shell")[1]["approval_id"] == fresh["id"] 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)" + elsewhere = request_body("shell", run_id="run_elsewhere") + elsewhere["conversation_id"] = "conv_elsewhere01" + status, scoped = call(router, "POST", "/hux/v1/approvals", elsewhere, WORKER) + assert status == 201 and scoped["status"] == "pending", "a session grant never crosses conversations" assert gate(router, capability="write_files")[1]["proceed"] is False @@ -351,9 +362,34 @@ def test_gate_refuses_when_the_run_budget_is_exhausted(router, events): 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 gate(router, run_id="run_fresh", capability="shell")[1]["proceed"] is False, "unknown runs inherit nothing" + call(router, "POST", "/hux/v1/runs/run_fresh/budget", {"conversation_id": CONV}, WORKER) + assert gate(router, run_id="run_fresh", capability="shell")[1]["reason"] == "budget_exhausted" + status, error = call(router, "POST", "/hux/v1/approvals", request_body("shell", run_id="run_fresh"), WORKER) + assert (status, error["code"]) == (429, "budget_exhausted") + call(router, "POST", "/hux/v1/runs/run_other/budget", {"conversation_id": "conv_other0001"}, WORKER) + assert gate(router, run_id="run_other", capability="shell")[1]["proceed"] is False assert budgets.run_conversation(router_store(router), "run_nobody") is None +def test_conversation_budget_aggregates_across_runs_and_policy_revision_resets(router): + """Run rotation cannot reset spend; an explicit human policy revision starts a new budget epoch.""" + policy_body = { + "scope": {"level": "conversation", "scope_id": CONV}, + "autonomy": "safe", + "budgets": {"tool_calls_per_run": 3}, + } + assert call(router, "PUT", "/hux/v1/policy", policy_body)[0] == 200 + first = call(router, "POST", "/hux/v1/runs/run_a/budget", {"conversation_id": CONV, "tool_calls": 2}, WORKER)[1] + second = call(router, "POST", "/hux/v1/runs/run_b/budget", {"conversation_id": CONV, "tool_calls": 1}, WORKER)[1] + assert first["spent"]["tool_calls"] == 2 + assert second["spent"]["tool_calls"] == 3 and second["exhausted"] == ["tool_calls_per_run"] + assert gate(router, run_id="run_b", capability="shell")[1]["reason"] == "budget_exhausted" + revised = call(router, "PUT", "/hux/v1/policy", policy_body)[1] + assert revised["revision"] == 2 + reset = call(router, "GET", "/hux/v1/runs/run_b/budget", headers=WORKER)[1] + assert reset["spent"]["tool_calls"] == 0 and reset["exhausted"] == [] + + def router_store(router): - return store.TenantStore(router.data_root, identity.resolve(HEADERS, {})) + return store.TenantStore(router.data_root, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"})) diff --git a/testing/tests/test_hermes_hux_policy_hook.py b/testing/tests/test_hermes_hux_policy_hook.py index 142c5545..c7b8ca3f 100644 --- a/testing/tests/test_hermes_hux_policy_hook.py +++ b/testing/tests/test_hermes_hux_policy_hook.py @@ -42,7 +42,7 @@ RUN = "run_hook_1" def start(tmp_path: Path, flags: str = ALL_ON) -> tuple[str, object]: """Run the real service on an ephemeral loopback port for one test.""" - router = build_router(tmp_path, {"HUX_FLAGS": flags, "HUX_WORKER_KEY": "wk"}) + router = build_router(tmp_path, {"HUX_FLAGS": flags, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk"}) server = serve(router, "127.0.0.1", 0) threading.Thread(target=server.serve_forever, daemon=True).start() return f"http://127.0.0.1:{server.server_address[1]}", server @@ -51,7 +51,7 @@ def start(tmp_path: Path, flags: str = ALL_ON) -> tuple[str, object]: @pytest.fixture def service(tmp_path): base, server = start(tmp_path) - human = HuxClient(base, HUMAN) + human = HuxClient(base, HUMAN, key="rk") conversation = human.post("/hux/v1/conversations", {"title": "hook test"}).body["id"] yield {"base": base, "root": tmp_path, "agent": HuxClient(base, IDENTITY, key="wk"), "human": human, "conv": conversation} server.shutdown() diff --git a/testing/tests/test_hermes_hux_policy_matrix.py b/testing/tests/test_hermes_hux_policy_matrix.py index 895f12a8..448b8db0 100644 --- a/testing/tests/test_hermes_hux_policy_matrix.py +++ b/testing/tests/test_hermes_hux_policy_matrix.py @@ -24,7 +24,7 @@ from hux import audit, contracts, errors, identity, policy, rules, store # noqa from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() -HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"} +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"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) T0 = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc) @@ -32,7 +32,7 @@ T0 = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc) @pytest.fixture def router(tmp_path): - return build_router(tmp_path, {"HUX_FLAGS": ALL_ON}) + return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) @pytest.fixture @@ -65,7 +65,7 @@ def test_first_read_creates_the_safe_global_default(router, tmp_path): assert body["scope"] == {"level": "global"} and body["autonomy"] == "safe" and body["grants"] == [] assert body["owner"] == HEADERS["X-Hux-Subject"] and body["provenance"]["actor"] == {"type": "user", "id": body["owner"]} assert call(router, "GET", "/hux/v1/policy?scope=global")[1] == body - rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))) + rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))) assert [r["action"] for r in rows] == ["policy.read", "policy.read"] @@ -120,7 +120,7 @@ def test_put_honours_if_match_and_audits_unconditional_writes(router, tmp_path): assert (status, body["code"]) == (409, "conflict") status, body, _ = call(router, "PUT", "/hux/v1/policy", put) assert (status, body["revision"]) == (200, 3) - rows = [(r["action"], r["outcome"], r.get("reason", "")) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {})))] + rows = [(r["action"], r["outcome"], r.get("reason", "")) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"})))] assert rows == [("policy.write", "allow", ""), ("policy.write", "allow", ""), ("policy.write", "conflict", rows[2][2]), ("policy.write", "allow", "unconditional_write")] assert "does not match" in rows[2][2] @@ -176,7 +176,7 @@ def test_second_subject_sees_its_own_default_not_the_first_tenants_policy(router def test_flag_off_hides_the_whole_card(tmp_path): - off = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation"}) + off = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation", "HUX_ROUTER_KEY": "rk"}) status, body, _ = call(off, "GET", "/hux/v1/policy") assert (status, body["code"]) == (404, "flag_off") @@ -195,7 +195,7 @@ def test_helpers_round_trip_time_and_actors(): 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"}) + router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", "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"} @@ -209,7 +209,7 @@ def test_only_a_human_actor_may_write_policy_or_hold_allow_grants(tmp_path): 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"] + rows = [(r["action"], r["outcome"]) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))) 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") diff --git a/testing/tests/test_hermes_hux_policy_receipts.py b/testing/tests/test_hermes_hux_policy_receipts.py index 55f1fb3c..a073bd24 100644 --- a/testing/tests/test_hermes_hux_policy_receipts.py +++ b/testing/tests/test_hermes_hux_policy_receipts.py @@ -25,7 +25,7 @@ from hux import events as hux_events # noqa: E402 from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() -HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"} +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"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) @@ -34,7 +34,7 @@ CONV = "conv_0001abcd" @pytest.fixture def router(tmp_path): - return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk"}) + return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk"}) @pytest.fixture @@ -112,7 +112,7 @@ def test_stop_writes_a_receipt_and_a_second_stop_returns_it(router, events, tmp_ assert (status, again) == (200, body) assert [e["kind"] for e in events] == ["run.cancelled"] assert events[0]["evidence"] == [{"kind": "run", "id": body["id"]}] and events[0]["run_id"] == "run_9f" - rows = [(r["action"], r.get("reason")) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))) if r["action"] == "runs.stop"] + rows = [(r["action"], r.get("reason")) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))) if r["action"] == "runs.stop"] assert rows == [("runs.stop", "cancelled"), ("runs.stop", "replayed")] @@ -126,7 +126,7 @@ def test_only_the_gateway_may_vouch_for_an_empty_registry_and_a_failed_receipt_c 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, {})) + 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)" status, third = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False}) diff --git a/testing/tests/test_hermes_hux_privacy_retention.py b/testing/tests/test_hermes_hux_privacy_retention.py index 0d23daaa..ef1113f6 100644 --- a/testing/tests/test_hermes_hux_privacy_retention.py +++ b/testing/tests/test_hermes_hux_privacy_retention.py @@ -24,7 +24,7 @@ from hux import audit, contracts, errors, events, identity, memory, privacy, sto from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() -HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"} +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"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) CONV = "conv_0001abcd" @@ -35,7 +35,7 @@ def ident() -> identity.Identity: def router_for(tmp_path): - return build_router(tmp_path, {"HUX_FLAGS": ALL_ON}) + return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) def call(router, method, path, headers=HEADERS, body=None): @@ -117,7 +117,7 @@ def test_audit_route_lists_newest_first_and_is_tenant_scoped(tmp_path): def test_retention_runs_without_flags(tmp_path): - router = build_router(tmp_path, {"HUX_FLAGS": ""}) + router = build_router(tmp_path, {"HUX_FLAGS": "", "HUX_ROUTER_KEY": "rk"}) s = tenant(tmp_path) assert call(router, "GET", "/hux/v1/privacy/audit")[0] == 404 assert counts(privacy.run_retention(s))["report"] == 1 diff --git a/testing/tests/test_hermes_hux_privacy_topics.py b/testing/tests/test_hermes_hux_privacy_topics.py index 2aec7ec2..ee1d0036 100644 --- a/testing/tests/test_hermes_hux_privacy_topics.py +++ b/testing/tests/test_hermes_hux_privacy_topics.py @@ -23,7 +23,7 @@ from hux import audit, contracts, events, identity, privacy, rules, store # noq from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() -HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"} +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"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) CONV = "conv_0001abcd" @@ -34,7 +34,7 @@ def ident() -> identity.Identity: def router_for(tmp_path): - return build_router(tmp_path, {"HUX_FLAGS": ALL_ON}) + return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) def call(router, method, path, headers=HEADERS, body=None): @@ -165,7 +165,7 @@ def test_forget_unknown_or_foreign_conversation_is_404(tmp_path): def test_flag_off_hides_privacy_routes(tmp_path): - router = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation"}) + router = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation", "HUX_ROUTER_KEY": "rk"}) assert call(router, "GET", "/hux/v1/privacy/policy")[1]["code"] == "flag_off" diff --git a/testing/tests/test_hermes_hux_research_citations.py b/testing/tests/test_hermes_hux_research_citations.py index 7a27d8aa..a47902f3 100644 --- a/testing/tests/test_hermes_hux_research_citations.py +++ b/testing/tests/test_hermes_hux_research_citations.py @@ -26,7 +26,7 @@ from hux import audit, contracts, identity, research, store # noqa: E402 from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() -OWNER = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"} +OWNER = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"} OTHER = {**OWNER, "X-Hux-Subject": "usr_fedcba9876543210"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) @@ -37,7 +37,7 @@ def tenant(router, subject="usr_0123456789abcdef") -> store.TenantStore: @pytest.fixture def router(tmp_path): - return build_router(tmp_path, {"HUX_FLAGS": ALL_ON}) + return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) def call(router, method, path, body=None, headers=None, raw=None): @@ -246,7 +246,7 @@ def test_research_modules_stay_under_500_lines(): def test_worker_trust_records_a_system_actor(tmp_path): - router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk"}) + router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk"}) headers = {"X-Hux-Trust": "worker", "X-Hux-Surface": "worker", "X-Hux-Relay-Key": "wk"} status, record, _ = call(router, "POST", "/hux/v1/sources", {"kind": "tool_output", "title": "grep"}, headers) assert status == 201 and record["provenance"] == {"surface": "worker", "actor": {"type": "system", "id": "worker"}, "recorded_at": record["retrieved_at"]} diff --git a/testing/tests/test_hermes_hux_research_notebook.py b/testing/tests/test_hermes_hux_research_notebook.py index 62fe5e4d..e328ebda 100644 --- a/testing/tests/test_hermes_hux_research_notebook.py +++ b/testing/tests/test_hermes_hux_research_notebook.py @@ -24,14 +24,14 @@ from hux import audit, contracts, identity, store # noqa: E402 from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() -OWNER = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"} +OWNER = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"} OTHER = {**OWNER, "X-Hux-Subject": "usr_fedcba9876543210"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) @pytest.fixture def router(tmp_path): - return build_router(tmp_path, {"HUX_FLAGS": ALL_ON}) + return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) def call(router, method, path, body=None, headers=None, raw=None): diff --git a/testing/tests/test_quality_contract.py b/testing/tests/test_quality_contract.py index 134485bf..c5c4ca67 100644 --- a/testing/tests/test_quality_contract.py +++ b/testing/tests/test_quality_contract.py @@ -33,6 +33,35 @@ def test_handoff_modules_are_exactly_managed_linted_and_covered(): assert "scripts/ops/hermes_handoff_*.py" in contract["hygiene"]["line_limit_globs"] +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/budgets.py", + "dockerfiles/hermes-hux-foundation/hux/contracts.py", + "dockerfiles/hermes-hux-foundation/hux/errors.py", + "dockerfiles/hermes-hux-foundation/hux/flags.py", + "dockerfiles/hermes-hux-foundation/hux/http.py", + "dockerfiles/hermes-hux-foundation/hux/identity.py", + "dockerfiles/hermes-hux-foundation/hux/policy.py", + "dockerfiles/hermes-hux-foundation/hux/redaction.py", + "dockerfiles/hermes-hux-foundation/hux/rules.py", + "dockerfiles/hermes-worker-hux/hux_hook/client.py", + "dockerfiles/hermes-worker-hux/hux_hook/hooks.py", + } + for paths in ( + contract["managed_modules"], + contract["lint_paths"], + contract["coverage"]["tracked_files"], + contract["coverage"]["branch_tracked_files"], + ): + assert expected <= set(paths) + assert "services/hermes/scripts/hux_contracts.py" not in paths + assert "services/hermes/scripts/hux_policy.py" not in paths + assert "dockerfiles/hermes-hux-foundation/hux/*.py" in contract["hygiene"]["line_limit_globs"] + assert "dockerfiles/hermes-worker-hux/hux_hook/*.py" in contract["hygiene"]["line_limit_globs"] + + def test_docs_check_reports_missing_docstring_and_missing_path(tmp_path: Path): module_path = tmp_path / "managed.py" module_path.write_text("value = 1\n", encoding="utf-8")