From 47165274f087b140d05674545d336fef730451b1 Mon Sep 17 00:00:00 2001 From: jenkins Date: Mon, 24 Aug 2026 03:10:13 -0300 Subject: [PATCH] security(hermes): isolate HUX release evidence trust --- .../hermes-hux-foundation/hux/flags.py | 27 ++- dockerfiles/hermes-hux-foundation/hux/http.py | 9 + .../hermes-hux-foundation/hux/identity.py | 16 +- .../hux/release_security.py | 134 +++++++++++++ .../hermes-hux-foundation/hux/releases.py | 6 +- .../hermes/contracts/hux/common.schema.json | 5 +- .../test_hermes_hux_contract_foundation.py | 24 ++- ...ermes_hux_missing_families_registration.py | 10 +- .../tests/test_hermes_hux_release_security.py | 177 ++++++++++++++++++ .../tests/test_hermes_hux_releases_backend.py | 52 ++++- 10 files changed, 434 insertions(+), 26 deletions(-) create mode 100644 dockerfiles/hermes-hux-foundation/hux/release_security.py create mode 100644 testing/tests/test_hermes_hux_release_security.py diff --git a/dockerfiles/hermes-hux-foundation/hux/flags.py b/dockerfiles/hermes-hux-foundation/hux/flags.py index 687cd095..1108f53b 100644 --- a/dockerfiles/hermes-hux-foundation/hux/flags.py +++ b/dockerfiles/hermes-hux-foundation/hux/flags.py @@ -11,6 +11,7 @@ reach at all. from __future__ import annotations import os +import re from collections.abc import Mapping from hux.errors import FlagOff @@ -18,6 +19,7 @@ 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"], @@ -66,10 +68,8 @@ WORKER_ROUTES: frozenset[tuple[str, str]] = frozenset({ ("GET", "/hux/v1/modes"), ("GET", "/hux/v1/projects/{project_id}/conversations/{id}/mode"), ("POST", "/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items"), - ("POST", "/hux/v1/projects/{project_id}/conversations/{id}/releases"), ("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases"), ("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}"), - ("POST", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions"), }) @@ -102,13 +102,23 @@ class Flags: 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 card in self._route_cards and flag_enabled(entry["flag"], self._environ) + 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 = [ @@ -128,4 +138,13 @@ class Flags: 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 - return {"commit": environ.get("HUX_BUILD_COMMIT", ""), "image_digest": environ.get("HUX_IMAGE_DIGEST", "")} + 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", "")} diff --git a/dockerfiles/hermes-hux-foundation/hux/http.py b/dockerfiles/hermes-hux-foundation/hux/http.py index a355625e..e55de695 100644 --- a/dockerfiles/hermes-hux-foundation/hux/http.py +++ b/dockerfiles/hermes-hux-foundation/hux/http.py @@ -207,6 +207,15 @@ 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") + if identity.trust == "evidence": + allowed = { + ("GET", "/hux/v1/capabilities"), + ("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases"), + ("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}"), + ("POST", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions"), + } + if (method, route.template) not in allowed: + raise Forbidden("route is not available to evidence trust") self.flags.require(route.card) try: parsed_query = parse_qs(parts.query, max_num_fields=MAX_QUERY_FIELDS) diff --git a/dockerfiles/hermes-hux-foundation/hux/identity.py b/dockerfiles/hermes-hux-foundation/hux/identity.py index 1c022578..bdb734ed 100644 --- a/dockerfiles/hermes-hux-foundation/hux/identity.py +++ b/dockerfiles/hermes-hux-foundation/hux/identity.py @@ -28,8 +28,11 @@ HEADER_KEY = "X-Hux-Relay-Key" 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 = {"router": "HUX_ROUTER_KEY", "relay": "HUX_RELAY_KEY", "worker": "HUX_WORKER_KEY"} +TRUSTS = ("router", "relay", "worker", "evidence") +KEY_ENV = { + "router": "HUX_ROUTER_KEY", "relay": "HUX_RELAY_KEY", + "worker": "HUX_WORKER_KEY", "evidence": "HUX_RELEASE_EVIDENCE_KEY", +} MAX_KEY_BYTES = 4096 MAX_SUBJECT_BYTES = 128 SUBJECT_BINDING_ENV = "HUX_SUBJECT_BINDING_FILE" @@ -58,6 +61,13 @@ def _header(headers: Mapping[str, str], name: str) -> str: def _expected_key(environ: Mapping[str, str], trust: str) -> str: """Read a caller key, preferring a bounded Kubernetes secret file.""" + if trust == "evidence": + from hux.release_security import evidence_key + + try: + return evidence_key(environ) + except Exception: # noqa: BLE001 - authentication fails closed without revealing configuration detail + return "" name = KEY_ENV[trust] key_file = environ.get(f"{name}_FILE", "") if key_file: @@ -180,5 +190,7 @@ 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") + if trust == "evidence" and surface != "api": + raise Unauthorized("evidence trust needs api surface") _enforce_subject_binding(environ, trust, subject) return Identity(slot, subject, surface, trust) diff --git a/dockerfiles/hermes-hux-foundation/hux/release_security.py b/dockerfiles/hermes-hux-foundation/hux/release_security.py new file mode 100644 index 00000000..3a7613dd --- /dev/null +++ b/dockerfiles/hermes-hux-foundation/hux/release_security.py @@ -0,0 +1,134 @@ +"""Fail-closed authentication and policy checks for HUX-12 evidence. + +The evidence producer is a loopback-only companion process. It receives a +dedicated Vault key and a non-secret policy file; neither the browser relay nor +the agent worker can use its trust class. This module deliberately performs no +network discovery: the producer supplies observations and this boundary binds +them to the configured Jenkins, Harbor, Flux, workload, pod and health facts. +""" + +from __future__ import annotations + +import json +import os +import re +import stat +from collections.abc import Mapping +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +from hux.errors import Forbidden, Invalid + +KEY_FILE_ENV = "HUX_RELEASE_EVIDENCE_KEY_FILE" +POLICY_FILE_ENV = "HUX_RELEASE_EVIDENCE_POLICY_FILE" +MAX_KEY_BYTES = 4096 +MAX_POLICY_BYTES = 64 * 1024 +POLICY_FIELDS = {"schema", "max_evidence_age_seconds", "workloads"} +WORKLOAD_FIELDS = { + "review_url_prefix", "jenkins_job_url", "image_repository", + "flux_kustomization", "health_url", +} + + +def _regular_file(path: str, modes: set[int], limit: int) -> bytes: + """Read one bounded regular file without accepting a symlink or loose mode.""" + if not path: + raise Invalid("HUX-12 evidence configuration is missing") + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + descriptor = -1 + try: + descriptor = os.open(Path(path), flags) + info = os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) not in modes: + raise Invalid("HUX-12 evidence file permissions are invalid") + data = os.read(descriptor, limit + 1) + except OSError as error: + raise Invalid("HUX-12 evidence configuration is unavailable") from error + finally: + if descriptor >= 0: + os.close(descriptor) + if not data or len(data) > limit: + raise Invalid("HUX-12 evidence file is empty or too large") + return data + + +def evidence_key(environ: Mapping[str, str]) -> str: + """Return the file-only producer key; inline environment secrets are ignored.""" + data = _regular_file(environ.get(KEY_FILE_ENV, ""), {0o400}, MAX_KEY_BYTES) + try: + key = data.decode("utf-8", errors="strict").strip() + except UnicodeDecodeError as error: + raise Invalid("HUX-12 evidence key is invalid") from error + if len(key) < 32: + raise Invalid("HUX-12 evidence key is too short") + return key + + +def _https_url(value: Any, field: str, *, prefix: bool = False) -> str: + if not isinstance(value, str) or not value or len(value) > 500: + raise Invalid(f"{field} is missing or too long") + parsed = urlsplit(value) + if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment: + raise Invalid(f"{field} must be a credential-free HTTPS URL") + if prefix and not value.endswith("/"): + raise Invalid(f"{field} must end with /") + return value + + +def load_policy(environ: Mapping[str, str]) -> dict[str, Any]: + """Load and strictly validate the non-secret producer policy.""" + raw = _regular_file(environ.get(POLICY_FILE_ENV, ""), {0o400, 0o440, 0o444}, MAX_POLICY_BYTES) + try: + policy = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise Invalid("HUX-12 evidence policy is not valid JSON") from error + if not isinstance(policy, dict) or set(policy) != POLICY_FIELDS or policy.get("schema") != "hux.release_evidence_policy.v1": + raise Invalid("HUX-12 evidence policy shape is invalid") + age = policy.get("max_evidence_age_seconds") + workloads = policy.get("workloads") + if not isinstance(age, int) or isinstance(age, bool) or not 60 <= age <= 86400: + raise Invalid("HUX-12 evidence age bound is invalid") + if not isinstance(workloads, dict) or not workloads or len(workloads) > 16: + raise Invalid("HUX-12 workload policy is invalid") + for name, item in workloads.items(): + if not isinstance(name, str) or not isinstance(item, dict) or set(item) != WORKLOAD_FIELDS: + raise Invalid("HUX-12 workload policy shape is invalid") + _https_url(item["review_url_prefix"], "review_url_prefix", prefix=True) + _https_url(item["jenkins_job_url"], "jenkins_job_url") + _https_url(item["health_url"], "health_url") + if not re.fullmatch(r"[a-z0-9./_-]+", str(item["image_repository"])): + raise Invalid("HUX-12 image repository is invalid") + if not re.fullmatch(r"[a-z0-9-]{1,63}", str(item["flux_kustomization"])): + raise Invalid("HUX-12 Flux kustomization is invalid") + return policy + + +def configured(environ: Mapping[str, str]) -> bool: + """Whether producer authentication and policy are both healthy.""" + try: + evidence_key(environ) + load_policy(environ) + except Invalid: + return False + return True + + +def workload_policy(environ: Mapping[str, str], workload: str) -> dict[str, Any]: + """Return the exact allowlist for a workload or fail closed.""" + item = load_policy(environ)["workloads"].get(workload) + if item is None: + raise Invalid("workload is not enabled for release evidence") + return item + + +def require_review_creator(trust: str) -> None: + """Only the authenticated browser path may record the human review decision.""" + if trust != "router": + raise Forbidden("reviewed releases require router trust") + + +def require_evidence_producer(trust: str) -> None: + """Only the dedicated producer may advance a reviewed release.""" + if trust != "evidence": + raise Forbidden("release transitions require evidence trust") diff --git a/dockerfiles/hermes-hux-foundation/hux/releases.py b/dockerfiles/hermes-hux-foundation/hux/releases.py index 69e90363..e879d3f2 100644 --- a/dockerfiles/hermes-hux-foundation/hux/releases.py +++ b/dockerfiles/hermes-hux-foundation/hux/releases.py @@ -14,7 +14,7 @@ import re from typing import Any from urllib.parse import urlsplit -from hux import contracts, redaction +from hux import contracts, redaction, release_security from hux.errors import Conflict, Invalid, NotFound, TooLarge from hux.http import Request, Response, Router, page from hux.store import check_id, new_id, now_iso @@ -56,6 +56,8 @@ def _scope(request: Request) -> tuple[str, str]: def _actor(request: Request) -> dict[str, str]: + if request.identity.trust == "evidence": + return {"type": "system", "id": "release-evidence-producer"} if request.identity.trust == "worker": return {"type": "system", "id": "release-worker"} return {"type": "user", "id": request.identity.subject} @@ -173,6 +175,7 @@ def _initial_evidence(body: dict[str, Any]) -> dict[str, Any]: def create_release(request: Request) -> Response: """Create the sole reviewed ledger for one scoped workload commit.""" + release_security.require_review_creator(request.identity.trust) body = _body(request, {"workload", "commit", "feature_flags", "evidence"}) project_id, conversation_id = _scope(request) key, fingerprint = _key(request), _fingerprint(body, request.path) @@ -262,6 +265,7 @@ def _evidence_refs(target: str, evidence: dict[str, Any]) -> list[dict[str, str] def transition_release(request: Request) -> Response: """Append exactly one legal, evidence-backed release transition.""" + release_security.require_evidence_producer(request.identity.trust) body = _body(request, {"to", "evidence"}) project_id, conversation_id = _scope(request) key, fingerprint = _key(request), _fingerprint(body, request.path) diff --git a/services/hermes/contracts/hux/common.schema.json b/services/hermes/contracts/hux/common.schema.json index aaf73b94..cbabc33e 100644 --- a/services/hermes/contracts/hux/common.schema.json +++ b/services/hermes/contracts/hux/common.schema.json @@ -250,11 +250,12 @@ }, "trust": { "type": "string", - "description": "How the identity was asserted: router (browser via chat router), relay (Telegram relay key), worker (owner control plane).", + "description": "How identity was asserted: router, relay, worker, or the loopback-only release evidence producer.", "enum": [ "router", "relay", - "worker" + "worker", + "evidence" ] }, "identity": { diff --git a/testing/tests/test_hermes_hux_contract_foundation.py b/testing/tests/test_hermes_hux_contract_foundation.py index f58b32d1..1099676e 100644 --- a/testing/tests/test_hermes_hux_contract_foundation.py +++ b/testing/tests/test_hermes_hux_contract_foundation.py @@ -22,9 +22,9 @@ FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation" if str(FOUNDATION) not in sys.path: sys.path.insert(0, str(FOUNDATION)) -from hux import audit, contracts, errors, flags, identity, store -from hux.http import Response, Router, page, serve -from hux.server import build_router +from hux import audit, contracts, errors, flags, identity, store # noqa: E402 +from hux.http import Response, Router, 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", "X-Hux-Relay-Key": "rk"} @@ -127,11 +127,24 @@ def test_flags_fail_closed_and_capabilities_validate(): record = on.capabilities(ident(), {"commit": "d3cbeb06" * 5, "image_digest": "sha256:" + "4a" * 32, "junk": "x"}) assert contracts.validate_record(record, SCHEMAS) == [] 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(enabled[card] for card, routes in flags.CARD_ROUTES.items() if routes and card != "HUX-12") + assert not enabled["HUX-12"] 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": ""} + release_commit = "d3cbeb06" * 5 + release_tag = f"git-{release_commit}-build-23-release" + assert flags.build_from_environ({"HUX_IMAGE_TAG": release_tag}) == { + "commit": release_commit, + "image_digest": "", + } + with pytest.raises(ValueError): + flags.build_from_environ({"HUX_IMAGE_TAG": "latest"}) + with pytest.raises(ValueError): + flags.build_from_environ( + {"HUX_IMAGE_TAG": release_tag, "HUX_BUILD_COMMIT": "a" * 40} + ) assert "commit" in flags.build_from_environ() @@ -442,7 +455,8 @@ def test_new_backend_cards_declare_routes_and_enable_with_dependencies(): shipped = {"HUX-06", "HUX-07", "HUX-09", "HUX-12"} configured = flags.Flags({"HUX_FLAGS": ALL_ON}) assert all(flags.CARD_ROUTES[card] for card in shipped) - assert all(configured.enabled(card) for card in shipped) + assert all(configured.enabled(card) for card in shipped - {"HUX-12"}) + assert not configured.enabled("HUX-12") def test_unexpected_handler_exceptions_become_a_500_error_record(tmp_path): diff --git a/testing/tests/test_hermes_hux_missing_families_registration.py b/testing/tests/test_hermes_hux_missing_families_registration.py index 7cadb89e..c99ac612 100644 --- a/testing/tests/test_hermes_hux_missing_families_registration.py +++ b/testing/tests/test_hermes_hux_missing_families_registration.py @@ -10,7 +10,7 @@ FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation" if str(FOUNDATION) not in sys.path: sys.path.insert(0, str(FOUNDATION)) -from hux import contracts, flags, server +from hux import contracts, flags, server # noqa: E402 ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) EXPECTED_FAMILIES = ("modes", "multimodal", "suggestions", "releases") @@ -47,7 +47,7 @@ def test_server_registers_each_family_and_exact_card_routes(tmp_path): for card, expected in EXPECTED_ROUTES.items(): assert set(flags.CARD_ROUTES[card]) == expected assert expected <= registered - assert router.flags.enabled(card) + assert router.flags.enabled(card) is (card != "HUX-12") def test_release_followthrough_requires_foundation_but_not_activity(): @@ -56,8 +56,8 @@ def test_release_followthrough_requires_foundation_but_not_activity(): assert release["depends_on"] == ["HUX-11"] assert release["contracts"] == ["release.schema.json", "release-ledger.schema.json"] assert not flags.Flags({"HUX_FLAGS": "hux.release_followthrough"}).enabled("HUX-12") - enabled = flags.Flags({"HUX_FLAGS": "hux.foundation,hux.release_followthrough"}) - assert enabled.enabled("HUX-12") + configured_without_evidence_key = flags.Flags({"HUX_FLAGS": "hux.foundation,hux.release_followthrough"}) + assert not configured_without_evidence_key.enabled("HUX-12") def test_contract_loader_and_worker_allowlist_are_complete(): @@ -69,10 +69,8 @@ def test_contract_loader_and_worker_allowlist_are_complete(): ("GET", "/hux/v1/modes"), ("GET", "/hux/v1/projects/{project_id}/conversations/{id}/mode"), ("POST", "/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items"), - ("POST", "/hux/v1/projects/{project_id}/conversations/{id}/releases"), ("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases"), ("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}"), - ("POST", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions"), } assert worker_expected <= flags.WORKER_ROUTES assert ("PUT", "/hux/v1/projects/{project_id}/conversations/{id}/mode") not in flags.WORKER_ROUTES diff --git a/testing/tests/test_hermes_hux_release_security.py b/testing/tests/test_hermes_hux_release_security.py new file mode 100644 index 00000000..30a97b28 --- /dev/null +++ b/testing/tests/test_hermes_hux_release_security.py @@ -0,0 +1,177 @@ +"""HUX-12 evidence identity and configuration fail-closed tests.""" + +from __future__ import annotations + +import json +import sys +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 identity, release_security # noqa: E402 +from hux.errors import Forbidden, Invalid, Unauthorized # noqa: E402 + +KEY = "evidence-key-value-that-is-at-least-32-bytes" + + +def policy() -> dict: + """Return one complete non-secret release allowlist.""" + return { + "schema": "hux.release_evidence_policy.v1", + "max_evidence_age_seconds": 900, + "workloads": {"hermes-webui": { + "review_url_prefix": "https://scm.bstein.dev/atlas/titan-iac/pulls/", + "jenkins_job_url": "https://jenkins.bstein.dev/job/hermes-webui-image", + "image_repository": "registry.bstein.dev/bstein/hermes-webui", + "flux_kustomization": "hermes", + "health_url": "https://chat.bstein.dev/healthz", + }}, + } + + +def files(tmp_path: Path, content: bytes | None = None) -> dict[str, str]: + """Create strict key/policy projections and return their environment.""" + key = tmp_path / "evidence.key" + key.write_bytes((KEY + "\n").encode() if content is None else content) + key.chmod(0o400) + config = tmp_path / "policy.json" + config.write_text(json.dumps(policy())) + config.chmod(0o444) + return { + release_security.KEY_FILE_ENV: str(key), + release_security.POLICY_FILE_ENV: str(config), + } + + +def test_good_configuration_and_evidence_identity(tmp_path): + env = files(tmp_path) + env["HUX_TENANT_SLOT"] = "slot-3" + assert release_security.configured(env) + assert release_security.evidence_key(env) == KEY + assert release_security.workload_policy(env, "hermes-webui")["flux_kustomization"] == "hermes" + headers = { + "X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", + "X-Hux-Surface": "api", "X-Hux-Trust": "evidence", "X-Hux-Relay-Key": KEY, + } + assert identity.resolve(headers, env).trust == "evidence" + with pytest.raises(Unauthorized, match="api surface"): + identity.resolve({**headers, "X-Hux-Surface": "chat"}, env) + with pytest.raises(Unauthorized, match="key missing or wrong"): + identity.resolve(headers, {**env, release_security.KEY_FILE_ENV: "", "HUX_RELEASE_EVIDENCE_KEY": KEY}) + + +@pytest.mark.parametrize("content", [b"short", b"\xff" * 40, b"", b"x" * 4097]) +def test_key_rejects_invalid_contents(tmp_path, content): + env = files(tmp_path, content) + with pytest.raises(Invalid): + release_security.evidence_key(env) + assert not release_security.configured(env) + + +def test_key_rejects_missing_loose_and_symlink_files(tmp_path): + env = files(tmp_path) + Path(env[release_security.KEY_FILE_ENV]).chmod(0o440) + with pytest.raises(Invalid, match="permissions"): + release_security.evidence_key(env) + env[release_security.KEY_FILE_ENV] = str(tmp_path / "missing") + with pytest.raises(Invalid, match="unavailable"): + release_security.evidence_key(env) + target = tmp_path / "target" + target.write_text(KEY) + link = tmp_path / "link" + link.symlink_to(target) + env[release_security.KEY_FILE_ENV] = str(link) + with pytest.raises(Invalid, match="unavailable"): + release_security.evidence_key(env) + with pytest.raises(Invalid, match="missing"): + release_security._regular_file("", {0o400}, 10) + + +def test_policy_rejects_encoding_json_shape_and_bounds(tmp_path): + env = files(tmp_path) + path = Path(env[release_security.POLICY_FILE_ENV]) + for value in (b"\xff", b"{", json.dumps([]).encode(), json.dumps({"schema": "bad"}).encode()): + path.chmod(0o600) + path.write_bytes(value) + path.chmod(0o444) + with pytest.raises(Invalid): + release_security.load_policy(env) + for age in (True, 59, 86401): + item = policy() + item["max_evidence_age_seconds"] = age + path.chmod(0o600) + path.write_text(json.dumps(item)) + path.chmod(0o444) + with pytest.raises(Invalid, match="age bound"): + release_security.load_policy(env) + + +@pytest.mark.parametrize("workloads", [None, {}, {str(index): {} for index in range(17)}]) +def test_policy_rejects_workload_collection(tmp_path, workloads): + env = files(tmp_path) + item = policy() + item["workloads"] = workloads + path = Path(env[release_security.POLICY_FILE_ENV]) + path.chmod(0o600) + path.write_text(json.dumps(item)) + path.chmod(0o444) + with pytest.raises(Invalid, match="workload policy"): + release_security.load_policy(env) + + +@pytest.mark.parametrize("field,value,message", [ + ("review_url_prefix", "https://scm.bstein.dev/pulls", "end with"), + ("review_url_prefix", "http://scm.bstein.dev/pulls/", "HTTPS"), + ("jenkins_job_url", "https://user:pass@jenkins.bstein.dev/job/x", "HTTPS"), + ("health_url", "https://chat.bstein.dev/healthz?token=x", "HTTPS"), + ("image_repository", "registry:5000/Bad", "image repository"), + ("flux_kustomization", "BAD_name", "Flux kustomization"), +]) +def test_policy_rejects_unsafe_workload_fields(tmp_path, field, value, message): + env = files(tmp_path) + item = policy() + item["workloads"]["hermes-webui"][field] = value + path = Path(env[release_security.POLICY_FILE_ENV]) + path.chmod(0o600) + path.write_text(json.dumps(item)) + path.chmod(0o444) + with pytest.raises(Invalid, match=message): + release_security.load_policy(env) + + +def test_policy_rejects_bad_workload_shape_and_unknown_name(tmp_path): + env = files(tmp_path) + item = policy() + item["workloads"]["hermes-webui"]["extra"] = True + path = Path(env[release_security.POLICY_FILE_ENV]) + path.chmod(0o600) + path.write_text(json.dumps(item)) + path.chmod(0o444) + with pytest.raises(Invalid, match="shape"): + release_security.load_policy(env) + path.chmod(0o600) + path.write_text(json.dumps(policy())) + path.chmod(0o444) + with pytest.raises(Invalid, match="not enabled"): + release_security.workload_policy(env, "hermes-agent") + + +def test_trust_gates_are_exact(): + release_security.require_review_creator("router") + release_security.require_evidence_producer("evidence") + for trust in ("relay", "worker", "evidence"): + if trust != "router": + with pytest.raises(Forbidden, match="router trust"): + release_security.require_review_creator(trust) + for trust in ("router", "relay", "worker"): + with pytest.raises(Forbidden, match="evidence trust"): + release_security.require_evidence_producer(trust) + + +def test_source_is_bounded(): + assert len((FOUNDATION / "hux" / "release_security.py").read_text().splitlines()) <= 500 diff --git a/testing/tests/test_hermes_hux_releases_backend.py b/testing/tests/test_hermes_hux_releases_backend.py index 265cc8a1..ab549cd3 100644 --- a/testing/tests/test_hermes_hux_releases_backend.py +++ b/testing/tests/test_hermes_hux_releases_backend.py @@ -14,11 +14,12 @@ FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation" if str(FOUNDATION) not in sys.path: sys.path.insert(0, str(FOUNDATION)) -from hux import contracts, identity, releases, store -from hux.errors import Invalid -from hux.server import build_router +from hux import contracts, identity, releases, store # noqa: E402 +from hux.errors import Invalid # noqa: E402 +from hux.server import build_router # noqa: E402 HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"} +EVIDENCE = {**HEADERS, "X-Hux-Surface": "api", "X-Hux-Trust": "evidence", "X-Hux-Relay-Key": "evidence-key-value-that-is-at-least-32-bytes"} OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) DIGEST = "sha256:" + "a" * 64 @@ -39,7 +40,25 @@ def tenant(router): @pytest.fixture def setup(tmp_path): - router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) + key_file = tmp_path / "evidence.key" + key_file.write_text("evidence-key-value-that-is-at-least-32-bytes\n") + key_file.chmod(0o400) + policy_file = tmp_path / "evidence-policy.json" + policy_file.write_text(json.dumps({ + "schema": "hux.release_evidence_policy.v1", "max_evidence_age_seconds": 900, + "workloads": {"hermes-webui": { + "review_url_prefix": "https://git.bstein.dev/atlas/titan-iac/pulls/", + "jenkins_job_url": "https://jenkins.bstein.dev/job/hermes", + "image_repository": "registry.bstein.dev/bstein/hermes", + "flux_kustomization": "hermes", "health_url": "https://chat.bstein.dev/healthz", + }}, + })) + policy_file.chmod(0o444) + router = build_router(tmp_path, { + "HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", + "HUX_RELEASE_EVIDENCE_KEY_FILE": str(key_file), + "HUX_RELEASE_EVIDENCE_POLICY_FILE": str(policy_file), + }) _, project, _ = call(router, "POST", "/hux/v1/projects", {"name": "P"}) _, conversation, _ = call(router, "POST", "/hux/v1/conversations", {"title": "C", "project_id": project["id"]}) base = f"/hux/v1/projects/{project['id']}/conversations/{conversation['id']}/releases" @@ -56,7 +75,9 @@ def create(setup, key="release-key-001", commit="1" * 40, **changes): def move(router, path, revision, key, target, evidence): - return call(router, "POST", path + "/transitions", {"to": target, "evidence": evidence}, {"If-Match": str(revision), "Idempotency-Key": key}) + return call(router, "POST", path + "/transitions", {"to": target, "evidence": evidence}, { + **EVIDENCE, "If-Match": str(revision), "Idempotency-Key": key, + }) def build_evidence(digest=DIGEST): @@ -162,7 +183,24 @@ def test_transition_requires_exact_order_revision_and_evidence(setup): status, replay, headers = move(router, path, 1, "good-merge-key", "merged", {"merge_commit": "2" * 40}) assert status == 200 and replay == merged and headers["HUX-Replayed"] == "true" assert move(router, path, 2, "good-merge-key", "built", build_evidence())[0] == 409 - assert call(router, "POST", path + "/transitions", {"to": "built", "evidence": build_evidence()}, {"Idempotency-Key": "missing-transition-if"})[0] == 400 + assert call(router, "POST", path + "/transitions", {"to": "built", "evidence": build_evidence()}, { + **EVIDENCE, "Idempotency-Key": "missing-transition-if", + })[0] == 400 + + +def test_only_router_reviews_and_only_evidence_trust_transitions(setup): + router, _, _, base = setup + created = create(setup, "trust-bound-create")[1] + path = base + "/" + created["release"]["id"] + transition = {"to": "merged", "evidence": {"merge_commit": "2" * 40}} + headers = {"If-Match": "1", "Idempotency-Key": "trust-bound-transition"} + assert call(router, "POST", path + "/transitions", transition, headers)[0] == 403 + worker = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"} + assert call(router, "POST", path + "/transitions", transition, {**worker, **headers})[0] == 401 + assert call(router, "POST", base, { + "workload": "hermes-webui", "commit": "8" * 40, + "evidence": {"review_url": "https://git.bstein.dev/atlas/titan-iac/pulls/8"}, + }, {**EVIDENCE, "If-Match": "0", "Idempotency-Key": "evidence-cannot-review"})[0] == 403 @pytest.mark.parametrize("evidence", [ @@ -258,6 +296,8 @@ def test_inconsistent_idempotency_and_cross_conversation_views_fail_closed(setup def test_internal_helpers_are_bounded_and_worker_actor_has_no_subject(): worker = SimpleNamespace(identity=identity.Identity("slot-3", "usr_0123456789abcdef", "worker", "worker")) assert releases._actor(worker) == {"type": "system", "id": "release-worker"} + producer = SimpleNamespace(identity=identity.Identity("slot-3", "usr_0123456789abcdef", "api", "evidence")) + assert releases._actor(producer) == {"type": "system", "id": "release-evidence-producer"} with pytest.raises(Invalid, match="missing or too long"): releases._safe_url(None) with pytest.raises(Invalid, match="secret"):