security(hux): harden backend trust boundaries
This commit is contained in:
parent
98c7c6184f
commit
f59f24a427
@ -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)
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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"<html>"))
|
||||
|
||||
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
|
||||
|
||||
@ -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")
|
||||
|
||||
169
testing/tests/test_hermes_hux_http_security.py
Normal file
169
testing/tests/test_hermes_hux_http_security.py
Normal file
@ -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()
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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"}))
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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"]}
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user