security(hermes): isolate HUX release evidence trust

This commit is contained in:
jenkins 2026-08-24 03:10:13 -03:00
parent 1eda927b71
commit 47165274f0
10 changed files with 434 additions and 26 deletions

View File

@ -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", "")}

View File

@ -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)

View File

@ -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)

View File

@ -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")

View File

@ -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)

View File

@ -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": {

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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"):