223 lines
13 KiB
Python
223 lines
13 KiB
Python
"""HUX-05 policy documents: the capability matrix, grants and their expiry.
|
|
|
|
Security obligations exercised: SO-38 (``always`` grants carry a server-set
|
|
expiry of at most 30 days), SO-39 (``effective_decision`` is the sole
|
|
resolver and deploy/external always ask), SO-44 (If-Match on revisioned
|
|
writes; unconditional writes are audited).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
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 audit, contracts, errors, identity, policy, rules, store # noqa: E402
|
|
from hux.server import build_router # noqa: E402
|
|
|
|
SCHEMAS = contracts.load_all()
|
|
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"}
|
|
OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
|
|
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
|
T0 = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
@pytest.fixture
|
|
def router(tmp_path):
|
|
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"})
|
|
|
|
|
|
@pytest.fixture
|
|
def frozen(monkeypatch):
|
|
state = {"now": T0}
|
|
monkeypatch.setattr(policy, "clock", lambda: state["now"])
|
|
return state
|
|
|
|
|
|
def call(router, method, path, body=None, headers=HEADERS):
|
|
raw = b"" if body is None else json.dumps(body).encode()
|
|
response = router.dispatch(method, path, headers, raw)
|
|
return response.status, response.body, response.headers
|
|
|
|
|
|
def valid(record):
|
|
assert contracts.validate_record(record, SCHEMAS) == [], contracts.validate_record(record, SCHEMAS)
|
|
return record
|
|
|
|
|
|
def test_modules_stay_under_the_line_budget():
|
|
for name in ("policy.py", "budgets.py"):
|
|
assert len((FOUNDATION / "hux" / name).read_text().splitlines()) <= 500
|
|
|
|
|
|
def test_first_read_creates_the_safe_global_default(router, tmp_path):
|
|
status, body, headers = call(router, "GET", "/hux/v1/policy")
|
|
assert status == 200 and headers["ETag"] == "1"
|
|
valid(body)
|
|
assert body["scope"] == {"level": "global"} and body["autonomy"] == "safe" and body["grants"] == []
|
|
assert body["owner"] == HEADERS["X-Hux-Subject"] and body["provenance"]["actor"] == {"type": "user", "id": body["owner"]}
|
|
assert call(router, "GET", "/hux/v1/policy?scope=global")[1] == body
|
|
rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"})))
|
|
assert [r["action"] for r in rows] == ["policy.read", "policy.read"]
|
|
|
|
|
|
def test_matrix_is_the_single_resolver_and_external_always_asks(router):
|
|
matrix = rules.default_capability_matrix()
|
|
for level in ("ask_first", "safe", "autonomous"):
|
|
put = {"scope": {"level": "global"}, "autonomy": level, "grants": [{"capability": "deploy", "decision": "allow"}]}
|
|
status, body, _ = call(router, "PUT", "/hux/v1/policy", put)
|
|
assert status == 200, body
|
|
for capability in rules.CAPABILITIES:
|
|
expected = matrix[level][capability]
|
|
if capability == "deploy":
|
|
expected = "ask" # an explicit allow on an always-ask capability still asks (SO-39)
|
|
assert policy.resolve_request(body, capability, external=False) == expected
|
|
assert policy.resolve_request(body, capability, external=True) in {"ask", "deny"}
|
|
assert policy.resolve_request(body, "network", external=True) == "ask"
|
|
|
|
|
|
def test_grants_get_server_set_expiry_and_actor(router, frozen):
|
|
far = policy.iso(T0 + timedelta(days=90))
|
|
soon = policy.iso(T0 + timedelta(days=2))
|
|
put = {"scope": {"level": "global"}, "autonomy": "safe", "grants": [
|
|
{"capability": "network", "decision": "allow", "expires_at": far, "granted_by": {"type": "operator", "id": "evil"}},
|
|
{"capability": "shell", "decision": "allow", "expires_at": soon},
|
|
{"capability": "write_files", "decision": "deny"},
|
|
]}
|
|
status, body, _ = call(router, "PUT", "/hux/v1/policy", put)
|
|
assert status == 200
|
|
valid(body)
|
|
by_cap = {g["capability"]: g for g in body["grants"]}
|
|
assert by_cap["network"]["expires_at"] == policy.iso(T0 + policy.ALWAYS_TTL)
|
|
assert by_cap["shell"]["expires_at"] == soon
|
|
assert by_cap["write_files"]["expires_at"] == policy.iso(T0 + policy.ALWAYS_TTL)
|
|
assert all(g["granted_by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]} for g in body["grants"])
|
|
assert policy.resolve_request(body, "network", False) == "allow"
|
|
assert policy.resolve_request(body, "shell", False) == "allow"
|
|
assert policy.resolve_request(body, "write_files", False) == "deny"
|
|
frozen["now"] = T0 + timedelta(days=3)
|
|
assert policy.resolve_request(body, "shell", False) == "ask", "expired grant falls back to the matrix"
|
|
assert policy.resolve_request(body, "network", False) == "allow"
|
|
frozen["now"] = T0 + timedelta(days=31)
|
|
assert policy.resolve_request(body, "network", False) == "deny", "safe denies network once the grant lapses"
|
|
|
|
|
|
def test_put_honours_if_match_and_audits_unconditional_writes(router, tmp_path):
|
|
put = {"scope": {"level": "global"}, "autonomy": "autonomous"}
|
|
status, body, headers = call(router, "PUT", "/hux/v1/policy", put)
|
|
assert (status, body["revision"], headers["ETag"]) == (200, 1, "1")
|
|
status, body, _ = call(router, "PUT", "/hux/v1/policy", put, {**HEADERS, "If-Match": "1"})
|
|
assert (status, body["revision"]) == (200, 2)
|
|
status, body, _ = call(router, "PUT", "/hux/v1/policy", put, {**HEADERS, "If-Match": "1"})
|
|
assert (status, body["code"]) == (409, "conflict")
|
|
status, body, _ = call(router, "PUT", "/hux/v1/policy", put)
|
|
assert (status, body["revision"]) == (200, 3)
|
|
rows = [(r["action"], r["outcome"], r.get("reason", "")) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"})))]
|
|
assert rows == [("policy.write", "allow", ""), ("policy.write", "allow", ""), ("policy.write", "conflict", rows[2][2]), ("policy.write", "allow", "unconditional_write")]
|
|
assert "does not match" in rows[2][2]
|
|
|
|
|
|
@pytest.mark.parametrize("body,fragment", [
|
|
([], "JSON object"),
|
|
({"scope": "global"}, "scope must be an object"),
|
|
({"scope": {"level": "planet"}, "autonomy": "safe"}, "scope must be"),
|
|
({"scope": {"level": "project"}, "autonomy": "safe"}, "scope_id required"),
|
|
({"scope": {"level": "project", "scope_id": "prj_" + "a" * 70}, "autonomy": "safe"}, "scope_id required"),
|
|
({"scope": {"level": "project", "scope_id": "../etc"}, "autonomy": "safe"}, "malformed id"),
|
|
({"scope": {"level": "global"}, "autonomy": "yolo"}, "autonomy must be"),
|
|
({"scope": {"level": "global"}, "autonomy": "safe", "grants": "all"}, "grants must be a list"),
|
|
({"scope": {"level": "global"}, "autonomy": "safe", "grants": [{"capability": "teleport", "decision": "allow"}]}, "known capability"),
|
|
({"scope": {"level": "global"}, "autonomy": "safe", "grants": [{"capability": "shell", "decision": "maybe"}]}, "grant decision"),
|
|
({"scope": {"level": "global"}, "autonomy": "safe", "budgets": {"tokens_per_run": -1}}, "fails contract"),
|
|
({"scope": {"level": "global"}, "autonomy": "safe", "budgets": {"unknown": 1}}, "fails contract"),
|
|
])
|
|
def test_put_rejects_bad_bodies(router, body, fragment):
|
|
status, error, _ = call(router, "PUT", "/hux/v1/policy", body)
|
|
assert status == 400 and fragment in error["message"], error
|
|
valid(error)
|
|
|
|
|
|
def test_get_rejects_bad_scope_queries(router):
|
|
assert call(router, "GET", "/hux/v1/policy?scope=nope")[0] == 400
|
|
assert call(router, "GET", "/hux/v1/policy?scope=conversation")[0] == 400
|
|
|
|
|
|
def test_effective_policy_walks_conversation_project_global(router):
|
|
_, project, _ = call(router, "POST", "/hux/v1/projects", {"name": "Kitchen"})
|
|
_, conversation, _ = call(router, "POST", "/hux/v1/conversations", {"title": "Cabinets", "project_id": project["id"]})
|
|
_, loose, _ = call(router, "POST", "/hux/v1/conversations", {"title": "Unfiled"})
|
|
conv_query = f"/hux/v1/policy?scope=conversation&scope_id={conversation['id']}"
|
|
assert call(router, "GET", conv_query)[1]["scope"] == {"level": "global"}
|
|
status, body, _ = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "project", "scope_id": project["id"]}, "autonomy": "autonomous"})
|
|
assert status == 200 and valid(body)["id"] == f"pol_project.{project['id']}"
|
|
assert call(router, "GET", conv_query)[1]["scope"] == {"level": "project", "scope_id": project["id"]}
|
|
assert call(router, "GET", f"/hux/v1/policy?scope=conversation&scope_id={loose['id']}")[1]["scope"] == {"level": "global"}
|
|
assert call(router, "GET", "/hux/v1/policy?scope=conversation&scope_id=conv_unknown0001")[1]["scope"] == {"level": "global"}
|
|
status, body, _ = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "conversation", "scope_id": conversation["id"]}, "autonomy": "ask_first"})
|
|
assert status == 200
|
|
got = call(router, "GET", conv_query)[1]
|
|
assert got["autonomy"] == "ask_first" and got["scope"]["level"] == "conversation"
|
|
assert call(router, "GET", f"/hux/v1/policy?scope=project&scope_id={project['id']}")[1]["autonomy"] == "autonomous"
|
|
|
|
|
|
def test_second_subject_sees_its_own_default_not_the_first_tenants_policy(router):
|
|
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "autonomous"})
|
|
status, body, _ = call(router, "GET", "/hux/v1/policy", headers=OTHER)
|
|
assert status == 200 and body["autonomy"] == "safe" and body["owner"] == OTHER["X-Hux-Subject"]
|
|
assert call(router, "GET", "/hux/v1/policy")[1]["autonomy"] == "autonomous"
|
|
|
|
|
|
def test_flag_off_hides_the_whole_card(tmp_path):
|
|
off = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation", "HUX_ROUTER_KEY": "rk"})
|
|
status, body, _ = call(off, "GET", "/hux/v1/policy")
|
|
assert (status, body["code"]) == (404, "flag_off")
|
|
|
|
|
|
def test_helpers_round_trip_time_and_actors():
|
|
assert policy.parse(policy.iso(T0)) == T0
|
|
assert policy.now().tzinfo is timezone.utc
|
|
worker = identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "worker", "worker")
|
|
assert policy.actor_for(worker) == {"type": "system", "id": "worker"}
|
|
assert policy.run_key("run_9f") == policy.run_key("run_9f") and len(policy.run_key("x")) == 32
|
|
assert policy.public({"a": 1, "_b": 2, "revision": 3}) == {"a": 1}
|
|
assert policy.public({"a": 1, "_b": 2, "revision": 3}, revisioned=True) == {"a": 1, "revision": 3}
|
|
|
|
|
|
# --- F1: only humans write policy ---------------------------------------------------
|
|
|
|
def test_only_a_human_actor_may_write_policy_or_hold_allow_grants(tmp_path):
|
|
"""F1 (critical): worker and api surfaces cannot rewrite the policy, escalate autonomy or plant allow grants."""
|
|
router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk", "HUX_RELAY_KEY": "rk"})
|
|
worker = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
|
|
api = {**HEADERS, "X-Hux-Surface": "api"}
|
|
relay = {**HEADERS, "X-Hux-Surface": "telegram", "X-Hux-Trust": "relay", "X-Hux-Relay-Key": "rk"}
|
|
escalate = {"scope": {"level": "global"}, "autonomy": "autonomous", "grants": [{"capability": "network", "decision": "allow"}]}
|
|
for headers in (worker, api):
|
|
status, body, _ = call(router, "PUT", "/hux/v1/policy", escalate, headers)
|
|
assert (status, body["code"]) == (403, "forbidden"), headers
|
|
status, body, _ = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe"}, headers)
|
|
assert status == 403, "even a harmless-looking write is a policy write"
|
|
status, body, _ = call(router, "GET", "/hux/v1/policy", headers=api)
|
|
assert status == 200 and body["autonomy"] == "safe" and body["grants"] == [], "nothing leaked through"
|
|
status, body, _ = call(router, "PUT", "/hux/v1/policy", escalate, relay)
|
|
assert status == 200 and body["autonomy"] == "autonomous" and body["grants"][0]["granted_by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]}
|
|
rows = [(r["action"], r["outcome"]) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))) if r["action"] == "policy.write"]
|
|
assert rows == [("policy.write", "deny")] * 4 + [("policy.write", "allow")]
|
|
# The helpers assert the invariant even when a caller reaches them without the route.
|
|
system = identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "worker", "worker")
|
|
with pytest.raises(errors.Forbidden):
|
|
policy.normalise_grants([{"capability": "shell", "decision": "allow"}], system)
|
|
assert policy.normalise_grants([{"capability": "shell", "decision": "deny"}], system)[0]["granted_by"] == {"type": "system", "id": "worker"}
|
|
with pytest.raises(errors.Forbidden):
|
|
policy.add_grant(store.TenantStore(tmp_path, system), system, "global", None, "shell", timedelta(hours=1))
|
|
assert policy.is_human(identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "chat", "router"))
|
|
assert not policy.is_human(identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "api", "router"))
|