GET /hux/v1/conversations/{id}/privacy reports forgotten, memory_disabled,
topics, mode and memory_writes_allowed; the worker hook's memory gate reads it
and fails closed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
235 lines
13 KiB
Python
235 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"}
|
|
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})
|
|
|
|
|
|
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"})
|
|
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, {}))
|
|
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
|
|
assert call(router, "GET", "/hux/v1/conversations/conv_unknown00/privacy")[1]["mode"] is None
|
|
assert call(router, "GET", "/hux/v1/conversations/bad%20id/privacy")[0] in (400, 404)
|
|
other = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
|
|
assert call(router, "GET", f"/hux/v1/conversations/{conv['id']}/privacy", other)[1]["forgotten"] is False
|