"""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")