"""Per-card feature flags and the capabilities record clients negotiate with. Flags come from the ``HUX_FLAGS`` comma list. A card counts as enabled only when it and every card it depends on are enabled, so a half-configured deployment fails closed. Route ownership per card is declared here so the capabilities record can tell a client exactly what it may call, and the worker allowlist (SO-08) says which of those a ``trust: worker`` caller may reach at all. """ from __future__ import annotations import os import re from collections.abc import Mapping from hux.errors import FlagOff from hux.identity import Identity from hux.rules import flag_enabled, flag_registry CONTRACT_VERSION = "1.1.0" RELEASE_TAG = re.compile(r"^git-([0-9a-f]{40})-build-[1-9][0-9]*-release$") CARD_ROUTES: dict[str, list[str]] = { "HUX-11": ["/hux/v1/capabilities", "/hux/v1/manifest", "/hux/v1/context/bootstrap"], "HUX-01": ["/hux/v1/conversations/{id}/events", "/hux/v1/conversations/{id}/events/stream"], "HUX-02": ["/hux/v1/memory", "/hux/v1/memory/{id}", "/hux/v1/memory/{id}/{action}", "/hux/v1/memory/export"], "HUX-03": ["/hux/v1/projects", "/hux/v1/projects/{id}", "/hux/v1/conversations", "/hux/v1/conversations/{id}", "/hux/v1/conversations/{id}/branch", "/hux/v1/conversations/{id}/lineage", "/hux/v1/search"], "HUX-04": ["/hux/v1/artifacts", "/hux/v1/artifacts/{id}", "/hux/v1/artifacts/{id}/versions", "/hux/v1/artifacts/{id}/versions/{n}", "/hux/v1/artifacts/{id}/versions/{n}/diff", "/hux/v1/artifacts/{id}/promote"], "HUX-05": ["/hux/v1/policy", "/hux/v1/approvals", "/hux/v1/approvals/{id}", "/hux/v1/runs/{id}/stop", "/hux/v1/runs/{id}/budget", "/hux/v1/runs/{id}/gate"], "HUX-06": ["/hux/v1/modes", "/hux/v1/projects/{project_id}/conversations/{id}/mode"], "HUX-07": [ "/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items", "/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}", "/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}/transcript-corrections", "/hux/v1/projects/{project_id}/conversations/{id}/capture-intents", ], "HUX-08": ["/hux/v1/sources", "/hux/v1/sources/{id}", "/hux/v1/passages", "/hux/v1/messages/{id}/citations", "/hux/v1/notebooks", "/hux/v1/notebooks/{id}"], "HUX-09": [ "/hux/v1/projects/{project_id}/conversations/{id}/suggestions/evaluate", "/hux/v1/projects/{project_id}/conversations/{id}/suggestions/{suggestion_id}/decisions", "/hux/v1/projects/{project_id}/conversations/{id}/suggestions/states", ], "HUX-10": ["/hux/v1/privacy/policy", "/hux/v1/privacy/notices", "/hux/v1/conversations/{id}/forget", "/hux/v1/privacy/audit", "/hux/v1/conversations/{id}/privacy"], "HUX-12": [ "/hux/v1/projects/{project_id}/conversations/{id}/releases", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions", ], } # The only (method, template) pairs a ``trust: worker`` caller may reach (SO-08). # Everything else is 403 before the flag check. These are the agent-hook # routes: negotiation, the approval/gate/budget/stop loop, activity events, # the privacy policy, memory retrieval and proposals, research inputs and # 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"), ("POST", "/hux/v1/context/bootstrap"), ("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"), ("POST", "/hux/v1/conversations/{id}/events"), ("GET", "/hux/v1/conversations/{id}"), ("GET", "/hux/v1/privacy/policy"), ("GET", "/hux/v1/conversations/{id}/privacy"), ("GET", "/hux/v1/memory"), ("POST", "/hux/v1/memory"), ("POST", "/hux/v1/sources"), ("POST", "/hux/v1/passages"), ("POST", "/hux/v1/messages/{id}/citations"), ("POST", "/hux/v1/artifacts"), ("POST", "/hux/v1/artifacts/{id}/versions"), ("GET", "/hux/v1/modes"), ("GET", "/hux/v1/projects/{project_id}/conversations/{id}/mode"), ("POST", "/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items"), ("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases"), ("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}"), }) def worker_may_call(method: str, template: str) -> bool: """True when a ``trust: worker`` caller is allowed on this route (SO-08).""" return (method, template) in WORKER_ROUTES class Flags: """Snapshot of which cards are on for this process.""" 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) enabled = bool(entry) and card in self._route_cards and flag_enabled(entry["flag"], self._environ) if enabled and card == "HUX-12": from hux.release_security import configured return configured(self._environ) return enabled def require(self, card: str) -> None: """Raise FlagOff unless the card is enabled.""" if not self.enabled(card): raise FlagOff(f"{card} is not enabled") @property def environ(self) -> Mapping[str, str]: """Read-only process configuration for route-family policy checks.""" return self._environ def capabilities(self, identity: Identity, build: Mapping[str, str] | None = None) -> dict: """Serialise ``hux.capabilities.v1`` for one caller.""" cards = [ {"card": card, "flag": entry["flag"], "enabled": self.enabled(card), "routes": CARD_ROUTES.get(card, [])} for card, entry in sorted(self._registry.items()) ] server = {k: v for k, v in (build or {}).items() if k in {"commit", "image_digest"} and v} return { "schema": "hux.capabilities.v1", "contract_version": CONTRACT_VERSION, "identity": identity.record(), "cards": cards, "server": server, } def build_from_environ(environ: Mapping[str, str] | None = None) -> dict[str, str]: """Commit and image digest the pod was started with, when the operator set them.""" environ = os.environ if environ is None else environ tag = environ.get("HUX_IMAGE_TAG", "") commit = environ.get("HUX_BUILD_COMMIT", "") if tag: match = RELEASE_TAG.fullmatch(tag) if not match: raise ValueError("HUX_IMAGE_TAG is not an immutable WebUI release tag") if commit and commit != match.group(1): raise ValueError("HUX build commit conflicts with the immutable image tag") commit = match.group(1) return {"commit": commit, "image_digest": environ.get("HUX_IMAGE_DIGEST", "")}