security(hux): converge backend trust boundaries

This commit is contained in:
jenkins 2026-08-24 01:58:14 -03:00
parent f59f24a427
commit a260506983
17 changed files with 575 additions and 76 deletions

View File

@ -0,0 +1 @@
"""Hermes User Experience foundation service package."""

View File

@ -9,6 +9,7 @@ actually happened to the run and its side effects (SO-41).
from __future__ import annotations
import re
from typing import Any
from hux import policy, rules
@ -24,14 +25,19 @@ LIMIT_OF = {
"tokens": "tokens_per_run", "tool_calls": "tool_calls_per_run", "wall_clock_seconds": "wall_clock_seconds",
"delegations": "delegations_per_run", "spend_units": "spend_units", "subagents": "subagents_per_run",
}
RUN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,120}$")
def checked_run_id(value: Any) -> str:
"""Return a canonical run id accepted by both body and path APIs."""
if not isinstance(value, str) or not RUN_ID_RE.fullmatch(value):
raise Invalid("run id is malformed or too long")
return value
def run_id_from(request: Request) -> str:
"""The run id in the path; the router already bounded its alphabet."""
run_id = request.params["id"]
if len(run_id) > 120:
raise Invalid("run id too long")
return run_id
return checked_run_id(request.params["id"])
# -- budgets -------------------------------------------------------------------
@ -43,6 +49,7 @@ def exhausted(spent: dict[str, int], limits: dict[str, Any]) -> list[str]:
def budget_state(store: TenantStore, identity: Identity, run_id: str, conversation_id: str | None = None) -> dict[str, Any]:
"""Current state for a run, enforced against its conversation's policy epoch aggregate."""
run_id = checked_run_id(run_id)
doc_id = f"bud_{policy.run_key(run_id)}"
stored = store.get(BUDGETS, doc_id) if store.exists(BUDGETS, doc_id) else {"spent": {}, "_conversation_id": None}
known_conversation = stored.get("_conversation_id") or run_conversation(store, run_id)
@ -53,7 +60,8 @@ def budget_state(store: TenantStore, identity: Identity, run_id: str, conversati
effective = policy.effective_policy(store, identity, level, scope_id)
limits = {k: v for k, v in effective["budgets"].items() if k != "scope"}
epoch = str(effective.get("_budget_epoch") or f"{effective['id']}:{effective['revision']}")
run_spent = {k: int(stored["spent"].get(k, 0)) for k in SPEND_KEYS}
same_epoch = stored.get("_budget_epoch") == epoch
run_spent = {k: int(stored["spent"].get(k, 0)) if same_epoch else 0 for k in SPEND_KEYS}
aggregate = {k: 0 for k in SPEND_KEYS}
for record in store.scan(BUDGETS):
if record.get("_conversation_id") != conversation_id or record.get("_budget_epoch") != epoch:
@ -76,6 +84,7 @@ def get_budget(request: Request) -> Response:
def post_budget(request: Request) -> Response:
"""``POST /hux/v1/runs/{id}/budget``: add spend increments; emits budget.exhausted on the crossing."""
policy.require_worker(request.identity, "budget reports")
body = policy.body_dict(request)
run_id = run_id_from(request)
conversation_id = body.get("conversation_id")
@ -113,13 +122,10 @@ def hashes_of(approval: dict[str, Any]) -> set[str]:
def run_conversation(store: TenantStore, run_id: str) -> str | None:
"""The conversation a run belongs to, from its budget document or an approval it raised; never the body (F4)."""
"""The conversation a trusted Worker bound to this run in its budget document (F4)."""
doc_id = f"bud_{policy.run_key(run_id)}"
if store.exists(BUDGETS, doc_id) and store.get(BUDGETS, doc_id).get("_conversation_id"):
return store.get(BUDGETS, doc_id)["_conversation_id"]
for record in store.scan(policy.APPROVALS):
if record["run_id"] == run_id:
return record["conversation_id"]
return None
@ -162,6 +168,7 @@ def matching_approval(store: TenantStore, run_id: str, capability: str, argument
def gate(request: Request) -> Response:
"""``POST /hux/v1/runs/{id}/gate``: may this side effect proceed right now?"""
policy.require_worker(request.identity, "gate checks")
body = policy.body_dict(request)
run_id = run_id_from(request)
capability = body.get("capability")
@ -193,14 +200,12 @@ def gate(request: Request) -> Response:
# -- stop ----------------------------------------------------------------------
def stop_outcome(request: Request, body: dict[str, Any]) -> tuple[str, str]:
"""(outcome, reason) for a stop; only the gateway (worker trust) may vouch for an empty process registry (F8, SO-41)."""
def stop_outcome(body: dict[str, Any]) -> tuple[str, str]:
"""Return the cancellation outcome proven by the gateway's process registry (F8, SO-41)."""
if body.get("already_complete"):
return "already_complete", "already_complete"
if body.get("process_registry_empty") is not True:
return "failed_to_cancel", "process_registry_not_empty"
if request.identity.trust != "worker":
return "failed_to_cancel", "registry_state_not_from_gateway"
return "cancelled", "cancelled"
@ -211,13 +216,14 @@ def stop(request: Request) -> Response:
stop that really cancels or finds the run complete supersedes it with a
revision bump; an identical repeat still replays (F8).
"""
policy.require_worker(request.identity, "stop receipts")
body = policy.body_dict(request)
run_id = run_id_from(request)
receipt_id = f"rcpt_{policy.run_key(run_id)}"
side_effects = body.get("side_effects", [])
if not isinstance(side_effects, list):
raise Invalid("side_effects must be a list")
outcome, reason = stop_outcome(request, body)
outcome, reason = stop_outcome(body)
with request.store.lock(RECEIPTS):
existing = request.store.get(RECEIPTS, receipt_id) if request.store.exists(RECEIPTS, receipt_id) else None
if existing is not None and (existing["outcome"] != "failed_to_cancel" or outcome == "failed_to_cancel"):

View File

@ -64,11 +64,25 @@ class Flags:
def __init__(self, environ: Mapping[str, str] | None = None) -> None:
self._environ = dict(os.environ if environ is None else environ)
self._registry = flag_registry()
self._route_cards = frozenset(card for card, routes in CARD_ROUTES.items() if routes)
def bind_routes(self, routes: Mapping[str, set[str]]) -> None:
"""Bind capability flags to the route templates actually registered by this process."""
unknown = set(routes) - set(self._registry)
if unknown:
raise ValueError(f"routes registered for unknown cards: {sorted(unknown)}")
for card, actual in routes.items():
undeclared = actual - set(CARD_ROUTES.get(card, []))
if undeclared:
raise ValueError(f"undeclared routes for {card}: {sorted(undeclared)}")
self._route_cards = frozenset(
card for card, declared in CARD_ROUTES.items() if declared and set(declared) == routes.get(card, set())
)
def enabled(self, card: str) -> bool:
"""True only for a route-backed card whose configured flag chain is on."""
entry = self._registry.get(card)
return bool(entry) and bool(CARD_ROUTES.get(card)) and flag_enabled(entry["flag"], self._environ)
return bool(entry) and card in self._route_cards and flag_enabled(entry["flag"], self._environ)
def require(self, card: str) -> None:
"""Raise FlagOff unless the card is enabled."""

View File

@ -28,6 +28,7 @@ from hux.identity import Identity, resolve
from hux.store import TenantStore
MAX_BODY_BYTES = 1024 * 1024
MAX_QUERY_FIELDS = 64
DEFAULT_REQUEST_TIMEOUT_SECONDS = 10.0
DEFAULT_READS_PER_MINUTE = 300
DEFAULT_WRITES_PER_MINUTE = 30
@ -161,6 +162,13 @@ class Router:
"""Register a handler; ``action`` is the audit action name (family.verb), ``max_body`` its byte cap."""
self.routes.append(Route(method, template, card, action, handler, max_body))
def bind_capability_routes(self) -> None:
"""Make feature negotiation depend on the route templates this process really registered."""
routes: dict[str, set[str]] = {}
for route in self.routes:
routes.setdefault(route.card, set()).add(route.template)
self.flags.bind_routes(routes)
def match(self, method: str, path: str) -> tuple[Route | None, dict[str, str], bool]:
"""Return (route, params, path_known)."""
known = False
@ -180,37 +188,44 @@ class Router:
def dispatch(self, method: str, raw_path: str, headers: Mapping[str, str], body: bytes) -> Response:
"""Run the full pipeline and never raise."""
parts = urlsplit(raw_path)
query = {k: v[-1] for k, v in parse_qs(parts.query).items()}
route: Route | None = None
try:
identity = resolve(headers, self.environ)
store = TenantStore(self.data_root, identity)
except HuxError as error:
return Response(error.status, error.record())
route, params, known = self.match(method, parts.path)
if route is None:
error = Invalid("method not allowed") if known else NotFound("no such route")
audit.record(store, identity, "http.route", parts.path, "not_found", error.message)
return Response(405 if known else 404, error.record())
try:
retry_after = self.rate_limiter.check(identity.subject, method)
if retry_after is not None:
raise RateLimited(retry_after)
route, params, known = self.match(method, parts.path)
if route is None:
error = Invalid("method not allowed") if known else NotFound("no such route")
audit.record(store, identity, "http.route", parts.path, "not_found", error.message)
return Response(405 if known else 404, error.record())
# SO-08: the worker allowlist is checked before the flag so a
# worker cannot even learn which cards are on.
if identity.trust == "worker" and not worker_may_call(method, route.template):
raise Forbidden("route is not available to worker trust")
retry_after = self.rate_limiter.check(identity.subject, method)
if retry_after is not None:
raise RateLimited(retry_after)
self.flags.require(route.card)
try:
parsed_query = parse_qs(parts.query, max_num_fields=MAX_QUERY_FIELDS)
except ValueError as error:
raise Invalid("query has too many fields") from error
query = {key: values[-1] for key, values in parsed_query.items()}
payload = self._decode(body, route.max_body)
request = Request(method, parts.path, params, query, headers, payload, identity, store, self.flags, self.build)
response = route.handler(request)
except HuxError as error:
outcome = {"flag_off": "flag_off", "conflict": "conflict", "not_found": "not_found"}.get(error.code, "deny")
audit.record(store, identity, route.action, parts.path, outcome, error.message)
action = route.action if route is not None else "http.route"
audit.record(store, identity, action, parts.path, outcome, error.message)
headers = {"Retry-After": str(error.retry_after)} if isinstance(error, RateLimited) else {}
return Response(error.status, error.record(), headers)
except Exception: # noqa: BLE001 - the pipeline never raises; anything else is a 500 with no detail leaked
error = HuxError("internal error")
audit.record(store, identity, route.action, parts.path, "deny", error.message)
action = route.action if route is not None else "http.route"
audit.record(store, identity, action, parts.path, "deny", error.message)
return Response(error.status, error.record())
return response
@ -274,6 +289,7 @@ def make_handler(router: Router) -> type[BaseHTTPRequestHandler]:
self.send_response(response.status)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
for key, value in response.headers.items():
self.send_header(key, value)
self.end_headers()
@ -286,6 +302,7 @@ def make_handler(router: Router) -> type[BaseHTTPRequestHandler]:
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
for key, value in response.headers.items():
self.send_header(key, value)
self.end_headers()

View File

@ -11,6 +11,8 @@ from __future__ import annotations
import hmac
import os
import re
import stat
import tempfile
from dataclasses import dataclass
from collections.abc import Mapping
from pathlib import Path
@ -29,6 +31,8 @@ SURFACES = ("chat", "worker", "telegram", "voice", "api")
TRUSTS = ("router", "relay", "worker")
KEY_ENV = {"router": "HUX_ROUTER_KEY", "relay": "HUX_RELAY_KEY", "worker": "HUX_WORKER_KEY"}
MAX_KEY_BYTES = 4096
MAX_SUBJECT_BYTES = 128
SUBJECT_BINDING_ENV = "HUX_SUBJECT_BINDING_FILE"
@dataclass(frozen=True)
@ -58,7 +62,11 @@ def _expected_key(environ: Mapping[str, str], trust: str) -> str:
key_file = environ.get(f"{name}_FILE", "")
if key_file:
try:
data = Path(key_file).read_bytes()
path = Path(key_file)
mode = path.stat().st_mode
if not stat.S_ISREG(mode) or stat.S_IMODE(mode) != 0o400:
return ""
data = path.read_bytes()
except OSError:
return ""
if len(data) > MAX_KEY_BYTES:
@ -70,6 +78,84 @@ def _expected_key(environ: Mapping[str, str], trust: str) -> str:
return environ.get(name, "")
def _read_subject_binding(path: Path) -> str | None:
"""Read a complete, permission-constrained binding without following links."""
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except FileNotFoundError:
return None
except OSError as error:
raise Unauthorized("subject binding is unavailable") from error
try:
mode = os.fstat(descriptor).st_mode
if not stat.S_ISREG(mode) or stat.S_IMODE(mode) not in {0o400, 0o440}:
raise Unauthorized("subject binding is invalid")
with os.fdopen(descriptor, "rb", closefd=False) as binding:
data = binding.read(MAX_SUBJECT_BYTES + 1)
except OSError as error:
raise Unauthorized("subject binding is unavailable") from error
finally:
os.close(descriptor)
if not data or len(data) > MAX_SUBJECT_BYTES:
raise Unauthorized("subject binding is invalid")
try:
value = data.decode("utf-8", errors="strict").strip()
except UnicodeDecodeError as error:
raise Unauthorized("subject binding is invalid") from error
if not SUBJECT_RE.fullmatch(value):
raise Unauthorized("subject binding is invalid")
return value
def _publish_subject_binding(path: Path, subject: str) -> None:
"""Publish an immutable first-writer binding; concurrent writers never see partial data."""
descriptor = -1
temporary = ""
try:
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
os.fchmod(descriptor, 0o440)
with os.fdopen(descriptor, "wb", closefd=False) as binding:
binding.write((subject + "\n").encode("utf-8"))
binding.flush()
os.fsync(descriptor)
os.close(descriptor)
descriptor = -1
try:
os.link(temporary, path, follow_symlinks=False)
except FileExistsError:
pass
except OSError as error:
raise Unauthorized("subject binding is unavailable") from error
finally:
if descriptor >= 0:
os.close(descriptor)
if temporary:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
except OSError:
# The binding decision is still verified by a fresh read below.
pass
def _enforce_subject_binding(environ: Mapping[str, str], trust: str, subject: str) -> None:
"""Bind from an authenticated edge hop, then require every caller to match."""
raw_path = environ.get(SUBJECT_BINDING_ENV, "")
if not raw_path:
return
path = Path(raw_path)
bound = _read_subject_binding(path)
if bound is None:
if trust == "worker":
raise Unauthorized("worker subject is not bound")
_publish_subject_binding(path, subject)
bound = _read_subject_binding(path)
if bound is None or not hmac.compare_digest(bound, subject):
raise Unauthorized("subject does not match trusted binding")
def resolve(headers: Mapping[str, str], environ: Mapping[str, str] | None = None) -> Identity:
"""Build an Identity from request headers or raise Unauthorized.
@ -96,4 +182,5 @@ def resolve(headers: Mapping[str, str], environ: Mapping[str, str] | None = None
raise Unauthorized(f"{trust} key missing or wrong")
if trust == "router" and surface == "worker":
raise Unauthorized("worker surface needs worker trust")
_enforce_subject_binding(environ, trust, subject)
return Identity(slot, subject, surface, trust)

View File

@ -11,6 +11,7 @@ this module registers their routes so the family stays one card.
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timedelta, timezone
from typing import Any
@ -72,6 +73,12 @@ def require_human(identity: Identity, what: str) -> None:
raise Forbidden(f"{what} only from a human surface")
def require_worker(identity: Identity, what: str) -> None:
"""Forbidden unless the separately keyed Worker gateway is making the call."""
if identity.trust != "worker" or identity.surface != "worker":
raise Forbidden(f"{what} only from worker trust")
def actor_for(identity: Identity) -> dict[str, str]:
"""The actor a record attributes to this caller: humans are users, hops are system."""
if is_human(identity):
@ -258,11 +265,20 @@ def load_approval(store: TenantStore, approval_id: str) -> dict[str, Any]:
return refresh(store, store.get(APPROVALS, check_id(approval_id)))
def replay(store: TenantStore, key: str) -> dict[str, Any] | None:
"""The approval an Idempotency-Key already created, if any."""
def request_fingerprint(body: dict[str, Any]) -> str:
"""Canonical digest that binds an Idempotency-Key to exactly one approval request."""
canonical = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
return "sha256:" + hashlib.sha256(canonical).hexdigest()
def replay(store: TenantStore, key: str, fingerprint: str) -> dict[str, Any] | None:
"""The approval an Idempotency-Key already created, rejecting a changed request body."""
for row in store.read(APPROVALS, "idempotency"):
if row["key"] == key:
return load_approval(store, row["id"])
record = load_approval(store, row["id"])
if record.get("_request_fingerprint") != fingerprint:
raise Conflict("Idempotency-Key was already used for a different approval request")
return record
return None
@ -276,36 +292,39 @@ def resolve_request(policy: dict[str, Any], capability: str, external: bool) ->
def create_approval(request: Request) -> Response:
"""``POST /hux/v1/approvals``: the agent hook asks before a gated action."""
require_worker(request.identity, "approval requests")
body = body_dict(request)
key = request.idempotency_key()
if key:
existing = replay(request.store, key)
if existing is not None:
request.audit("approvals.create", existing["id"], reason="replayed")
return Response(200, public(existing))
conversation_id = check_id(body.get("conversation_id"))
capability = body.get("capability")
if capability not in rules.CAPABILITIES:
raise Invalid("unknown capability")
from hux import budgets # lazy: budgets imports this module
bound_conversation = budgets.run_conversation(request.store, str(body.get("run_id", "")))
state = budgets.budget_state(request.store, request.identity, str(body.get("run_id", "")), conversation_id)
run_id = budgets.checked_run_id(body.get("run_id"))
bound_conversation = budgets.run_conversation(request.store, run_id)
if bound_conversation != conversation_id:
raise Invalid("run is not authoritatively bound to this conversation")
req = body.get("request") if isinstance(body.get("request"), dict) else {}
external = bool(req.get("external", False))
evidence = req.get("evidence") if isinstance(req.get("evidence"), list) else []
if sum(1 for e in evidence if isinstance(e, dict) and e.get("kind") == "tool_call") != 1:
raise Invalid("an approval names exactly one tool_call; ask once per side effect (SO-37)")
fingerprint = request_fingerprint(body)
if key:
with request.store.lock(APPROVALS):
existing = replay(request.store, key, fingerprint)
if existing is not None:
request.audit("approvals.create", existing["id"], reason="replayed")
return Response(200, public(existing))
state = budgets.budget_state(request.store, request.identity, run_id, conversation_id)
if state["exhausted"]:
emit(request.store, request.identity, conversation_id, "budget.exhausted", f"Budget exhausted: {', '.join(state['exhausted'])}", run_id=state["run_id"])
raise BudgetExhausted("run budget exhausted", state["exhausted"])
policy = effective_policy(request.store, request.identity, "conversation", conversation_id)
req = body.get("request") if isinstance(body.get("request"), dict) else {}
external = bool(req.get("external", False))
evidence = req.get("evidence") if isinstance(req.get("evidence"), list) else []
if sum(1 for e in evidence if isinstance(e, dict) and e.get("kind") == "tool_call") > 1:
raise Invalid("an approval names exactly one tool_call; ask once per side effect (SO-37)")
decision = resolve_request(policy, capability, external)
base_decision = resolve_request({**policy, "grants": []}, capability, external)
if decision == "allow" and base_decision != "allow" and bound_conversation != conversation_id:
decision = "ask"
stamp = now()
record: dict[str, Any] = {
"schema": "hux.approval.v1", "id": new_id("apr"), "run_id": body.get("run_id"), "conversation_id": conversation_id,
"schema": "hux.approval.v1", "id": new_id("apr"), "run_id": run_id, "conversation_id": conversation_id,
"capability": capability, "request": {**req, "external": external},
"status": {"allow": "approved", "deny": "denied", "ask": "pending"}[decision],
"requested_at": iso(stamp), "expires_at": iso(stamp + APPROVAL_TTL),
@ -314,7 +333,13 @@ def create_approval(request: Request) -> Response:
record["decision"] = {"choice": "once" if decision == "allow" else "deny", "by": {"type": "system", "id": "policy"}, "at": iso(stamp)}
if key:
record["idempotency_key"] = key
record["_request_fingerprint"] = fingerprint
with request.store.lock(APPROVALS):
if key:
existing = replay(request.store, key, fingerprint)
if existing is not None:
request.audit("approvals.create", existing["id"], reason="replayed")
return Response(200, public(existing))
stored = request.store.put(APPROVALS, checked(record))
if key:
request.store.append(APPROVALS, "idempotency", {"key": key, "id": stored["id"]})

View File

@ -267,7 +267,11 @@ PAGE_LIMIT = 200
def get_conversation_privacy(request: Request) -> Response:
"""``GET /hux/v1/conversations/{id}/privacy``: forget/disable state and topics, so the hook can stop proposing memory early."""
from hux import events
conversation_id = check_id(request.params["id"])
if not events.conversation_known(request.store, conversation_id):
raise NotFound("conversation not found")
state = conversation_state(request.store, conversation_id)
mode = None
try:

View File

@ -17,14 +17,12 @@ FAMILIES = ("foundation", "events", "memory", "privacy", "organization", "artifa
def build_router(data_root: Path, environ: dict[str, str] | None = None) -> Router:
"""Create a router with every family module that is present registered."""
"""Create a router with every required family registered and route-bound to its flag."""
router = Router(data_root, environ)
for name in FAMILIES:
try:
module = importlib.import_module(f"hux.{name}")
except ModuleNotFoundError:
continue
module = importlib.import_module(f"hux.{name}")
module.register(router)
router.bind_capability_routes()
return router

View File

@ -10,12 +10,17 @@ own message only.
from __future__ import annotations
import http.client
import hmac
import json
import os
import re
import stat
import threading
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Mapping
from pathlib import Path
from typing import Any
HEADER_SLOT = "X-Hermes-Tenant-Identity"
@ -26,7 +31,10 @@ HEADER_KEY = "X-Hux-Relay-Key"
DEFAULT_BASE_URL = "http://127.0.0.1:8790"
MESSAGE_MAX = 280
MAX_RESPONSE_BYTES = 4 * 1024 * 1024
MAX_KEY_BYTES = 4096
MAX_SUBJECT_BYTES = 128
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1"})
SUBJECT_RE = re.compile(r"^usr_[0-9a-f]{16,64}$")
class _RejectRedirect(urllib.request.HTTPRedirectHandler):
@ -70,6 +78,55 @@ def _validated_path(path: str) -> str:
return path
def _key_from_file(key_file: str | Path) -> str:
"""Read one 0400 regular-file credential without accepting weak permissions or unbounded data."""
path = Path(key_file)
try:
mode = path.stat().st_mode
if not stat.S_ISREG(mode) or stat.S_IMODE(mode) != 0o400:
raise ValueError("HUX key file must be a 0400 regular file")
data = path.read_bytes()
if not data or len(data) > MAX_KEY_BYTES:
raise ValueError("HUX key file is empty or oversized")
value = data.decode("utf-8", errors="strict").strip()
if not value:
raise ValueError("HUX key file is empty")
return value
except OSError as error:
raise ValueError("HUX key file is unavailable") from error
except UnicodeDecodeError as error:
raise ValueError("HUX key file is not UTF-8") from error
def _subject_from_file(subject_file: str | Path) -> str:
"""Read one router-published subject binding from a read-only shared file."""
path = Path(subject_file)
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except OSError as error:
raise ValueError("HUX subject file is unavailable") from error
try:
mode = os.fstat(descriptor).st_mode
if not stat.S_ISREG(mode) or stat.S_IMODE(mode) not in {0o400, 0o440}:
raise ValueError("HUX subject file must be a 0400 or 0440 regular file")
with os.fdopen(descriptor, "rb", closefd=False) as subject_stream:
data = subject_stream.read(MAX_SUBJECT_BYTES + 1)
except OSError as error:
raise ValueError("HUX subject file is unavailable") from error
finally:
os.close(descriptor)
if not data or len(data) > MAX_SUBJECT_BYTES:
raise ValueError("HUX subject file is empty or oversized")
try:
value = data.decode("utf-8", errors="strict").strip()
except UnicodeDecodeError as error:
raise ValueError("HUX subject file is not UTF-8") from error
if not SUBJECT_RE.fullmatch(value):
raise ValueError("HUX subject file is malformed")
return value
class HuxServiceError(Exception):
"""The service answered with a ``hux.error.v1`` body (or a non-JSON failure)."""
@ -115,14 +172,24 @@ class HuxClient:
"""
def __init__(self, base_url: str = DEFAULT_BASE_URL, identity: Mapping[str, str] | None = None,
key: str | None = None, timeout: float = 5) -> None:
key: str | None = None, timeout: float = 5, *, key_file: str | Path | None = None,
subject_file: str | Path | None = None) -> None:
identity = dict(identity or {})
configured_subject_file = subject_file if subject_file is not None else os.environ.get("HUX_SUBJECT_FILE")
if configured_subject_file:
bound_subject = _subject_from_file(configured_subject_file)
asserted_subject = str(identity.get("subject", ""))
if asserted_subject and not hmac.compare_digest(asserted_subject, bound_subject):
raise ValueError("HUX identity subject conflicts with the trusted binding")
identity["subject"] = bound_subject
self.base_url = _validated_base_url(base_url)
self.identity = {
"tenant_slot": str(identity.get("tenant_slot", "")), "subject": str(identity.get("subject", "")),
"surface": str(identity.get("surface", "worker")), "trust": str(identity.get("trust", "worker")),
}
self._key = key
if key is not None and key_file is not None:
raise ValueError("set either key or key_file, not both")
self._key = _key_from_file(key_file) if key_file is not None else key
if isinstance(timeout, bool) or not isinstance(timeout, int | float) or not 0.1 <= float(timeout) <= 30:
raise ValueError("timeout must be between 0.1 and 30 seconds")
self.timeout = float(timeout)

View File

@ -104,15 +104,28 @@
"scripts/ops/hermes_handoff_rules.py",
"scripts/ops/hermes_handoff_run.py",
"testing/quality_handoff_mutation.py",
"dockerfiles/hermes-hux-foundation/hux/__init__.py",
"dockerfiles/hermes-hux-foundation/hux/artifacts.py",
"dockerfiles/hermes-hux-foundation/hux/audit.py",
"dockerfiles/hermes-hux-foundation/hux/budgets.py",
"dockerfiles/hermes-hux-foundation/hux/contracts.py",
"dockerfiles/hermes-hux-foundation/hux/diffs.py",
"dockerfiles/hermes-hux-foundation/hux/errors.py",
"dockerfiles/hermes-hux-foundation/hux/events.py",
"dockerfiles/hermes-hux-foundation/hux/flags.py",
"dockerfiles/hermes-hux-foundation/hux/foundation.py",
"dockerfiles/hermes-hux-foundation/hux/http.py",
"dockerfiles/hermes-hux-foundation/hux/identity.py",
"dockerfiles/hermes-hux-foundation/hux/memory.py",
"dockerfiles/hermes-hux-foundation/hux/organization.py",
"dockerfiles/hermes-hux-foundation/hux/policy.py",
"dockerfiles/hermes-hux-foundation/hux/privacy.py",
"dockerfiles/hermes-hux-foundation/hux/redaction.py",
"dockerfiles/hermes-hux-foundation/hux/research.py",
"dockerfiles/hermes-hux-foundation/hux/rules.py",
"dockerfiles/hermes-hux-foundation/hux/server.py",
"dockerfiles/hermes-hux-foundation/hux/store.py",
"dockerfiles/hermes-worker-hux/hux_hook/__init__.py",
"dockerfiles/hermes-worker-hux/hux_hook/client.py",
"dockerfiles/hermes-worker-hux/hux_hook/hooks.py"
],
@ -189,15 +202,28 @@
"scripts/ops/hermes_handoff_redaction.py",
"scripts/ops/hermes_handoff_rules.py",
"scripts/ops/hermes_handoff_run.py",
"dockerfiles/hermes-hux-foundation/hux/__init__.py",
"dockerfiles/hermes-hux-foundation/hux/artifacts.py",
"dockerfiles/hermes-hux-foundation/hux/audit.py",
"dockerfiles/hermes-hux-foundation/hux/budgets.py",
"dockerfiles/hermes-hux-foundation/hux/contracts.py",
"dockerfiles/hermes-hux-foundation/hux/diffs.py",
"dockerfiles/hermes-hux-foundation/hux/errors.py",
"dockerfiles/hermes-hux-foundation/hux/events.py",
"dockerfiles/hermes-hux-foundation/hux/flags.py",
"dockerfiles/hermes-hux-foundation/hux/foundation.py",
"dockerfiles/hermes-hux-foundation/hux/http.py",
"dockerfiles/hermes-hux-foundation/hux/identity.py",
"dockerfiles/hermes-hux-foundation/hux/memory.py",
"dockerfiles/hermes-hux-foundation/hux/organization.py",
"dockerfiles/hermes-hux-foundation/hux/policy.py",
"dockerfiles/hermes-hux-foundation/hux/privacy.py",
"dockerfiles/hermes-hux-foundation/hux/redaction.py",
"dockerfiles/hermes-hux-foundation/hux/research.py",
"dockerfiles/hermes-hux-foundation/hux/rules.py",
"dockerfiles/hermes-hux-foundation/hux/server.py",
"dockerfiles/hermes-hux-foundation/hux/store.py",
"dockerfiles/hermes-worker-hux/hux_hook/__init__.py",
"dockerfiles/hermes-worker-hux/hux_hook/client.py",
"dockerfiles/hermes-worker-hux/hux_hook/hooks.py"
],
@ -365,15 +391,28 @@
"ci/scripts/semgrep_report.py",
"testing/quality_coverage.py",
"testing/quality_handoff_mutation.py",
"dockerfiles/hermes-hux-foundation/hux/__init__.py",
"dockerfiles/hermes-hux-foundation/hux/artifacts.py",
"dockerfiles/hermes-hux-foundation/hux/audit.py",
"dockerfiles/hermes-hux-foundation/hux/budgets.py",
"dockerfiles/hermes-hux-foundation/hux/contracts.py",
"dockerfiles/hermes-hux-foundation/hux/diffs.py",
"dockerfiles/hermes-hux-foundation/hux/errors.py",
"dockerfiles/hermes-hux-foundation/hux/events.py",
"dockerfiles/hermes-hux-foundation/hux/flags.py",
"dockerfiles/hermes-hux-foundation/hux/foundation.py",
"dockerfiles/hermes-hux-foundation/hux/http.py",
"dockerfiles/hermes-hux-foundation/hux/identity.py",
"dockerfiles/hermes-hux-foundation/hux/memory.py",
"dockerfiles/hermes-hux-foundation/hux/organization.py",
"dockerfiles/hermes-hux-foundation/hux/policy.py",
"dockerfiles/hermes-hux-foundation/hux/privacy.py",
"dockerfiles/hermes-hux-foundation/hux/redaction.py",
"dockerfiles/hermes-hux-foundation/hux/research.py",
"dockerfiles/hermes-hux-foundation/hux/rules.py",
"dockerfiles/hermes-hux-foundation/hux/server.py",
"dockerfiles/hermes-hux-foundation/hux/store.py",
"dockerfiles/hermes-worker-hux/hux_hook/__init__.py",
"dockerfiles/hermes-worker-hux/hux_hook/client.py",
"dockerfiles/hermes-worker-hux/hux_hook/hooks.py"
],
@ -460,15 +499,28 @@
"scripts/ops/hermes_handoff_rules.py",
"scripts/ops/hermes_handoff_run.py",
"testing/quality_handoff_mutation.py",
"dockerfiles/hermes-hux-foundation/hux/__init__.py",
"dockerfiles/hermes-hux-foundation/hux/artifacts.py",
"dockerfiles/hermes-hux-foundation/hux/audit.py",
"dockerfiles/hermes-hux-foundation/hux/budgets.py",
"dockerfiles/hermes-hux-foundation/hux/contracts.py",
"dockerfiles/hermes-hux-foundation/hux/diffs.py",
"dockerfiles/hermes-hux-foundation/hux/errors.py",
"dockerfiles/hermes-hux-foundation/hux/events.py",
"dockerfiles/hermes-hux-foundation/hux/flags.py",
"dockerfiles/hermes-hux-foundation/hux/foundation.py",
"dockerfiles/hermes-hux-foundation/hux/http.py",
"dockerfiles/hermes-hux-foundation/hux/identity.py",
"dockerfiles/hermes-hux-foundation/hux/memory.py",
"dockerfiles/hermes-hux-foundation/hux/organization.py",
"dockerfiles/hermes-hux-foundation/hux/policy.py",
"dockerfiles/hermes-hux-foundation/hux/privacy.py",
"dockerfiles/hermes-hux-foundation/hux/redaction.py",
"dockerfiles/hermes-hux-foundation/hux/research.py",
"dockerfiles/hermes-hux-foundation/hux/rules.py",
"dockerfiles/hermes-hux-foundation/hux/server.py",
"dockerfiles/hermes-hux-foundation/hux/store.py",
"dockerfiles/hermes-worker-hux/hux_hook/__init__.py",
"dockerfiles/hermes-worker-hux/hux_hook/client.py",
"dockerfiles/hermes-worker-hux/hux_hook/hooks.py"
]

View File

@ -89,6 +89,7 @@ def test_router_key_file_failures_are_unauthorized(tmp_path, payload):
key_file = tmp_path / "router-key"
if payload is not None:
key_file.write_bytes(payload)
key_file.chmod(0o400)
with pytest.raises(errors.Unauthorized) as denied:
identity.resolve(HEADERS, {"HUX_ROUTER_KEY_FILE": str(key_file)})
assert "x" * 32 not in str(denied.value) and "\\xff" not in str(denied.value)

View File

@ -72,12 +72,66 @@ def test_headers_match_the_service_vocabulary():
assert HuxClient().identity == {"tenant_slot": "", "subject": "", "surface": "worker", "trust": "worker"}
def test_client_loads_only_a_bounded_0400_key_file(tmp_path):
"""The hook can consume its projected worker key without an inline environment secret."""
key_file = tmp_path / "worker-key"
key_file.write_text("wk\n")
key_file.chmod(0o400)
client = HuxClient("http://127.0.0.1:1", WORKER, key_file=key_file)
assert client.headers()[client_mod.HEADER_KEY] == "wk"
with pytest.raises(ValueError):
HuxClient("http://127.0.0.1:1", WORKER, key="wk", key_file=key_file)
key_file.chmod(0o444)
with pytest.raises(ValueError):
HuxClient("http://127.0.0.1:1", WORKER, key_file=key_file)
def test_client_loads_router_bound_subject_from_file(tmp_path, monkeypatch):
"""The worker gets its subject only from the shared router binding and rejects conflicts."""
subject_file = tmp_path / "subject"
subject_file.write_text(SUBJECT + "\n")
subject_file.chmod(0o440)
unbound_worker = {**WORKER, "subject": ""}
assert HuxClient(identity=unbound_worker, subject_file=subject_file).identity["subject"] == SUBJECT
assert HuxClient(identity=WORKER, subject_file=subject_file).identity["subject"] == SUBJECT
with pytest.raises(ValueError, match="conflicts"):
HuxClient(identity={**WORKER, "subject": OTHER["subject"]}, subject_file=subject_file)
monkeypatch.setenv("HUX_SUBJECT_FILE", str(subject_file))
assert HuxClient(identity=unbound_worker).identity["subject"] == SUBJECT
def test_client_subject_file_failures_are_closed(tmp_path):
"""Unavailable, linked, weak, malformed, oversized, and non-UTF-8 subject files are rejected."""
with pytest.raises(ValueError, match="unavailable"):
HuxClient(identity=WORKER, subject_file=tmp_path / "missing")
for name, payload, mode, message in (
("empty", b"", 0o440, "empty or oversized"),
("oversized", b"x" * (client_mod.MAX_SUBJECT_BYTES + 1), 0o440, "empty or oversized"),
("unicode", b"\xff", 0o440, "not UTF-8"),
("malformed", b"brad@example.test", 0o440, "malformed"),
("weak", SUBJECT.encode(), 0o444, "0400 or 0440"),
):
subject_file = tmp_path / name
subject_file.write_bytes(payload)
subject_file.chmod(mode)
with pytest.raises(ValueError, match=message):
HuxClient(identity=WORKER, subject_file=subject_file)
valid = tmp_path / "valid"
valid.write_text(SUBJECT)
valid.chmod(0o400)
linked = tmp_path / "linked"
linked.symlink_to(valid)
with pytest.raises(ValueError, match="unavailable"):
HuxClient(identity=WORKER, subject_file=linked)
def test_error_mapping_and_no_body_leak(live):
"""SO-07: a hux.error.v1 answer becomes HuxServiceError(status, code, message) and the body never appears in it."""
base, _ = live
human = HuxClient(base, HUMAN, key="rk")
worker = HuxClient(base, WORKER, key="wk")
with pytest.raises(HuxServiceError) as bad:
human.post("/hux/v1/approvals", {"conversation_id": "conv_0001abcd", "capability": "nope", "secret": CANARY})
worker.post("/hux/v1/approvals", {"conversation_id": "conv_0001abcd", "capability": "nope", "secret": CANARY})
assert (bad.value.status, bad.value.code) == (400, "invalid") and CANARY not in str(bad.value)
with pytest.raises(HuxServiceError) as unauth:
HuxClient(base, WORKER, key="wrong").get("/hux/v1/capabilities")
@ -293,4 +347,4 @@ def test_library_is_stdlib_only_small_and_documented():
assert ast.get_docstring(node), f"{path.name}:{node.name}"
if isinstance(node, ast.Import | ast.ImportFrom):
root = (node.names[0].name if isinstance(node, ast.Import) else node.module or "").split(".")[0]
assert root in {"http", "json", "threading", "urllib", "collections", "typing", "hashlib", "re", "dataclasses", "hux_hook", "__future__"}, root
assert root in {"http", "hmac", "json", "os", "stat", "threading", "urllib", "collections", "pathlib", "typing", "hashlib", "re", "dataclasses", "hux_hook", "__future__"}, root

View File

@ -9,12 +9,14 @@ import threading
from http.client import HTTPConnection
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import contracts # noqa: E402
from hux import contracts, errors, flags, identity # noqa: E402
from hux import http as hux_http # noqa: E402
from hux.http import DEFAULT_REQUEST_TIMEOUT_SECONDS, RateLimiter, Router, serve # noqa: E402
from hux.server import build_router # noqa: E402
@ -38,6 +40,132 @@ def _start(router: Router):
return server
def test_each_trust_uses_its_own_0400_key_file(tmp_path):
"""Router, relay and worker credentials are distinct files; weak permissions fail closed."""
files = {}
for trust in ("router", "relay", "worker"):
path = tmp_path / f"{trust}-key"
path.write_text(f"{trust}-secret\n")
path.chmod(0o400)
files[trust] = path
base = {key: value for key, value in HEADERS.items() if key != "X-Hux-Relay-Key"}
for trust, surface in (("router", "chat"), ("relay", "telegram"), ("worker", "worker")):
headers = {**base, "X-Hux-Trust": trust, "X-Hux-Surface": surface, "X-Hux-Relay-Key": f"{trust}-secret"}
env = {f"HUX_{trust.upper()}_KEY_FILE": str(files[trust])}
assert identity.resolve(headers, env).trust == trust
wrong = files["router" if trust != "router" else "worker"]
with pytest.raises(errors.Unauthorized):
identity.resolve(headers, {f"HUX_{trust.upper()}_KEY_FILE": str(wrong)})
files["router"].chmod(0o440)
with pytest.raises(errors.Unauthorized):
identity.resolve({**base, "X-Hux-Relay-Key": "router-secret"}, {"HUX_ROUTER_KEY_FILE": str(files["router"])})
def _trusted_headers(trust: str, subject: str = "usr_0123456789abcdef") -> dict[str, str]:
"""Build headers for one separately keyed authenticated hop."""
surfaces = {"router": "chat", "relay": "telegram", "worker": "worker"}
return {
**HEADERS,
"X-Hux-Subject": subject,
"X-Hux-Trust": trust,
"X-Hux-Surface": surfaces[trust],
"X-Hux-Relay-Key": f"{trust}-key",
}
def _binding_env(path: Path) -> dict[str, str]:
"""Return the three-key environment used by subject-binding tests."""
return {
"HUX_ROUTER_KEY": "router-key",
"HUX_RELAY_KEY": "relay-key",
"HUX_WORKER_KEY": "worker-key",
"HUX_SUBJECT_BINDING_FILE": str(path),
}
def test_edge_hop_binds_subject_before_worker_can_assert_it(tmp_path):
"""Only a keyed router/relay may initialize the file; all later callers must match it."""
binding = tmp_path / "subject"
environ = _binding_env(binding)
with pytest.raises(errors.Unauthorized, match="not bound"):
identity.resolve(_trusted_headers("worker"), environ)
assert identity.resolve(_trusted_headers("router"), environ).subject == "usr_0123456789abcdef"
assert binding.read_text().strip() == "usr_0123456789abcdef"
assert binding.stat().st_mode & 0o777 == 0o440
assert identity.resolve(_trusted_headers("worker"), environ).trust == "worker"
other = "usr_fedcba9876543210"
for trust in ("router", "relay", "worker"):
with pytest.raises(errors.Unauthorized, match="does not match"):
identity.resolve(_trusted_headers(trust, other), environ)
def test_relay_can_initialize_binding_and_invalid_files_fail_closed(tmp_path):
"""Relay is an authoritative edge; malformed, linked, weak, and unavailable files never authenticate."""
relay_binding = tmp_path / "relay-subject"
assert identity.resolve(_trusted_headers("relay"), _binding_env(relay_binding)).trust == "relay"
for name, payload, mode in (
("empty", b"", 0o440),
("oversized", b"x" * (identity.MAX_SUBJECT_BYTES + 1), 0o440),
("unicode", b"\xff", 0o440),
("malformed", b"brad@example.test", 0o440),
("weak", b"usr_0123456789abcdef", 0o444),
):
binding = tmp_path / name
binding.write_bytes(payload)
binding.chmod(mode)
with pytest.raises(errors.Unauthorized):
identity.resolve(_trusted_headers("router"), _binding_env(binding))
link = tmp_path / "linked"
link.symlink_to(relay_binding)
with pytest.raises(errors.Unauthorized):
identity.resolve(_trusted_headers("router"), _binding_env(link))
with pytest.raises(errors.Unauthorized, match="unavailable"):
identity.resolve(_trusted_headers("router"), _binding_env(tmp_path / "missing" / "subject"))
def test_concurrent_edge_binding_has_exactly_one_subject(tmp_path):
"""First-writer publication is complete and immutable even when two edge hops race."""
binding = tmp_path / "subject"
environ = _binding_env(binding)
barrier = threading.Barrier(2)
outcomes: list[tuple[str, bool]] = []
def bind(trust: str, subject: str) -> None:
barrier.wait()
try:
identity.resolve(_trusted_headers(trust, subject), environ)
except errors.Unauthorized:
outcomes.append((subject, False))
else:
outcomes.append((subject, True))
attempts = (
threading.Thread(target=bind, args=("router", "usr_0123456789abcdef")),
threading.Thread(target=bind, args=("relay", "usr_fedcba9876543210")),
)
for attempt in attempts:
attempt.start()
for attempt in attempts:
attempt.join()
bound = binding.read_text().strip()
assert sorted(ok for _, ok in outcomes) == [False, True]
assert bound in {subject for subject, ok in outcomes if ok}
def test_capability_flags_are_bound_to_the_routes_really_registered():
"""A configured card stays off when a declared route is absent; undeclared routes fail startup."""
all_routes = {card: set(routes) for card, routes in flags.CARD_ROUTES.items() if routes}
bound = flags.Flags({"HUX_FLAGS": ALL_ON})
missing = {card: set(routes) for card, routes in all_routes.items()}
missing["HUX-01"].remove("/hux/v1/conversations/{id}/events/stream")
bound.bind_routes(missing)
assert not bound.enabled("HUX-01") and bound.enabled("HUX-11")
with pytest.raises(ValueError, match="unknown cards"):
bound.bind_routes({"HUX-99": {"/hux/v1/nope"}})
with pytest.raises(ValueError, match="undeclared routes"):
bound.bind_routes({"HUX-11": {"/hux/v1/nope"}})
def _raw_request(address: tuple[str, int], request: bytes) -> bytes:
"""Send one HTTP/1.0 request and return its complete close-delimited response."""
with socket.create_connection(address, timeout=1) as client:
@ -162,8 +290,22 @@ def test_health_and_json_are_no_store_and_health_refuses_bodies(tmp_path):
reply = conn.getresponse()
reply.read()
assert reply.getheader("Cache-Control") == "no-store"
assert reply.getheader("X-Content-Type-Options") == "nosniff"
conn.request("GET", "/healthz", body=b"x", headers={"Content-Length": "1"})
assert conn.getresponse().status == 413
finally:
server.shutdown()
server.server_close()
def test_unknown_routes_are_limited_and_query_fields_are_bounded(tmp_path):
"""Authenticated misses consume their bucket and oversized query maps fail as typed 400s."""
router = _router(tmp_path, HUX_READS_PER_MINUTE="1")
missing = router.dispatch("GET", "/hux/v1/nope", HEADERS, b"")
limited = router.dispatch("GET", "/hux/v1/nope", HEADERS, b"")
assert missing.status == 404
assert (limited.status, limited.body["code"]) == (429, "rate_limited")
roomy = _router(tmp_path / "roomy")
query = "&".join(f"x{index}=1" for index in range(hux_http.MAX_QUERY_FIELDS + 1))
response = roomy.dispatch("GET", f"/hux/v1/capabilities?{query}", HEADERS, b"")
assert (response.status, response.body["code"]) == (400, "invalid")

View File

@ -42,7 +42,12 @@ OTHER_HASH = "sha256:" + "cd" * 32
@pytest.fixture
def router(tmp_path):
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk", "HUX_RELAY_KEY": "rk"})
instance = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk", "HUX_RELAY_KEY": "rk"})
response = instance.dispatch(
"POST", "/hux/v1/runs/run_9f/budget", WORKER, json.dumps({"conversation_id": CONV}).encode()
)
assert response.status == 200
return instance
@pytest.fixture
@ -116,8 +121,10 @@ def test_idempotency_key_returns_the_original(router):
key = {"Idempotency-Key": "run_9f:approval:call-7"}
status, first = call(router, "POST", "/hux/v1/approvals", request_body(), {**WORKER, **key})
assert status == 201 and first["idempotency_key"] == "run_9f:approval:call-7"
status, again = call(router, "POST", "/hux/v1/approvals", request_body("shell"), {**WORKER, **key})
status, again = call(router, "POST", "/hux/v1/approvals", request_body(), {**WORKER, **key})
assert (status, again) == (200, first)
status, conflict = call(router, "POST", "/hux/v1/approvals", request_body("shell"), {**WORKER, **key})
assert (status, conflict["code"]) == (409, "conflict")
status, other = call(router, "POST", "/hux/v1/approvals", request_body(), {**WORKER, "Idempotency-Key": "run_9f:approval:call-8"})
assert status == 201 and other["id"] != first["id"]
assert call(router, "GET", f"/hux/v1/approvals/{first['id']}")[1] == first
@ -138,7 +145,11 @@ def test_create_rejects_bad_bodies(router, body, status):
# --- deciding -----------------------------------------------------------------------
def pending(router, capability="write_files", **kw):
status, body = call(router, "POST", "/hux/v1/approvals", request_body(capability, **kw), WORKER)
body = request_body(capability, **kw)
run_id = body["run_id"]
if budgets.run_conversation(router_store(router), run_id) is None:
assert call(router, "POST", f"/hux/v1/runs/{run_id}/budget", {"conversation_id": body["conversation_id"]}, WORKER)[0] == 200
status, body = call(router, "POST", "/hux/v1/approvals", body, WORKER)
assert status == 201 and body["status"] == "pending", body
return body
@ -233,6 +244,9 @@ def test_exhausted_budget_blocks_new_approvals(router, events):
assert (status, error["code"]) == (429, "budget_exhausted") and error["details"] == ["tool_calls_per_run"]
assert [e["kind"] for e in events] == ["budget.exhausted", "budget.exhausted"]
status, body = call(router, "POST", "/hux/v1/approvals", request_body("read_files", run_id="run_fresh"), WORKER)
assert (status, body["code"]) == (400, "invalid"), "an unbound run cannot create an approval record"
call(router, "POST", "/hux/v1/runs/run_fresh/budget", {"conversation_id": CONV}, WORKER)
status, body = call(router, "POST", "/hux/v1/approvals", request_body("read_files", run_id="run_fresh"), WORKER)
assert (status, body["code"]) == (429, "budget_exhausted"), "run rotation cannot reset conversation spend"
@ -255,7 +269,7 @@ def test_gate_blocks_before_approval_and_releases_once_exactly_once(router, even
status, again = gate(router)
assert again["proceed"] is False and "already consumed" in again["reason"], "once is once (SO-36)"
kinds = [e["kind"] for e in events]
assert kinds.count("side_effect.released") == 1 and kinds.count("side_effect.blocked") == 4, "the first block predates any approval, so no conversation is known"
assert kinds.count("side_effect.released") == 1 and kinds.count("side_effect.blocked") == 5
released = next(e for e in events if e["kind"] == "side_effect.released")
assert released["evidence"] == [{"kind": "approval", "id": record["id"]}] and released["run_id"] == "run_9f"
served = call(router, "GET", f"/hux/v1/approvals/{record['id']}")[1]
@ -271,7 +285,7 @@ def test_gate_session_is_reusable_within_the_conversation(router):
assert gate(router, capability="shell", argument_hash=OTHER_HASH)[1]["proceed"] is True
assert gate(router, run_id="run_later", capability="shell")[1]["proceed"] is False, "unknown runs inherit no approval"
status, unbound = call(router, "POST", "/hux/v1/approvals", request_body("shell", run_id="run_unbound"), WORKER)
assert status == 201 and unbound["status"] == "pending", "a scoped grant needs a prior authoritative binding"
assert (status, unbound["code"]) == (400, "invalid"), "an unknown run cannot mint any approval record"
call(router, "POST", "/hux/v1/runs/run_later/budget", {"conversation_id": CONV}, WORKER)
assert gate(router, run_id="run_later", capability="shell")[1]["proceed"] is False, "the old approval record never spans runs"
status, fresh = call(router, "POST", "/hux/v1/approvals", request_body("shell", run_id="run_later"), WORKER)
@ -308,7 +322,7 @@ def test_gate_rejects_bad_bodies_and_other_tenants(router):
record = pending(router)
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})
status, body = call(router, "POST", "/hux/v1/runs/run_9f/gate", {"capability": "write_files", "argument_hash": HASH}, OTHER)
assert status == 200 and body["proceed"] is False
assert (status, body["code"]) == (403, "forbidden")
def test_events_module_absence_is_tolerated(router, monkeypatch):
@ -389,6 +403,8 @@ def test_conversation_budget_aggregates_across_runs_and_policy_revision_resets(r
assert revised["revision"] == 2
reset = call(router, "GET", "/hux/v1/runs/run_b/budget", headers=WORKER)[1]
assert reset["spent"]["tool_calls"] == 0 and reset["exhausted"] == []
restarted = call(router, "POST", "/hux/v1/runs/run_b/budget", {"conversation_id": CONV, "tool_calls": 1}, WORKER)[1]
assert restarted["spent"]["tool_calls"] == 1, "the old epoch's per-run spend does not leak into the new epoch"
def router_store(router):

View File

@ -28,6 +28,7 @@ SCHEMAS = contracts.load_all()
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"}
OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
WORKER = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
OTHER_WORKER = {**WORKER, "X-Hux-Subject": OTHER["X-Hux-Subject"]}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
CONV = "conv_0001abcd"
@ -117,25 +118,24 @@ def test_stop_writes_a_receipt_and_a_second_stop_returns_it(router, events, tmp_
def test_only_the_gateway_may_vouch_for_an_empty_registry_and_a_failed_receipt_can_be_superseded(router, events, tmp_path):
"""F8 / SO-41: a human surface asserting ``process_registry_empty`` gets ``failed_to_cancel``; the worker's later real cancel supersedes it."""
"""F8 / SO-41: browser callers cannot write receipts; only the Worker records the proven outcome."""
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "conversation_id": CONV}, HEADERS)
assert status == 201 and valid(body)["outcome"] == "failed_to_cancel" and "completed_at" not in body
first_requested = body["requested_at"]
assert (status, body["code"]) == (403, "forbidden")
status, again = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True}, HEADERS)
assert (status, again) == (200, body), "an identical failed stop replays"
assert (status, again["code"]) == (403, "forbidden")
status, done = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "side_effects": [{"description": "x", "reverted": True}]})
assert status == 201 and valid(done)["outcome"] == "cancelled" and done["requested_at"] == first_requested and "completed_at" in done
assert done["conversation_id"] == CONV and done["requested_by"] == {"type": "system", "id": "worker"}
assert status == 201 and valid(done)["outcome"] == "cancelled" and "completed_at" in done
assert "conversation_id" not in done and done["requested_by"] == {"type": "system", "id": "worker"}
tenant = store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))
stored = tenant.get("receipts", done["id"])
assert stored["revision"] == 2 and contracts.validate_record(stored, SCHEMAS) == [], "stored receipts carry revision (F11)"
assert stored["revision"] == 1 and contracts.validate_record(stored, SCHEMAS) == [], "stored receipts carry revision (F11)"
status, third = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False})
assert (status, third) == (200, done), "a terminal receipt never changes again"
rows = [r.get("reason") for r in audit.recent(tenant) if r["action"] == "runs.stop"]
assert rows == ["registry_state_not_from_gateway", "replayed", "superseded:cancelled", "replayed"]
assert [e["summary"] for e in events] == ["Run stopped: failed_to_cancel (registry_state_not_from_gateway)", "Run stopped: cancelled (cancelled)"]
assert rows == ["stop receipts only from worker trust", "stop receipts only from worker trust", "cancelled", "replayed"]
assert events == [], "a receipt without an authoritative conversation binding emits no conversation event"
status, body = call(router, "POST", "/hux/v1/runs/run_h/stop", {"already_complete": True}, HEADERS)
assert valid(body)["outcome"] == "already_complete", "already_complete needs no registry claim"
assert (status, body["code"]) == (403, "forbidden")
def test_stop_outcomes_follow_the_process_registry(router):
@ -156,5 +156,5 @@ def test_stop_rejects_bad_bodies(router, body):
def test_receipts_are_per_tenant(router):
call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True})
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False}, OTHER)
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False}, OTHER_WORKER)
assert status == 201 and body["outcome"] == "failed_to_cancel", "same run id, other subject, its own receipt"

