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