210 lines
11 KiB
Python
210 lines
11 KiB
Python
"""HUX-02 memory review repairs: edit shaping (F3), stale-write resurrection (F7), idempotency race (F13b).
|
|
|
|
Security obligations exercised: SO-21 (deny topics never reach memory, even
|
|
through an edit), SO-22 (no_store and forget are content-free and final),
|
|
SO-23 (a forgotten entry never retrieves again), SO-28 (private mode refuses
|
|
memory writes), SO-44 (revision checks hold under concurrency).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import threading
|
|
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 contracts, identity, memory, privacy, store # noqa: E402
|
|
from hux.errors import Conflict # 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"}
|
|
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
|
USER = {"proposed_by": "user", "approval_mode": "automatic"}
|
|
|
|
|
|
def ident() -> identity.Identity:
|
|
return identity.Identity(tenant_slot="slot-3", subject="usr_0123456789abcdef", surface="chat", trust="router")
|
|
|
|
|
|
def router_for(tmp_path):
|
|
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"})
|
|
|
|
|
|
def call(router, method, path, body=None, headers=None):
|
|
raw = json.dumps(body).encode() if body is not None else b""
|
|
response = router.dispatch(method, path, {**HEADERS, **(headers or {})}, raw)
|
|
return response.status, response.body
|
|
|
|
|
|
def tenant(tmp_path) -> store.TenantStore:
|
|
return store.TenantStore(tmp_path, ident())
|
|
|
|
|
|
def conversation(router) -> str:
|
|
status, body = call(router, "POST", "/hux/v1/conversations", {"title": "tea"})
|
|
assert status == 201
|
|
return body["id"]
|
|
|
|
|
|
def active(router, conv, content="I like tea") -> dict:
|
|
status, body = call(router, "POST", "/hux/v1/memory", {"content": content, "conversation_id": conv, **USER})
|
|
assert (status, body["status"]) == (201, "active"), body
|
|
return body
|
|
|
|
|
|
def valid(record) -> dict:
|
|
assert contracts.validate_record(record, SCHEMAS) == [], record
|
|
return record
|
|
|
|
|
|
# --- F3: edit goes through the same shaping as a proposal ---------------------------
|
|
|
|
def test_f3_edit_with_deny_topic_becomes_no_store_and_leaves_the_original(tmp_path):
|
|
"""F3 / SO-21 / SO-22: a location + minors edit is refused content-free; the old entry is not superseded."""
|
|
router = router_for(tmp_path)
|
|
conv = conversation(router)
|
|
original = active(router, conv)
|
|
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "my home address is 12 Main Street; my daughter is 6"}, {"If-Match": "1"})
|
|
assert status == 202 and valid(body)["status"] == "no_store" and body["content"] == "" and "topic" not in body
|
|
assert body["sensitivity"] == "restricted" and body["retrievable"] is False and "supersedes" not in body
|
|
assert body["audit"][0]["note"] == "restricted; topic=location" and "Main Street" not in json.dumps(body)
|
|
assert body["id"] in memory.tombstoned(tenant(tmp_path))
|
|
status, again = call(router, "GET", f"/hux/v1/memory/{original['id']}")
|
|
assert status == 200 and again["status"] == "active" and again["revision"] == 1 and again["content"] == "I like tea"
|
|
assert [hit["content"] for hit in memory.retrieve(tenant(tmp_path), ["address"])] == []
|
|
assert [hit["id"] for hit in memory.retrieve(tenant(tmp_path), ["tea"])] == [original["id"]]
|
|
kinds = [row["kind"] for row in tenant(tmp_path).read("events", conv)]
|
|
assert kinds[-1] == "memory.suppressed"
|
|
|
|
|
|
def test_f3_edit_with_sensitive_content_is_proposed_with_ask(tmp_path):
|
|
"""F3 / SO-21: the sensitivity floor from detected topics applies to edits, so a health edit waits for approval."""
|
|
router = router_for(tmp_path)
|
|
conv = conversation(router)
|
|
original = active(router, conv)
|
|
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "I take lithium for my diagnosis"})
|
|
assert status == 200 and valid(body)["status"] == "proposed" and body["approval_mode"] == "ask"
|
|
assert body["sensitivity"] == "sensitive" and body["topic"] == "health" and body["retrievable"] is False
|
|
assert body["supersedes"] == original["id"] and [row["action"] for row in body["audit"]] == ["edited"]
|
|
assert call(router, "GET", f"/hux/v1/memory/{original['id']}")[1]["status"] == "forgotten"
|
|
|
|
|
|
def test_f3_edit_scrubs_secrets_and_floors_credentials(tmp_path):
|
|
"""F3 / SO-07: a secret in edited content is scrubbed and the credentials topic makes the edit no_store."""
|
|
router = router_for(tmp_path)
|
|
conv = conversation(router)
|
|
original = active(router, conv)
|
|
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"})
|
|
assert status == 202 and body["status"] == "no_store" and body["sensitivity"] == "restricted"
|
|
leaked = [path for path in tmp_path.rglob("*.json*") if "ghp_ABCDEFGHIJ" in path.read_text()]
|
|
assert leaked == []
|
|
|
|
|
|
def test_f3_edit_refuses_private_mode_and_declines_when_memory_disabled(tmp_path):
|
|
"""F3 / SO-28: private mode is a 403 for edits too; disable_memory_here turns the edit into no_store."""
|
|
router = router_for(tmp_path)
|
|
conv = conversation(router)
|
|
original = active(router, conv)
|
|
privacy.set_flag(tenant(tmp_path), conv, "memory_disabled", True)
|
|
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "I like coffee"})
|
|
assert status == 202 and body["status"] == "no_store" and body["audit"][0]["note"].startswith("memory_disabled")
|
|
assert call(router, "GET", f"/hux/v1/memory/{original['id']}")[1]["status"] == "active"
|
|
privacy.set_flag(tenant(tmp_path), conv, "memory_disabled", False)
|
|
assert call(router, "PATCH", f"/hux/v1/conversations/{conv}", {"mode": "private"})[0] == 200
|
|
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "I like coffee"})
|
|
assert status == 403 and body["code"] == "forbidden"
|
|
assert call(router, "GET", f"/hux/v1/memory/{original['id']}")[1]["status"] == "active"
|
|
|
|
|
|
def test_f3_edit_still_needs_content(tmp_path):
|
|
router = router_for(tmp_path)
|
|
original = active(router, conversation(router))
|
|
assert call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": " "})[0] == 400
|
|
assert call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "x" * 2001})[0] == 400
|
|
|
|
|
|
# --- F7: a stale record cannot resurrect a forgotten entry ------------------------
|
|
|
|
def test_f7_stale_approve_after_forget_is_a_conflict(tmp_path, monkeypatch):
|
|
"""F7 / SO-22 / SO-44: approve holding a pre-reject snapshot (the a2-B interleaving) is refused and the entry stays rejected."""
|
|
router = router_for(tmp_path)
|
|
conv = conversation(router)
|
|
status, proposed = call(router, "POST", "/hux/v1/memory", {"content": "I take lithium daily", "conversation_id": conv, "proposed_by": "user", "approval_mode": "ask"})
|
|
assert (status, proposed["status"]) == (201, "proposed")
|
|
stale = dict(proposed)
|
|
assert call(router, "POST", f"/hux/v1/memory/{proposed['id']}/reject")[1]["status"] == "rejected"
|
|
monkeypatch.setattr(memory, "load", lambda store, memory_id, now=None: stale)
|
|
status, body = call(router, "POST", f"/hux/v1/memory/{proposed['id']}/approve")
|
|
assert status == 409 and body["code"] == "conflict"
|
|
monkeypatch.undo()
|
|
status, after = call(router, "GET", f"/hux/v1/memory/{proposed['id']}")
|
|
assert after["status"] == "rejected" and after["content"] == "" and after["revision"] == 2
|
|
assert memory.retrieve(tenant(tmp_path), ["lithium"]) == []
|
|
assert call(router, "GET", "/hux/v1/memory/export")[1]["items"] == []
|
|
|
|
|
|
def test_f7_stale_remove_retrieval_cannot_overwrite_a_forget(tmp_path, monkeypatch):
|
|
"""F7: the unconditional retrieval branch re-reads the entry under the lock, so the a5 race ends in 409 and no restore works."""
|
|
router = router_for(tmp_path)
|
|
conv = conversation(router)
|
|
entry = active(router, conv, "I take lithium daily")
|
|
stale = dict(entry)
|
|
assert call(router, "POST", f"/hux/v1/memory/{entry['id']}/forget")[1]["status"] == "forgotten"
|
|
monkeypatch.setattr(memory, "load", lambda store, memory_id, now=None: stale)
|
|
assert call(router, "POST", f"/hux/v1/memory/{entry['id']}/remove_retrieval")[0] == 409
|
|
monkeypatch.undo()
|
|
assert call(router, "POST", f"/hux/v1/memory/{entry['id']}/restore_retrieval")[0] == 409
|
|
assert call(router, "GET", f"/hux/v1/memory/{entry['id']}")[1]["status"] == "forgotten"
|
|
assert memory.retrieve(tenant(tmp_path), ["lithium"]) == []
|
|
|
|
|
|
def test_f7_transition_checks_if_match_against_the_fresh_copy(tmp_path):
|
|
"""F7 / SO-44: If-Match is compared with the stored revision, not the caller's stale snapshot."""
|
|
router = router_for(tmp_path)
|
|
entry = active(router, conversation(router))
|
|
s = tenant(tmp_path)
|
|
memory._set_retrievable(s, entry, False, {"type": "user", "id": "usr_0123456789abcdef"}, 1)
|
|
try:
|
|
memory._transition(s, ident(), entry, "forgotten", "forgotten", {"type": "user", "id": "usr_0123456789abcdef"}, expected=1)
|
|
except Conflict as error:
|
|
assert "revision 1 does not match current revision 2" in str(error)
|
|
else:
|
|
raise AssertionError("stale If-Match must conflict")
|
|
assert memory.load(s, entry["id"])["status"] == "active"
|
|
stored = memory._transition(s, ident(), entry, "forgotten", "forgotten", {"type": "user", "id": "usr_0123456789abcdef"})
|
|
assert stored["status"] == "forgotten" and stored["revision"] == 3
|
|
|
|
|
|
# --- F13b: Idempotency-Key is checked under the lock ------------------------------
|
|
|
|
def test_f13b_concurrent_proposals_with_one_key_create_exactly_one_record(tmp_path):
|
|
"""F13b: eight threads racing one Idempotency-Key produce one memory entry; seven are 200 replays."""
|
|
router = router_for(tmp_path)
|
|
results: list[tuple[int, str]] = []
|
|
barrier = threading.Barrier(8)
|
|
|
|
def propose() -> None:
|
|
barrier.wait(5)
|
|
status, body = call(router, "POST", "/hux/v1/memory", {"content": "I like tea", **USER}, {"Idempotency-Key": "same-key-0001"})
|
|
results.append((status, body["id"]))
|
|
|
|
threads = [threading.Thread(target=propose) for _ in range(8)]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join(10)
|
|
assert sorted(status for status, _ in results) == [200] * 7 + [201]
|
|
assert len({memory_id for _, memory_id in results}) == 1
|
|
assert len(call(router, "GET", "/hux/v1/memory")[1]["items"]) == 1
|
|
assert len(tenant(tmp_path).read(memory.IDEM_FAMILY, "keys")) == 1
|
|
|
|
|
|
def test_memory_module_stays_under_500_lines():
|
|
assert len((FOUNDATION / "hux" / "memory.py").read_text().splitlines()) <= 500
|