View File

@ -217,7 +217,7 @@ def test_conversation_privacy_state_route(tmp_path):
assert status == 200 and state["memory_writes_allowed"] is True and state["mode"] == "thoughtful"
assert state["forgotten"] is False and state["memory_disabled"] is False and state["topics"] == []
from hux import privacy, store, identity as ident_mod
tenant = store.TenantStore(router.data_root, ident_mod.resolve(HEADERS, {}))
tenant = store.TenantStore(router.data_root, ident_mod.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))
privacy.mark_topic(tenant, conv["id"], "health")
privacy.set_flag(tenant, conv["id"], "memory_disabled", True)
state = call(router, "GET", f"/hux/v1/conversations/{conv['id']}/privacy")[1]
@ -228,7 +228,9 @@ def test_conversation_privacy_state_route(tmp_path):
assert state["forgotten"] is True
status, private, _ = call(router, "POST", "/hux/v1/conversations", body={"title": "p", "mode": "private"})
assert call(router, "GET", f"/hux/v1/conversations/{private['id']}/privacy")[1]["memory_writes_allowed"] is False
assert call(router, "GET", "/hux/v1/conversations/conv_unknown00/privacy")[1]["mode"] is None
status, unknown, _ = call(router, "GET", "/hux/v1/conversations/conv_unknown00/privacy")
assert (status, unknown["code"]) == (404, "not_found")
assert call(router, "GET", "/hux/v1/conversations/bad%20id/privacy")[0] in (400, 404)
other = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
assert call(router, "GET", f"/hux/v1/conversations/{conv['id']}/privacy", other)[1]["forgotten"] is False
status, cross_tenant, _ = call(router, "GET", f"/hux/v1/conversations/{conv['id']}/privacy", other)
assert (status, cross_tenant["code"]) == (404, "not_found")

