351 lines
18 KiB
Python
351 lines
18 KiB
Python
"""HuxClient transport contract and the pure helpers of the agent hook.
|
|
|
|
Security obligations exercised: SO-04 (the client emits exactly the identity
|
|
header vocabulary ``hux.identity`` accepts, with the worker key), SO-07 (errors
|
|
carry status, code and message only; request bodies are never embedded),
|
|
SO-27 and SO-28 (memory writes need the privacy card and are refused in a
|
|
private conversation), SO-37 (the argument hash is stable across key order
|
|
and whitespace), SO-50 (capabilities are read from the resolved flag chain).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import http.client
|
|
import io
|
|
import sys
|
|
import threading
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
|
|
HOOK_ROOT = ROOT / "dockerfiles" / "hermes-worker-hux"
|
|
for entry in (FOUNDATION, HOOK_ROOT):
|
|
if str(entry) not in sys.path:
|
|
sys.path.insert(0, str(entry))
|
|
|
|
from hux import contracts, identity # noqa: E402
|
|
from hux.http import serve # noqa: E402
|
|
from hux.server import build_router # noqa: E402
|
|
from hux_hook import HuxClient, HuxServiceError, HuxUnavailable, canonical_argument_hash, emit, memory_gate # noqa: E402
|
|
from hux_hook import client as client_mod # noqa: E402
|
|
from hux_hook import hooks # noqa: E402
|
|
|
|
SCHEMAS = contracts.load_all()
|
|
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
|
SUBJECT = "usr_0123456789abcdef"
|
|
WORKER = {"tenant_slot": "slot-3", "subject": SUBJECT, "surface": "worker", "trust": "worker"}
|
|
HUMAN = {"tenant_slot": "slot-3", "subject": SUBJECT, "surface": "chat", "trust": "router"}
|
|
OTHER = {**HUMAN, "subject": "usr_fedcba9876543210"}
|
|
CANARY = "CANARY-9c1d-SECRET"
|
|
|
|
|
|
def start(tmp_path: Path, flags: str = ALL_ON):
|
|
router = build_router(tmp_path, {"HUX_FLAGS": flags, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk"})
|
|
server = serve(router, "127.0.0.1", 0)
|
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
return f"http://127.0.0.1:{server.server_address[1]}", server
|
|
|
|
|
|
@pytest.fixture
|
|
def live(tmp_path):
|
|
base, server = start(tmp_path)
|
|
yield base, tmp_path
|
|
server.shutdown()
|
|
|
|
|
|
# --- headers and identity ---------------------------------------------------------
|
|
|
|
def test_headers_match_the_service_vocabulary():
|
|
"""SO-04: the exact header names identity.resolve reads, key only when present, extras only when non-empty."""
|
|
worker = HuxClient("http://127.0.0.1:1/", WORKER, key="wk")
|
|
sent = worker.headers({"Idempotency-Key": "run:approval:1", "If-Match": ""})
|
|
assert identity.resolve(sent, {"HUX_WORKER_KEY": "wk"}) == identity.Identity("slot-3", SUBJECT, "worker", "worker")
|
|
assert sent["Idempotency-Key"] == "run:approval:1" and "If-Match" not in sent
|
|
assert worker.base_url == "http://127.0.0.1:1"
|
|
plain = HuxClient(identity={"tenant_slot": "slot-3", "subject": SUBJECT}).headers()
|
|
assert client_mod.HEADER_KEY not in plain and plain[client_mod.HEADER_TRUST] == "worker"
|
|
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:
|
|
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")
|
|
assert unauth.value.code == "unauthorized"
|
|
with pytest.raises(HuxServiceError) as missing:
|
|
human.get("/hux/v1/no/such/route")
|
|
assert (missing.value.status, missing.value.code) == (404, "not_found")
|
|
assert HuxUnavailable().code == "unavailable" and HuxUnavailable().status == 0
|
|
assert HuxServiceError(500, "x", "m" * 400).message == "m" * 280
|
|
|
|
|
|
def test_transport_edge_cases(monkeypatch):
|
|
"""Non-JSON failures, a 4xx delivered without an exception and socket errors map to typed errors."""
|
|
client = HuxClient("http://127.0.0.1:1", WORKER, key="wk")
|
|
|
|
def raise_http(*args, **kwargs):
|
|
raise urllib.error.HTTPError("u", 502, "bad gateway", {}, io.BytesIO(b"<html>"))
|
|
|
|
monkeypatch.setattr(client._opener, "open", raise_http)
|
|
with pytest.raises(HuxServiceError) as html:
|
|
client.get("/hux/v1/capabilities")
|
|
assert (html.value.status, html.value.code) == (502, "invalid")
|
|
|
|
class Raw:
|
|
status = 418
|
|
headers = {"X-Test": "1"}
|
|
|
|
def read(self, *_args):
|
|
return b"{bad json"
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
monkeypatch.setattr(client._opener, "open", lambda *a, **k: Raw())
|
|
with pytest.raises(HuxServiceError) as teapot:
|
|
client.put("/hux/v1/policy", {"x": 1}, if_match=3)
|
|
assert teapot.value.status == 418
|
|
|
|
def raise_socket(*args, **kwargs):
|
|
raise TimeoutError("slow")
|
|
|
|
monkeypatch.setattr(client._opener, "open", raise_socket)
|
|
with pytest.raises(HuxUnavailable):
|
|
client.get("/hux/v1/capabilities", query={"a": "b c"})
|
|
|
|
def raise_half_closed(*args, **kwargs):
|
|
raise http.client.BadStatusLine("gone")
|
|
|
|
monkeypatch.setattr(client._opener, "open", raise_half_closed)
|
|
with pytest.raises(HuxUnavailable):
|
|
client.get("/hux/v1/capabilities")
|
|
|
|
|
|
@pytest.mark.parametrize("base", [
|
|
"https://127.0.0.1:8790", "http://localhost:8790", "http://10.0.0.1:8790",
|
|
"http://user:secret@127.0.0.1:8790", "http://127.0.0.1:8790/path",
|
|
"http://127.0.0.1:8790?next=x", "http://127.0.0.1",
|
|
])
|
|
def test_client_accepts_only_literal_loopback_origin(base):
|
|
"""The worker client refuses non-loopback, credentialed, ambiguous and TLS origins."""
|
|
with pytest.raises(ValueError):
|
|
HuxClient(base, WORKER)
|
|
assert HuxClient("http://[::1]:8790", WORKER).base_url == "http://[::1]:8790"
|
|
with pytest.raises(ValueError):
|
|
HuxClient("http://127.0.0.1:8790", WORKER, timeout=0)
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["https://evil.invalid/hux/v1/x", "//evil.invalid/x", "/hux/v1/../x", "/hux/v1/%2e%2e/x", "/hux/v1/x?y=1", "/healthz"])
|
|
def test_client_rejects_noncanonical_paths_and_redirects(path, monkeypatch):
|
|
"""Paths stay same-origin and the opener never creates a redirected credential-bearing request."""
|
|
monkeypatch.setattr(urllib.request, "getproxies", lambda: {"http": "http://proxy.invalid:8080"})
|
|
client = HuxClient("http://127.0.0.1:8790", WORKER, key=CANARY)
|
|
with pytest.raises(HuxServiceError) as invalid:
|
|
client.get(path)
|
|
assert invalid.value.status == 400
|
|
assert client_mod._RejectRedirect().redirect_request(None, None, 302, "moved", {}, "https://evil.invalid") is None
|
|
proxy_handlers = [handler for handler in client._opener.handlers if isinstance(handler, urllib.request.ProxyHandler)]
|
|
assert proxy_handlers == [], "loopback credentials never enter an environment proxy"
|
|
|
|
|
|
def test_client_bounds_sidecar_responses_and_non_string_paths():
|
|
"""A compromised local sidecar cannot allocate an unbounded response or smuggle a non-string URL."""
|
|
class Oversized:
|
|
def read(self, amount):
|
|
return b"x" * amount
|
|
|
|
with pytest.raises(HuxUnavailable, match="oversized response"):
|
|
client_mod._bounded_read(Oversized())
|
|
with pytest.raises(HuxServiceError) as malformed:
|
|
HuxClient("http://127.0.0.1:8790", WORKER).get(None) # type: ignore[arg-type]
|
|
assert malformed.value.status == 400
|
|
|
|
|
|
def test_put_with_if_match_and_get_with_query(live):
|
|
"""Revisioned writes send If-Match; a stale revision is a conflict; queries reach the service."""
|
|
base, _ = live
|
|
human = HuxClient(base, HUMAN, key="rk")
|
|
first = human.put("/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe"})
|
|
assert first.header("ETag") == "1" and first.header("Missing") == ""
|
|
second = human.put("/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "autonomous"}, if_match=1)
|
|
assert second.body["revision"] == 2
|
|
with pytest.raises(HuxServiceError) as stale:
|
|
human.put("/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe"}, if_match=1)
|
|
assert stale.value.code == "conflict"
|
|
assert human.get("/hux/v1/policy", {"scope": "global"}).body["autonomy"] == "autonomous"
|
|
|
|
|
|
# --- capabilities ---------------------------------------------------------------------
|
|
|
|
def test_capabilities_cached_per_process(live, monkeypatch):
|
|
"""SO-50: one capabilities read per process; refresh and forget re-read; failures are not cached."""
|
|
base, _ = live
|
|
worker = HuxClient(base, WORKER, key="wk")
|
|
calls = []
|
|
real_get = worker.get
|
|
monkeypatch.setattr(worker, "get", lambda path, query=None: calls.append(path) or real_get(path, query))
|
|
assert worker.card_enabled("HUX-05") and worker.card_enabled("HUX-01")
|
|
assert worker.capabilities()["contract_version"] == contracts.load_flags()["contract_version"]
|
|
assert len(calls) == 1
|
|
worker.capabilities(refresh=True)
|
|
worker.forget_capabilities()
|
|
worker.capabilities()
|
|
assert len(calls) == 3
|
|
monkeypatch.setattr(worker, "get", lambda path, query=None: (_ for _ in ()).throw(HuxServiceError(404, "flag_off", "off")))
|
|
worker.forget_capabilities()
|
|
assert worker.capabilities() == {"reachable": True, "cards": {}, "contract_version": ""}
|
|
dead = HuxClient("http://127.0.0.1:1", WORKER, key="wk", timeout=1)
|
|
assert dead.capabilities()["reachable"] is False and not dead.card_enabled("HUX-11")
|
|
|
|
|
|
# --- events ---------------------------------------------------------------------------
|
|
|
|
def test_emit_is_best_effort_and_redaction_safe(live):
|
|
"""SO-11: detail outside the allowlist is dropped by the service; failures return None and never raise."""
|
|
base, root = live
|
|
human = HuxClient(base, HUMAN, key="rk")
|
|
conv = human.post("/hux/v1/conversations", {"title": "t"}).body["id"]
|
|
worker = HuxClient(base, WORKER, key="wk")
|
|
record = emit(worker, conv, "tool.call", "shell call", {"tool": "shell", "arguments": {"cmd": CANARY}, "argument_bytes": 7},
|
|
evidence=[{"kind": "run", "id": "run_1"}], run_id="run_1", turn=2, correlation_id="corr-1", idempotency_key="run_1:call:0001")
|
|
assert contracts.validate_record(record, SCHEMAS) == [] and record["detail"] == {"tool": "shell", "argument_bytes": 7}
|
|
assert record["provenance"]["actor"] == {"type": "system", "id": "hux-worker"} and record["correlation_id"] == "corr-1"
|
|
assert emit(worker, conv, "tool.call", "again", idempotency_key="run_1:call:0001")["id"] == record["id"]
|
|
assert emit(worker, conv, "not.a.kind", "x") is None
|
|
assert emit(worker, "conv_unknown0001", "tool.call", "x") is None
|
|
assert emit(worker, conv, "tool.call", "x", {"tool": object()}) is None
|
|
assert emit(HuxClient(base, OTHER, key="rk"), conv, "tool.call", "cross tenant") is None
|
|
private = human.post("/hux/v1/conversations", {"title": "p", "mode": "private"}).body["id"]
|
|
assert emit(worker, private, "tool.call", "private mode writes nothing") is None
|
|
assert CANARY not in "\n".join(p.read_text(errors="ignore") for p in root.rglob("*") if p.is_file())
|
|
|
|
|
|
# --- memory gate ------------------------------------------------------------------------
|
|
|
|
def test_memory_gate(live, tmp_path):
|
|
"""SO-27, SO-28: allowed only when the privacy card answers and the conversation is not private."""
|
|
base, _ = live
|
|
human = HuxClient(base, HUMAN, key="rk")
|
|
worker = HuxClient(base, WORKER, key="wk")
|
|
normal = human.post("/hux/v1/conversations", {"title": "n", "mode": "thoughtful"}).body["id"]
|
|
private = human.post("/hux/v1/conversations", {"title": "p", "mode": "private"}).body["id"]
|
|
assert memory_gate(worker, normal) is True
|
|
assert memory_gate(worker, private) is False
|
|
assert memory_gate(worker, "conv_notknown01") is False
|
|
assert memory_gate(worker, "bad id") is False
|
|
human.post(f"/hux/v1/conversations/{normal}/forget", {})
|
|
assert memory_gate(worker, normal) is False
|
|
assert memory_gate(HuxClient("http://127.0.0.1:1", WORKER, key="wk", timeout=1), normal) is False
|
|
off_base, off_server = start(tmp_path / "off", "hux.foundation,hux.projects")
|
|
try:
|
|
assert memory_gate(HuxClient(off_base, WORKER, key="wk"), normal) is False
|
|
finally:
|
|
off_server.shutdown()
|
|
|
|
|
|
# --- pure helpers -------------------------------------------------------------------------
|
|
|
|
def test_hash_is_canonical():
|
|
"""SO-37: key order, whitespace and nesting order of dict keys do not change the hash; values do."""
|
|
a = canonical_argument_hash("write", {"path": "n.md", "opts": {"b": 1, "a": [1, 2]}})
|
|
b = canonical_argument_hash("write", {"opts": {"a": [1, 2], "b": 1}, "path": "n.md"})
|
|
assert a == b and a.startswith("sha256:") and len(a) == 71
|
|
assert canonical_argument_hash("write", {"path": "n.md ", "opts": {"b": 1, "a": [1, 2]}}) != a
|
|
assert canonical_argument_hash("other", {"path": "n.md", "opts": {"b": 1, "a": [1, 2]}}) != a
|
|
assert canonical_argument_hash("w", "raw string") == canonical_argument_hash("w", "raw string")
|
|
assert hooks.canonical_json({"z": "é", "a": None}) == b'{"a":null,"z":"\\u00e9"}'
|
|
|
|
|
|
def test_key_and_ref_helpers():
|
|
"""Idempotency keys always satisfy the contract pattern; call refs never contain arguments."""
|
|
assert hooks.idempotency_key("r", "a", "") == "r:a:.pad"
|
|
assert hooks.idempotency_key("r", "a", "x") == "r:a:x.pad"
|
|
key = hooks.idempotency_key("run id/with spaces", "approval", "f" * 200)
|
|
assert len(key) == 120 and " " not in key and "/" not in key
|
|
assert hooks.call_ref("my tool/x", "sha256:" + "ab" * 32) == "my-tool-x:abababababababab"
|
|
assert hooks._failure_reason(HuxServiceError(404, "flag_off", "")) == "flag_off"
|
|
assert hooks._failure_reason(HuxServiceError(0, "unavailable", "")) == "hux_unavailable"
|
|
assert hooks._failure_reason(HuxServiceError(409, "conflict", "")) == "service_error:conflict"
|
|
|
|
|
|
def test_library_is_stdlib_only_small_and_documented():
|
|
"""Every module ≤ 500 lines, every public function and module documented, no third-party imports."""
|
|
for path in sorted((HOOK_ROOT / "hux_hook").glob("*.py")):
|
|
source = path.read_text()
|
|
assert len(source.splitlines()) <= 500, path
|
|
tree = ast.parse(source)
|
|
assert ast.get_docstring(tree), path
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.FunctionDef | ast.ClassDef) and not node.name.startswith("_"):
|
|
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", "hmac", "json", "os", "stat", "threading", "urllib", "collections", "pathlib", "typing", "hashlib", "re", "dataclasses", "hux_hook", "__future__"}, root
|