jenkins 18b980d6fa hermes(hux): expose conversation privacy state for the agent hook
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
2026-08-24 00:54:34 -03:00

296 lines
15 KiB
Python

"""HUX-10 privacy behaviour: topic detection, notices, conversation forget and the retention job.
The policy itself lives in ``rules.privacy_policy``; this module records what
was shown, scopes detected topics to the conversation they appeared in, gates
memory writes for other families (``memory_write_allowed``) and runs the
daily retention audit that expires memory, decays topic context and purges
forgotten content while never touching the audit ledgers (SO-47).
"""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from typing import Any
from hux import contracts, rules
from hux.errors import Invalid, NotFound
from hux.http import Request, Response, Router, page
from hux.identity import Identity
from hux.store import TenantStore, check_id, new_id, now_iso
FAMILY = "privacy"
RETENTION_FAMILY = "retention"
TOPICS_DOC = "topics_conversations"
TOPICS_SCHEMA = "hux.conversation_topics.v1"
CONTROLS = ("forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss")
SENSITIVITY_RANK = {"public": 0, "personal": 1, "sensitive": 2, "restricted": 3}
AUDIT_STALE_HOURS = 48
SCHEMAS = contracts.load_all()
# Deliberately narrow: a false positive only asks the user before remembering,
# a false negative would store something it should not. Detection is a hint,
# never proof, so callers treat "uncertain" as sensitive (SO-21).
TOPIC_PATTERNS: dict[str, re.Pattern[str]] = {
"credentials": re.compile(r"(?i)\b(password|passphrase|api[ _-]?key|secret key|private key|token|2fa|otp|ssh key|login credentials?)\b"),
"biometric": re.compile(r"(?i)\b(fingerprint|face ?id|retina|iris scan|voice ?print|dna|genome|biometric)\b"),
"minors": re.compile(r"(?i)\b(my (son|daughter|kid|kids|child|children|toddler|baby)|(?:\d{1,2}|[a-z]+)[- ]year[- ]old (?:son|daughter|boy|girl|child)|school ?(?:pickup|run)|daycare|minor child)\b"),
"location": re.compile(r"(?i)\b(home address|my address|lives? at \d|gps|coordinates|\d{1,5} [A-Z][a-z]+ (?:street|st|avenue|ave|road|rd|lane|ln|drive|dr)\b|latitude|longitude|track(?:ing)? (?:my|his|her) (?:phone|location))")
,
"health": re.compile(r"(?i)\b(diagnos(?:is|ed)|prescription|medication|therapist|therapy|symptoms?|doctor|clinic|hospital|surgery|chronic|depression|anxiety|cancer|diabetes|pregnan(?:t|cy)|mental health|blood (?:pressure|test))\b"),
"finance": re.compile(r"(?i)\b(salary|income|mortgage|bank account|credit card|iban|routing number|debt|loan|tax return|net worth|investment|401k|savings account|bankrupt(?:cy)?)\b"),
"legal": re.compile(r"(?i)\b(lawsuit|attorney|lawyer|court (?:date|case|order)|subpoena|arrest(?:ed)?|custody|divorce filing|criminal (?:record|charge)|restraining order|litigation)\b"),
"relationships": re.compile(r"(?i)\b(my (?:wife|husband|partner|girlfriend|boyfriend|ex)|breakup|broke up|divorce|affair|dating|marriage counsel(?:l)?ing)\b"),
}
def detect_topics(text: str) -> list[str]:
"""Sensitive topics a piece of text looks like it touches, most restrictive first. Heuristic and uncertain by design."""
found = [topic for topic, pattern in TOPIC_PATTERNS.items() if pattern.search(text or "")]
return sorted(found, key=lambda topic: (-SENSITIVITY_RANK[rules.PRIVACY_TOPICS[topic]["sensitivity"]], topic))
def topic_sensitivity(topics: list[str]) -> str:
"""Highest sensitivity among detected topics; ``personal`` when nothing matched."""
ranked = sorted((rules.PRIVACY_TOPICS[t]["sensitivity"] for t in topics), key=SENSITIVITY_RANK.get, reverse=True)
return ranked[0] if ranked else "personal"
def _parse(stamp: str) -> datetime:
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
def _iso(when: datetime) -> str:
return when.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def load_topics(store: TenantStore) -> dict[str, Any]:
"""The ``conversation_topics`` document: conversation id -> scoping state."""
if store.exists(FAMILY, TOPICS_DOC):
return store.get(FAMILY, TOPICS_DOC)
return {"id": TOPICS_DOC, "schema": TOPICS_SCHEMA, "items": {}}
def _save_topics(store: TenantStore, doc: dict[str, Any]) -> None:
store.put(FAMILY, doc)
def conversation_state(store: TenantStore, conversation_id: str) -> dict[str, Any]:
"""Scoping row for one conversation (empty when nothing was ever detected)."""
return load_topics(store)["items"].get(conversation_id, {})
def mark_topic(store: TenantStore, conversation_id: str, topic: str, now: str | None = None) -> dict[str, Any]:
"""Scope ``topic`` to ``conversation_id``; the first sighting fixes ``decay_at``."""
now = now or now_iso()
with store.lock(FAMILY):
doc = load_topics(store)
row = doc["items"].setdefault(conversation_id, {"topics": [], "first_seen": now, "memory_disabled": False, "forgotten": False})
if topic not in row["topics"]:
row["topics"].append(topic)
decay = _parse(row["first_seen"]) + timedelta(days=min(rules.PRIVACY_TOPICS[t]["decay_days"] for t in row["topics"]))
row["decay_at"] = _iso(decay)
_save_topics(store, doc)
return row
def set_flag(store: TenantStore, conversation_id: str, flag: str, value: bool) -> None:
"""Set ``memory_disabled`` or ``forgotten`` for a conversation."""
with store.lock(FAMILY):
doc = load_topics(store)
row = doc["items"].setdefault(conversation_id, {"topics": [], "first_seen": now_iso(), "memory_disabled": False, "forgotten": False})
row[flag] = value
_save_topics(store, doc)
def memory_write_allowed(store: TenantStore, conversation_id: str | None, entry: dict[str, Any]) -> tuple[bool, str]:
"""Gate every memory write (SO-21, SO-23, SO-28). Returns (allowed, reason)."""
from hux import events
if conversation_id:
if events.is_private(store, conversation_id):
return False, "private_mode"
state = conversation_state(store, conversation_id)
if state.get("forgotten"):
return False, "conversation_forgotten"
if state.get("memory_disabled"):
return False, "memory_disabled"
if entry.get("sensitivity") == "restricted":
return False, "restricted"
topic = entry.get("topic", "general")
if topic in rules.PRIVACY_TOPICS and rules.PRIVACY_TOPICS[topic]["memory_write"] == "deny":
return False, f"topic_{topic}"
return True, "allowed"
def blocked_conversations(store: TenantStore) -> set[str]:
"""Conversations that contribute nothing to retrieval: forgotten or memory disabled (SO-23)."""
return {cid for cid, row in load_topics(store)["items"].items() if row.get("forgotten") or row.get("memory_disabled")}
def latest_audit(store: TenantStore) -> dict[str, Any] | None:
"""Newest retention audit record, or None when the job never ran."""
rows = sorted(store.scan(RETENTION_FAMILY), key=lambda row: row["ran_at"])
return rows[-1] if rows else None
def audit_stale(store: TenantStore, now: datetime | None = None) -> bool:
"""SO-27: no retention audit in the last 48 hours."""
last = latest_audit(store)
now = now or datetime.now(timezone.utc)
return last is None or now - _parse(last["ran_at"]) > timedelta(hours=AUDIT_STALE_HOURS)
# -- retention ----------------------------------------------------------------
def run_retention(store: TenantStore, now: datetime | None = None, identity: Identity | None = None) -> dict[str, Any]:
"""Expire memory, decay topic context, purge forgotten content, report; write and return ``hux.retention_audit.v1``."""
from hux import events, memory
now = now or datetime.now(timezone.utc)
expired = memory.expire_due(store, now)
decayed = 0
with store.lock(FAMILY):
doc = load_topics(store)
for conversation_id, row in doc["items"].items():
if row.get("decayed") or not row.get("topics") or _parse(row.get("decay_at", "9999-01-01T00:00:00Z")) > now:
continue
notice = rules.privacy_policy()["topics"]
text = next(t["notice"] for t in notice if t["topic"] == row["topics"][0])
decayed += events.rewrite_full(store, conversation_id, text, "topic context decayed", lambda r: r.get("sensitivity") in {"sensitive", "restricted"})
row["decayed"] = True
_save_topics(store, doc)
purged = memory.purge_forgotten(store)
record = {
"schema": "hux.retention_audit.v1",
"id": new_id("aud"),
"ran_at": _iso(now),
"policy_version": rules.privacy_policy()["version"],
"results": [
{"action": "expire_memory", "count": expired},
{"action": "decay_topic_context", "count": decayed},
{"action": "purge_forgotten_content", "count": purged},
{"action": "report", "count": 1},
],
}
problems = contracts.validate_record(record, SCHEMAS)
if problems:
raise Invalid("retention audit failed contract validation", problems)
return _strip(store.put(RETENTION_FAMILY, record))
def forget_conversation(store: TenantStore, identity: Identity, conversation_id: str) -> dict[str, Any]:
"""Mark a conversation forgotten: its memory entries move to forgotten, its events go to full redaction."""
from hux import events, memory
set_flag(store, conversation_id, "forgotten", True)
forgotten_ids = memory.forget_from_conversation(store, identity, conversation_id)
redacted = events.rewrite_full(store, conversation_id, "[forgotten conversation]", "conversation forgotten")
counts = {"memory_forgotten": len(forgotten_ids), "events_redacted": redacted, "document_blanked": _blank_document(store, conversation_id)}
store.append(FAMILY, "forgotten", {"conv_id": conversation_id, "requested_at": now_iso(), "purged_at": "", "counts": counts})
return {"conversation_id": conversation_id, "forgotten": True, **counts}
def _blank_document(store: TenantStore, conversation_id: str) -> bool:
"""Blank the HUX-03 conversation document (title, tags, archived) when the organisation lane holds one (F9)."""
try:
from hux import organization
except ModuleNotFoundError:
return False
return organization.mark_forgotten(store, conversation_id)
# -- routes ---------------------------------------------------------------------
def _strip(record: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in record.items() if key != "revision"}
def get_policy(request: Request) -> Response:
"""``GET /hux/v1/privacy/policy``: the frozen policy; ``HUX-Audit-Stale`` says whether retention ran lately (SO-27)."""
request.audit("privacy.policy", "policy")
stale = audit_stale(request.store)
return Response(200, rules.privacy_policy(), {"HUX-Audit-Stale": "true" if stale else "false"})
def post_notice(request: Request) -> Response:
"""``POST /hux/v1/privacy/notices``: record a just-in-time notice as shown and scope the topic to the conversation."""
from hux import events
body = request.body if isinstance(request.body, dict) else {}
topic, conversation_id = body.get("topic"), body.get("conversation_id")
if topic not in rules.PRIVACY_TOPICS or not isinstance(conversation_id, str):
raise Invalid("topic and conversation_id are required")
controls = [c for c in body.get("controls", list(CONTROLS)) if c in CONTROLS] or list(CONTROLS)
text = next(t["notice"] for t in rules.privacy_policy()["topics"] if t["topic"] == topic)
notice = {"schema": "hux.privacy_notice.v1", "topic": topic, "conversation_id": conversation_id, "text": text, "controls": sorted(set(controls), key=CONTROLS.index), "shown_at": now_iso()}
problems = contracts.validate_record(notice, SCHEMAS)
if problems:
raise Invalid("notice failed contract validation", problems)
request.store.append(FAMILY, "notices", notice)
mark_topic(request.store, conversation_id, topic)
chosen = body.get("chosen")
if chosen == "disable_memory_here":
set_flag(request.store, conversation_id, "memory_disabled", True)
elif chosen == "forget_this_conversation":
forget_conversation(request.store, request.identity, conversation_id)
# F13a: a notice for a conversation this subject never had must not conjure
# an event ledger for it; the notice itself is still on record.
known = events.conversation_known(request.store, conversation_id)
if known:
events.emit(request.store, request.identity, conversation_id, "privacy.notice", text, notice, sensitivity=rules.PRIVACY_TOPICS[topic]["sensitivity"])
request.audit("privacy.notice", conversation_id, "allow", "" if known else "unknown_conversation")
return Response(201, notice)
def post_forget(request: Request) -> Response:
"""``POST /hux/v1/conversations/{id}/forget``: user-initiated forget; unknown conversations are 404 (SO-18)."""
from hux import events
conversation_id = request.params["id"]
if not (events.conversation_known(request.store, conversation_id) or conversation_id in load_topics(request.store)["items"]):
raise NotFound("conversation not found")
result = forget_conversation(request.store, request.identity, conversation_id)
request.audit("privacy.forget", conversation_id)
return Response(200, result)
def get_audit(request: Request) -> Response:
"""``GET /hux/v1/privacy/audit``: retention audit records, newest first."""
rows = sorted(request.store.scan(RETENTION_FAMILY), key=lambda row: row["ran_at"], reverse=True)
request.audit("privacy.audit", "retention")
return page([_strip(row) for row in rows[:PAGE_LIMIT]], None)
PAGE_LIMIT = 200
def get_conversation_privacy(request: Request) -> Response:
"""``GET /hux/v1/conversations/{id}/privacy``: forget/disable state and topics, so the hook can stop proposing memory early."""
conversation_id = check_id(request.params["id"])
state = conversation_state(request.store, conversation_id)
mode = None
try:
from hux.organization import CONVERSATIONS
mode = request.store.get(CONVERSATIONS, conversation_id).get("mode")
except (ModuleNotFoundError, ImportError, NotFound):
mode = None
request.audit("privacy.conversation_state", conversation_id)
return Response(200, {
"conversation_id": conversation_id,
"forgotten": bool(state.get("forgotten")),
"memory_disabled": bool(state.get("memory_disabled")),
"topics": sorted(state.get("topics", {})) if isinstance(state.get("topics"), dict) else list(state.get("topics", [])),
"mode": mode,
"memory_writes_allowed": not (state.get("forgotten") or state.get("memory_disabled") or mode == "private"),
})
def register(router: Router) -> None:
"""Attach HUX-10 routes."""
router.add("GET", "/hux/v1/privacy/policy", "HUX-10", "privacy.policy", get_policy)
router.add("POST", "/hux/v1/privacy/notices", "HUX-10", "privacy.notice", post_notice)
router.add("POST", "/hux/v1/conversations/{id}/forget", "HUX-10", "privacy.forget", post_forget)
router.add("GET", "/hux/v1/privacy/audit", "HUX-10", "privacy.audit", get_audit)
router.add("GET", "/hux/v1/conversations/{id}/privacy", "HUX-10", "privacy.conversation_state", get_conversation_privacy)