View File

@ -37,15 +37,28 @@ def test_hux_replacement_modules_are_quality_managed():
"""The deleted prototype helpers are replaced by the production foundation and hook modules."""
contract = load_contract()
expected = {
"dockerfiles/hermes-hux-foundation/hux/__init__.py",
"dockerfiles/hermes-hux-foundation/hux/artifacts.py",
"dockerfiles/hermes-hux-foundation/hux/audit.py",
"dockerfiles/hermes-hux-foundation/hux/budgets.py",
"dockerfiles/hermes-hux-foundation/hux/contracts.py",
"dockerfiles/hermes-hux-foundation/hux/diffs.py",
"dockerfiles/hermes-hux-foundation/hux/errors.py",
"dockerfiles/hermes-hux-foundation/hux/events.py",
"dockerfiles/hermes-hux-foundation/hux/flags.py",
"dockerfiles/hermes-hux-foundation/hux/foundation.py",
"dockerfiles/hermes-hux-foundation/hux/http.py",
"dockerfiles/hermes-hux-foundation/hux/identity.py",
"dockerfiles/hermes-hux-foundation/hux/memory.py",
"dockerfiles/hermes-hux-foundation/hux/organization.py",
"dockerfiles/hermes-hux-foundation/hux/policy.py",
"dockerfiles/hermes-hux-foundation/hux/privacy.py",
"dockerfiles/hermes-hux-foundation/hux/redaction.py",
"dockerfiles/hermes-hux-foundation/hux/research.py",
"dockerfiles/hermes-hux-foundation/hux/rules.py",
"dockerfiles/hermes-hux-foundation/hux/server.py",
"dockerfiles/hermes-hux-foundation/hux/store.py",
"dockerfiles/hermes-worker-hux/hux_hook/__init__.py",
"dockerfiles/hermes-worker-hux/hux_hook/client.py",
"dockerfiles/hermes-worker-hux/hux_hook/hooks.py",
}