314 lines
17 KiB
Python
314 lines
17 KiB
Python
"""HUX-02 memory ledger: proposals, consent transitions, edits, If-Match and no-store.
|
|
|
|
Security obligations exercised: SO-10 (server-set owner and provenance),
|
|
SO-15 (server-assigned ids), SO-18 (foreign ids are 404), SO-21 (policy
|
|
violations suppressed with an event), SO-22 (no_store and forget write a
|
|
content-free line plus tombstone), SO-24 (forget re-redacts events), SO-25
|
|
(supersedes of a forgotten id needs a user approval), SO-26 (export scope and
|
|
audit), SO-28 (private mode refuses writes), SO-35-style human-only decisions,
|
|
SO-44 (If-Match conflicts, unconditional writes audited).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
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, events, identity, memory, 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"])
|
|
CONV = "conv_0001abcd"
|
|
USER = {"proposed_by": "user"}
|
|
|
|
|
|
def ident(**overrides) -> identity.Identity:
|
|
base = {"tenant_slot": "slot-3", "subject": "usr_0123456789abcdef", "surface": "chat", "trust": "router"}
|
|
return identity.Identity(**{**base, **overrides})
|
|
|
|
|
|
def router_for(tmp_path):
|
|
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"})
|
|
|
|
|
|
def call(router, method, path, headers=HEADERS, body=None):
|
|
raw = json.dumps(body).encode() if body is not None else b""
|
|
response = router.dispatch(method, path, headers, raw)
|
|
return response.status, response.body, response
|
|
|
|
|
|
def tenant(tmp_path, who=None) -> store.TenantStore:
|
|
return store.TenantStore(tmp_path, who or ident())
|
|
|
|
|
|
def valid(record) -> None:
|
|
assert contracts.validate_record(record, SCHEMAS) == [], record
|
|
|
|
|
|
def propose(router, headers=HEADERS, **fields):
|
|
body = {"kind": "preference", "content": "Prefers terse answers with code first.", "reason": "asked twice", "conversation_id": CONV, **fields}
|
|
return call(router, "POST", "/hux/v1/memory", headers, body)
|
|
|
|
|
|
def event_kinds(tmp_path, conversation=CONV):
|
|
return [row["kind"] for row in tenant(tmp_path).read(events.FAMILY, conversation)]
|
|
|
|
|
|
# --- proposals ------------------------------------------------------------------
|
|
|
|
def test_user_proposal_with_personal_content_is_active_immediately(tmp_path):
|
|
router = router_for(tmp_path)
|
|
status, body, _ = propose(router, **USER, scope={"level": "project", "scope_id": "prj_0001aaaa"}, source={"kind": "message", "id": "msg-42"}, run_id="run_9f")
|
|
assert status == 201 and body["status"] == "active" and body["approval_mode"] == "automatic" and body["retrievable"] is True
|
|
assert body["owner"] == "usr_0123456789abcdef" and body["provenance"]["actor"]["type"] == "user" and body["provenance"]["run_id"] == "run_9f"
|
|
assert body["ttl"] == {"policy": "decay", "decay_days": 180} and body["topic"] == "general" and body["revision"] == 1
|
|
assert [row["action"] for row in body["audit"]] == ["proposed", "approved"]
|
|
valid(body)
|
|
assert event_kinds(tmp_path) == ["memory.committed"]
|
|
ledger = tenant(tmp_path).read(memory.FAMILY, memory.LEDGER)
|
|
assert [row["id"] for row in ledger] == [body["id"]]
|
|
|
|
|
|
def test_assistant_proposal_is_suggest_only(tmp_path):
|
|
router = router_for(tmp_path)
|
|
status, body, _ = propose(router)
|
|
assert status == 201 and body["status"] == "proposed" and body["approval_mode"] == "ask" and body["retrievable"] is False
|
|
assert body["provenance"]["actor"] == {"type": "assistant", "id": "hermes"}
|
|
valid(body)
|
|
assert event_kinds(tmp_path) == ["memory.proposed"]
|
|
status, explicit, _ = propose(router, **USER, approval_mode="ask")
|
|
assert explicit["status"] == "proposed"
|
|
|
|
|
|
def test_body_supplied_identity_and_ids_are_rejected_or_ignored(tmp_path):
|
|
router = router_for(tmp_path)
|
|
assert propose(router, id="mem_evil0001")[0] == 400
|
|
assert propose(router, revision=7)[0] == 400
|
|
assert propose(router, status="active")[0] == 400
|
|
assert propose(router, content="")[0] == 400
|
|
assert propose(router, content="x" * 2001)[0] == 400
|
|
assert propose(router, sensitivity="ultra")[0] == 400
|
|
assert call(router, "POST", "/hux/v1/memory", HEADERS, [1])[0] == 400
|
|
status, body, _ = propose(router, **USER, owner="usr_fedcba9876543210", kind="weird", topic="not-a-topic", ttl="never", scope="bad", source="bad")
|
|
assert status == 201 and body["owner"] == "usr_0123456789abcdef" and body["kind"] == "fact" and body["scope"] == {"level": "global"}
|
|
assert body["source"] == {"kind": "message", "id": "unspecified"}
|
|
|
|
|
|
def test_sensitive_content_always_asks_and_decays(tmp_path):
|
|
router = router_for(tmp_path)
|
|
status, body, _ = propose(router, **USER, content="My therapist suggested a new medication for anxiety.", ttl={"policy": "never"})
|
|
assert status == 201 and body["status"] == "proposed" and body["approval_mode"] == "ask"
|
|
assert body["sensitivity"] == "sensitive" and body["topic"] == "health" and body["ttl"] == {"policy": "decay", "decay_days": 30}
|
|
valid(body)
|
|
status, body, _ = propose(router, **USER, content="plain", sensitivity="sensitive")
|
|
assert body["approval_mode"] == "ask" and body["topic"] == "general"
|
|
|
|
|
|
def test_no_store_writes_tombstone_and_content_free_line(tmp_path):
|
|
router = router_for(tmp_path)
|
|
status, body, _ = propose(router, **USER, content="my password is hunter2hunter2 for the bank")
|
|
assert status == 202 and body["status"] == "no_store" and body["content"] == "" and body["retrievable"] is False and "topic" not in body
|
|
assert body["audit"][0]["action"] == "no_store" and "credentials" in body["audit"][0]["note"]
|
|
valid(body)
|
|
s = tenant(tmp_path)
|
|
assert "hunter2" not in json.dumps(s.read(memory.FAMILY, memory.LEDGER))
|
|
assert body["id"] in memory.tombstoned(s)
|
|
assert event_kinds(tmp_path) == ["memory.suppressed"]
|
|
status, declined, _ = propose(router, **USER, approval_mode="no_store", content="plain thing")
|
|
assert status == 202 and declined["audit"][0]["note"].startswith("declined")
|
|
status, restricted, _ = propose(router, **USER, sensitivity="restricted", content="plain")
|
|
assert status == 202 and restricted["audit"][0]["note"].startswith("restricted")
|
|
status, sensitive_declined, _ = propose(router, **USER, approval_mode="no_store", content="my mortgage rate")
|
|
assert status == 202 and sensitive_declined["approval_mode"] == "ask"
|
|
assert memory.retrieve(s, ["password", "mortgage", "plain"]) == []
|
|
|
|
|
|
def test_private_mode_conversation_refuses_memory(tmp_path):
|
|
router = router_for(tmp_path)
|
|
tenant(tmp_path).put("conversations", {"id": "conv_priv0001", "mode": "private"})
|
|
status, body, _ = propose(router, **USER, conversation_id="conv_priv0001")
|
|
assert (status, body["code"]) == (403, "forbidden")
|
|
assert tenant(tmp_path).count(memory.FAMILY) == 0
|
|
|
|
|
|
def test_policy_violation_is_suppressed_with_event(tmp_path):
|
|
router = router_for(tmp_path)
|
|
status, body, _ = propose(router, **USER, ttl={"policy": "expires_at"})
|
|
assert status == 400 and any("expires_at" in d for d in body["details"])
|
|
assert event_kinds(tmp_path) == ["memory.suppressed"]
|
|
assert tenant(tmp_path).count(memory.FAMILY) == 0
|
|
|
|
|
|
def test_proposal_idempotency_key_replays(tmp_path):
|
|
router = router_for(tmp_path)
|
|
propose(router, {**HEADERS, "Idempotency-Key": "conv:mem:0000"}, **USER)
|
|
headers = {**HEADERS, "Idempotency-Key": "conv:mem:0001"}
|
|
status, first, _ = propose(router, headers, **USER)
|
|
status, again, response = propose(router, headers, **USER)
|
|
assert status == 200 and again == first and response.headers["HUX-Replayed"] == "true"
|
|
assert tenant(tmp_path).count(memory.FAMILY) == 2
|
|
|
|
|
|
# --- transitions -------------------------------------------------------------------
|
|
|
|
def test_approve_reject_and_illegal_transitions(tmp_path):
|
|
router = router_for(tmp_path)
|
|
_, proposed, _ = propose(router)
|
|
path = f"/hux/v1/memory/{proposed['id']}"
|
|
status, approved, response = call(router, "POST", f"{path}/approve", {**HEADERS, "If-Match": "1"})
|
|
assert status == 200 and approved["status"] == "active" and approved["retrievable"] is True and approved["revision"] == 2
|
|
assert response.headers["ETag"] == "2" and approved["audit"][-1]["actor"] == {"type": "user", "id": "usr_0123456789abcdef"}
|
|
valid(approved)
|
|
status, body, _ = call(router, "POST", f"{path}/approve")
|
|
assert (status, body["code"]) == (409, "conflict")
|
|
_, other, _ = propose(router)
|
|
status, rejected, _ = call(router, "POST", f"/hux/v1/memory/{other['id']}/reject")
|
|
assert status == 200 and rejected["status"] == "rejected" and rejected["content"] == "" and rejected["retrievable"] is False
|
|
valid(rejected)
|
|
assert call(router, "POST", f"/hux/v1/memory/{other['id']}/forget")[0] == 409
|
|
assert event_kinds(tmp_path) == ["memory.proposed", "memory.committed", "memory.proposed", "memory.suppressed"]
|
|
|
|
|
|
def test_if_match_conflicts_and_unconditional_writes_are_audited(tmp_path):
|
|
router = router_for(tmp_path)
|
|
_, proposed, _ = propose(router)
|
|
path = f"/hux/v1/memory/{proposed['id']}"
|
|
status, body, _ = call(router, "POST", f"{path}/approve", {**HEADERS, "If-Match": "5"})
|
|
assert (status, body["code"]) == (409, "conflict") and body["details"] == ["1"]
|
|
assert call(router, "POST", f"{path}/approve", {**HEADERS, "If-Match": "x"})[0] == 400
|
|
assert call(router, "POST", f"{path}/approve")[0] == 200
|
|
rows = [(r["action"], r["outcome"], r.get("reason", "")) for r in audit.recent(tenant(tmp_path)) if r["action"] in {"memory.act", "memory.approve"}]
|
|
assert rows[-3:] == [("memory.act", "conflict", "revision 5 does not match current revision 1"), ("memory.act", "deny", "If-Match must be a revision integer"), ("memory.approve", "allow", "unconditional_write")]
|
|
|
|
|
|
def test_forget_drops_content_tombstones_and_redacts_events(tmp_path):
|
|
router = router_for(tmp_path)
|
|
_, active, _ = propose(router, **USER)
|
|
s = tenant(tmp_path)
|
|
events.emit(s, ident(), CONV, "message.assistant", "used memory", evidence=[{"kind": "memory", "id": active["id"]}])
|
|
status, forgotten, _ = call(router, "POST", f"/hux/v1/memory/{active['id']}/forget", {**HEADERS, "If-Match": "1"})
|
|
assert status == 200 and forgotten["status"] == "forgotten" and forgotten["content"] == "" and forgotten["retrievable"] is False
|
|
valid(forgotten)
|
|
assert active["id"] in memory.tombstoned(s)
|
|
rows = s.read(events.FAMILY, CONV)
|
|
assert [r["kind"] for r in rows] == ["memory.committed", "message.assistant", "memory.forgotten"]
|
|
assert rows[0]["redaction"]["level"] == "full" and rows[1]["redaction"]["level"] == "full"
|
|
assert call(router, "GET", f"/hux/v1/memory/{active['id']}")[1]["status"] == "forgotten"
|
|
assert call(router, "POST", f"/hux/v1/memory/{active['id']}/forget")[0] == 409
|
|
|
|
|
|
def test_edit_supersedes_and_forgets_the_old_entry(tmp_path):
|
|
router = router_for(tmp_path)
|
|
_, active, _ = propose(router, **USER)
|
|
status, fresh, _ = call(router, "POST", f"/hux/v1/memory/{active['id']}/edit", {**HEADERS, "If-Match": "1"}, {"content": "Prefers long answers now."})
|
|
assert status == 200 and fresh["id"] != active["id"] and fresh["supersedes"] == active["id"] and fresh["status"] == "active"
|
|
assert fresh["content"] == "Prefers long answers now." and [a["action"] for a in fresh["audit"]] == ["edited", "approved"]
|
|
valid(fresh)
|
|
old = tenant(tmp_path).get(memory.FAMILY, active["id"])
|
|
assert old["status"] == "forgotten" and old["content"] == "" and old["audit"][-1]["action"] == "superseded"
|
|
assert call(router, "POST", f"/hux/v1/memory/{fresh['id']}/edit", HEADERS, {"content": ""})[0] == 400
|
|
assert call(router, "POST", f"/hux/v1/memory/{fresh['id']}/edit", HEADERS)[0] == 400
|
|
assert event_kinds(tmp_path)[-1] == "memory.committed"
|
|
|
|
|
|
def test_edit_of_sensitive_entry_goes_back_to_ask(tmp_path):
|
|
router = router_for(tmp_path)
|
|
_, proposed, _ = propose(router, **USER, content="my mortgage is with the bank")
|
|
_, active, _ = call(router, "POST", f"/hux/v1/memory/{proposed['id']}/approve")
|
|
status, fresh, _ = call(router, "POST", f"/hux/v1/memory/{active['id']}/edit", HEADERS, {"content": "refinanced the mortgage"})
|
|
assert status == 200 and fresh["status"] == "proposed" and fresh["approval_mode"] == "ask" and fresh["retrievable"] is False
|
|
valid(fresh)
|
|
|
|
|
|
def test_supersedes_of_forgotten_id_requires_ask(tmp_path):
|
|
router = router_for(tmp_path)
|
|
_, active, _ = propose(router, **USER)
|
|
call(router, "POST", f"/hux/v1/memory/{active['id']}/forget")
|
|
status, body, _ = propose(router, **USER, supersedes=active["id"])
|
|
assert status == 201 and body["status"] == "proposed" and body["approval_mode"] == "ask" and body["supersedes"] == active["id"]
|
|
status, body, _ = propose(router, **USER, source={"kind": "memory", "id": active["id"]})
|
|
assert body["approval_mode"] == "ask"
|
|
|
|
|
|
def test_retrieval_removal_and_restore(tmp_path):
|
|
router = router_for(tmp_path)
|
|
_, active, _ = propose(router, **USER)
|
|
path = f"/hux/v1/memory/{active['id']}"
|
|
status, removed, _ = call(router, "POST", f"{path}/remove_retrieval")
|
|
assert status == 200 and removed["status"] == "active" and removed["retrievable"] is False and removed["audit"][-1]["action"] == "retrieval_removed"
|
|
valid(removed)
|
|
assert memory.retrieve(tenant(tmp_path), []) == []
|
|
status, restored, _ = call(router, "POST", f"{path}/restore_retrieval", {**HEADERS, "If-Match": "2"})
|
|
assert status == 200 and restored["retrievable"] is True and restored["audit"][-1]["note"] == "retrieval restored"
|
|
assert [m["id"] for m in memory.retrieve(tenant(tmp_path), [])] == [active["id"]]
|
|
_, proposed, _ = propose(router)
|
|
assert call(router, "POST", f"/hux/v1/memory/{proposed['id']}/remove_retrieval")[0] == 409
|
|
assert event_kinds(tmp_path)[:3] == ["memory.committed", "memory.retrieval_removed", "memory.committed"]
|
|
|
|
|
|
def test_decisions_need_a_human_surface(tmp_path):
|
|
router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk"})
|
|
_, proposed, _ = propose(router)
|
|
worker = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
|
|
status, body, _ = call(router, "POST", f"/hux/v1/memory/{proposed['id']}/approve", worker)
|
|
assert (status, body["code"]) == (403, "forbidden")
|
|
api = {**HEADERS, "X-Hux-Surface": "api"}
|
|
assert call(router, "POST", f"/hux/v1/memory/{proposed['id']}/approve", api)[0] == 403
|
|
status, body, _ = propose(router, worker, proposed_by="user")
|
|
assert body["provenance"]["actor"]["type"] == "assistant"
|
|
|
|
|
|
# --- reads, ownership, export ------------------------------------------------------
|
|
|
|
def test_list_get_and_cross_tenant_denial(tmp_path):
|
|
router = router_for(tmp_path)
|
|
_, active, _ = propose(router, **USER, scope={"level": "project", "scope_id": "prj_0001aaaa"})
|
|
_, proposed, _ = propose(router)
|
|
status, body, _ = call(router, "GET", "/hux/v1/memory")
|
|
assert status == 200 and {m["id"] for m in body["items"]} == {active["id"], proposed["id"]}
|
|
for item in body["items"]:
|
|
valid(item)
|
|
assert [m["id"] for m in call(router, "GET", "/hux/v1/memory?status=proposed")[1]["items"]] == [proposed["id"]]
|
|
assert [m["id"] for m in call(router, "GET", "/hux/v1/memory?scope=project:prj_0001aaaa")[1]["items"]] == [active["id"]]
|
|
assert call(router, "GET", "/hux/v1/memory?scope=project:prj_other000")[1]["items"] == []
|
|
assert call(router, "GET", "/hux/v1/memory?scope=global")[1]["items"][0]["id"] == proposed["id"]
|
|
status, got, response = call(router, "GET", f"/hux/v1/memory/{active['id']}")
|
|
assert status == 200 and got == active and response.headers["ETag"] == "1"
|
|
assert call(router, "GET", f"/hux/v1/memory/{active['id']}", OTHER)[0] == 404
|
|
assert call(router, "POST", f"/hux/v1/memory/{active['id']}/forget", OTHER)[0] == 404
|
|
assert call(router, "GET", "/hux/v1/memory", OTHER)[1]["items"] == []
|
|
assert call(router, "GET", "/hux/v1/memory/not-an-id")[0] == 404
|
|
assert call(router, "GET", "/hux/v1/memory/mem_missing00")[0] == 404
|
|
assert call(router, "POST", f"/hux/v1/memory/{active['id']}/explode")[0] == 404
|
|
assert call(router, "POST", "/hux/v1/memory/mem_missing00/approve")[0] == 404
|
|
|
|
|
|
def test_export_is_active_only_and_audited(tmp_path):
|
|
router = router_for(tmp_path)
|
|
_, active, _ = propose(router, **USER)
|
|
_, hidden, _ = propose(router, **USER)
|
|
call(router, "POST", f"/hux/v1/memory/{hidden['id']}/remove_retrieval")
|
|
propose(router)
|
|
_, gone, _ = propose(router, **USER)
|
|
call(router, "POST", f"/hux/v1/memory/{gone['id']}/forget")
|
|
status, body, response = call(router, "GET", "/hux/v1/memory/export")
|
|
assert status == 200 and [m["id"] for m in body["items"]] == [active["id"]]
|
|
assert body["items"][0]["audit"][-1]["action"] == "exported" and body["items"][0]["revision"] == 2
|
|
assert response.headers["Content-Disposition"].startswith("attachment")
|
|
valid(body["items"][0])
|
|
assert tenant(tmp_path).read(memory.FAMILY, "exports")[0]["ids"] == [active["id"]]
|
|
assert call(router, "GET", "/hux/v1/memory/export", OTHER)[1]["items"] == []
|
|
|
|
|
|
def test_memory_module_stays_under_500_lines():
|
|
assert len((FOUNDATION / "hux" / "memory.py").read_text().splitlines()) <= 500
|