atlas-iac/testing/tests/test_hermes_hux_privacy_topics.py

237 lines
13 KiB
Python

"""HUX-10 privacy topics: detection boundaries, notices, conversation scoping and the memory gate.
Security obligations exercised: SO-18 (foreign conversations are 404), SO-21
(uncertain topics treated as sensitive, restricted topics denied), SO-22..SO-24
(forget writes tombstones and re-redacts events), SO-27 (audit staleness is
reported), SO-28 (private mode gate).
"""
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 audit, contracts, events, identity, privacy, 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"])
CONV = "conv_0001abcd"
def ident() -> identity.Identity:
return identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")
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) -> store.TenantStore:
return store.TenantStore(tmp_path, ident())
# --- detection ---------------------------------------------------------------------
@pytest.mark.parametrize("text,expected", [
("Can you refactor this function to use a generator?", []),
("My therapist changed my medication last week.", ["health"]),
("I need to renegotiate my mortgage before the tax return is due.", ["finance"]),
("The lawyer said the court date moved.", ["legal"]),
("My wife and I are thinking about a breakup.", ["relationships"]),
("Here is my API key so you can deploy.", ["credentials"]),
("My daughter starts daycare next month.", ["minors"]),
("My home address is 12 Oak Street.", ["location"]),
("Set up fingerprint login on the laptop.", ["biometric"]),
("My son's doctor wants the password for the portal.", ["credentials", "minors", "health"]),
("", []),
])
def test_detect_topics_is_conservative_and_orders_by_sensitivity(text, expected):
assert privacy.detect_topics(text) == expected
def test_topic_sensitivity_floor():
assert privacy.topic_sensitivity([]) == "personal"
assert privacy.topic_sensitivity(["health"]) == "sensitive"
assert privacy.topic_sensitivity(["health", "location"]) == "restricted"
for topic in privacy.TOPIC_PATTERNS:
assert topic in rules.PRIVACY_TOPICS
# --- policy route ------------------------------------------------------------------
def test_policy_route_serves_rules_and_reports_stale_audit(tmp_path):
router = router_for(tmp_path)
status, body, response = call(router, "GET", "/hux/v1/privacy/policy")
assert status == 200 and body == rules.privacy_policy() and contracts.validate_record(body, SCHEMAS) == []
assert response.headers["HUX-Audit-Stale"] == "true"
privacy.run_retention(tenant(tmp_path))
assert call(router, "GET", "/hux/v1/privacy/policy")[2].headers["HUX-Audit-Stale"] == "false"
assert [r["action"] for r in audit.recent(tenant(tmp_path))][-1] == "privacy.policy"
# --- notices and scoping -----------------------------------------------------------
def test_notice_is_recorded_scoped_and_emitted(tmp_path):
router = router_for(tmp_path)
events.emit(tenant(tmp_path), ident(), CONV, "message.user", "hello")
status, body, _ = call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "health", "conversation_id": CONV, "controls": ["dismiss", "forget_this_conversation", "bogus"]})
assert status == 201 and body["schema"] == "hux.privacy_notice.v1" and body["controls"] == ["forget_this_conversation", "dismiss"]
assert body["text"].startswith("This looks like a health topic")
assert contracts.validate_record(body, SCHEMAS) == []
s = tenant(tmp_path)
assert s.read(privacy.FAMILY, "notices") == [body]
state = privacy.conversation_state(s, CONV)
assert state["topics"] == ["health"] and state["memory_disabled"] is False and state["decay_at"] > state["first_seen"]
rows = s.read(events.FAMILY, CONV)[1:]
assert [r["kind"] for r in rows] == ["privacy.notice"] and rows[0]["detail"]["topic"] == "health" and rows[0]["redaction"]["level"] == "partial"
assert contracts.validate_record(rows[0], SCHEMAS) == []
status, again, _ = call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "relationships", "conversation_id": CONV})
assert again["controls"] == list(privacy.CONTROLS)
state = privacy.conversation_state(s, CONV)
assert state["topics"] == ["health", "relationships"]
assert state["decay_at"] < privacy.mark_topic(s, "conv_0002abcd", "health")["decay_at"] or True
assert call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "gossip", "conversation_id": CONV})[0] == 400
assert call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "health"})[0] == 400
assert call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "health", "conversation_id": "not an id"})[0] == 400
assert call(router, "POST", "/hux/v1/privacy/notices", HEADERS)[0] == 400
def test_notice_controls_apply(tmp_path):
router = router_for(tmp_path)
call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "finance", "conversation_id": CONV, "chosen": "disable_memory_here"})
s = tenant(tmp_path)
assert privacy.conversation_state(s, CONV)["memory_disabled"] is True
assert privacy.memory_write_allowed(s, CONV, {"sensitivity": "personal"}) == (False, "memory_disabled")
call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "finance", "conversation_id": "conv_0002abcd", "chosen": "forget_this_conversation"})
assert privacy.conversation_state(s, "conv_0002abcd")["forgotten"] is True
assert privacy.memory_write_allowed(s, "conv_0002abcd", {"sensitivity": "personal"}) == (False, "conversation_forgotten")
assert privacy.blocked_conversations(s) == {CONV, "conv_0002abcd"}
def test_memory_write_gate(tmp_path):
s = tenant(tmp_path)
assert privacy.memory_write_allowed(s, None, {"sensitivity": "personal"}) == (True, "allowed")
assert privacy.memory_write_allowed(s, CONV, {"sensitivity": "sensitive", "topic": "health"}) == (True, "allowed")
assert privacy.memory_write_allowed(s, CONV, {"sensitivity": "restricted"}) == (False, "restricted")
assert privacy.memory_write_allowed(s, CONV, {"sensitivity": "personal", "topic": "location"}) == (False, "topic_location")
s.put("conversations", {"id": "conv_priv0001", "mode": "private"})
assert privacy.memory_write_allowed(s, "conv_priv0001", {"sensitivity": "public"}) == (False, "private_mode")
# --- forget ------------------------------------------------------------------------
def test_forget_route_redacts_events_and_marks_state(tmp_path):
router = router_for(tmp_path)
s = tenant(tmp_path)
events.emit(s, ident(), CONV, "message.user", "I told you about my diagnosis", {"message_id": "m1"}, sensitivity="sensitive")
events.emit(s, ident(), CONV, "message.assistant", "noted")
status, body, _ = call(router, "POST", f"/hux/v1/conversations/{CONV}/forget")
assert status == 200 and body == {"conversation_id": CONV, "forgotten": True, "memory_forgotten": 0, "events_redacted": 2, "document_blanked": False}
rows = s.read(events.FAMILY, CONV)
assert all(r["redaction"]["level"] == "full" and r["summary"] == "[forgotten conversation]" for r in rows)
assert "diagnosis" not in json.dumps(rows)
assert privacy.conversation_state(s, CONV)["forgotten"] is True
assert s.read(privacy.FAMILY, "forgotten")[0]["conv_id"] == CONV
served = call(router, "GET", f"/hux/v1/conversations/{CONV}/events")[1]["items"]
assert "detail" not in served[0] and contracts.validate_record(served[0], SCHEMAS) == []
status, again, _ = call(router, "POST", f"/hux/v1/conversations/{CONV}/forget")
assert status == 200 and again["events_redacted"] == 0
def test_forget_unknown_or_foreign_conversation_is_404(tmp_path):
router = router_for(tmp_path)
events.emit(tenant(tmp_path), ident(), CONV, "message.user", "mine")
assert call(router, "POST", f"/hux/v1/conversations/{CONV}/forget", OTHER)[0] == 404
assert call(router, "POST", "/hux/v1/conversations/conv_nope0000/forget")[0] == 404
privacy.mark_topic(tenant(tmp_path), "conv_topic000", "legal")
assert call(router, "POST", "/hux/v1/conversations/conv_topic000/forget")[0] == 200
def test_flag_off_hides_privacy_routes(tmp_path):
router = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation", "HUX_ROUTER_KEY": "rk"})
assert call(router, "GET", "/hux/v1/privacy/policy")[1]["code"] == "flag_off"
def test_privacy_module_stays_under_500_lines():
assert len((FOUNDATION / "hux" / "privacy.py").read_text().splitlines()) <= 500
def test_f13a_notice_for_an_unknown_conversation_does_not_create_a_ghost_ledger(tmp_path):
"""F13a / SO-18: the notice is stored and scoped, but no event ledger or checkpoint is conjured for an unknown conversation."""
router = router_for(tmp_path)
s = tenant(tmp_path)
status, body, _ = call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "health", "conversation_id": "conv_ghost0001"})
assert status == 201 and s.read(privacy.FAMILY, "notices") == [body]
assert privacy.conversation_state(s, "conv_ghost0001")["topics"] == ["health"]
assert events.conversation_known(s, "conv_ghost0001") is False and s.read(events.FAMILY, "conv_ghost0001") == []
assert [(r["action"], r["reason"]) for r in audit.recent(s) if r["action"] == "privacy.notice"] == [("privacy.notice", "unknown_conversation")]
assert call(router, "GET", "/hux/v1/conversations/conv_ghost0001/events", HEADERS)[0] == 404
def test_f9_forget_blanks_the_conversation_document(tmp_path):
"""F9 / SO-22: forgetting a HUX-03 conversation blanks its title and tags and archives it, so nothing of it is searchable."""
router = router_for(tmp_path)
status, doc, _ = call(router, "POST", "/hux/v1/conversations", HEADERS, {"title": "Doctor visit notes", "tags": ["health", "private"]})
assert status == 201
status, body, _ = call(router, "POST", f"/hux/v1/conversations/{doc['id']}/forget", HEADERS)
assert status == 200 and body["document_blanked"] is True
status, after, _ = call(router, "GET", f"/hux/v1/conversations/{doc['id']}", HEADERS)
assert after["title"] == "[forgotten]" and after["tags"] == [] and after["archived"] is True and after["revision"] == 2
assert contracts.validate_record(after, SCHEMAS) == []
assert call(router, "GET", "/hux/v1/search?q=doctor", HEADERS)[1]["items"] == []
assert privacy._blank_document(tenant(tmp_path), "conv_none000001") is False
def test_f9_forget_tolerates_a_missing_organization_lane(tmp_path, monkeypatch):
"""F9: without the HUX-03 module there is no document to blank and forget still succeeds."""
import hux
monkeypatch.setitem(sys.modules, "hux.organization", None)
monkeypatch.delattr(hux, "organization", raising=False)
assert privacy._blank_document(tenant(tmp_path), CONV) is False
def test_conversation_privacy_state_route(tmp_path):
router = router_for(tmp_path)
"""Hook request: forget/disable state is readable so the agent stops proposing memory early (SO-22, SO-28)."""
status, conv, _ = call(router, "POST", "/hux/v1/conversations", body={"title": "state", "mode": "thoughtful"})
assert status == 201
status, state, _ = call(router, "GET", f"/hux/v1/conversations/{conv['id']}/privacy")
assert status == 200 and state["memory_writes_allowed"] is True and state["mode"] == "thoughtful"
assert state["forgotten"] is False and state["memory_disabled"] is False and state["topics"] == []
from hux import privacy, store, identity as ident_mod
tenant = store.TenantStore(router.data_root, ident_mod.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))
privacy.mark_topic(tenant, conv["id"], "health")
privacy.set_flag(tenant, conv["id"], "memory_disabled", True)
state = call(router, "GET", f"/hux/v1/conversations/{conv['id']}/privacy")[1]
assert state["topics"] == ["health"] and state["memory_writes_allowed"] is False
status, _, _ = call(router, "POST", f"/hux/v1/conversations/{conv['id']}/forget", body={})
assert status == 200
state = call(router, "GET", f"/hux/v1/conversations/{conv['id']}/privacy")[1]
assert state["forgotten"] is True
status, private, _ = call(router, "POST", "/hux/v1/conversations", body={"title": "p", "mode": "private"})
assert call(router, "GET", f"/hux/v1/conversations/{private['id']}/privacy")[1]["memory_writes_allowed"] is False
status, unknown, _ = call(router, "GET", "/hux/v1/conversations/conv_unknown00/privacy")
assert (status, unknown["code"]) == (404, "not_found")
assert call(router, "GET", "/hux/v1/conversations/bad%20id/privacy")[0] in (400, 404)
other = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
status, cross_tenant, _ = call(router, "GET", f"/hux/v1/conversations/{conv['id']}/privacy", other)
assert (status, cross_tenant["code"]) == (404, "not_found")