From 18b980d6fae835adfc959b98ab7eb7ffa2a3d3d3 Mon Sep 17 00:00:00 2001 From: jenkins Date: Mon, 24 Aug 2026 00:54:34 -0300 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM --- .../hermes-hux-foundation/hux/flags.py | 4 +-- .../hermes-hux-foundation/hux/privacy.py | 24 ++++++++++++++++- .../hermes-worker-hux/hux_hook/hooks.py | 16 +++++++----- docs/hermes_hux_contracts.md | 2 +- docs/hux/HANDOFF.md | 5 +++- .../tests/test_hermes_hux_contract_hook.py | 2 ++ .../tests/test_hermes_hux_privacy_topics.py | 26 +++++++++++++++++++ 7 files changed, 67 insertions(+), 12 deletions(-) diff --git a/dockerfiles/hermes-hux-foundation/hux/flags.py b/dockerfiles/hermes-hux-foundation/hux/flags.py index 53a40fd5..300142ff 100644 --- a/dockerfiles/hermes-hux-foundation/hux/flags.py +++ b/dockerfiles/hermes-hux-foundation/hux/flags.py @@ -29,7 +29,7 @@ CARD_ROUTES: dict[str, list[str]] = { "HUX-07": [], "HUX-08": ["/hux/v1/sources", "/hux/v1/sources/{id}", "/hux/v1/passages", "/hux/v1/messages/{id}/citations", "/hux/v1/notebooks", "/hux/v1/notebooks/{id}"], "HUX-09": [], - "HUX-10": ["/hux/v1/privacy/policy", "/hux/v1/privacy/notices", "/hux/v1/conversations/{id}/forget", "/hux/v1/privacy/audit"], + "HUX-10": ["/hux/v1/privacy/policy", "/hux/v1/privacy/notices", "/hux/v1/conversations/{id}/forget", "/hux/v1/privacy/audit", "/hux/v1/conversations/{id}/privacy"], "HUX-12": [], } # Cards whose routes are not shipped yet declare [] above until they land (F12). @@ -46,7 +46,7 @@ WORKER_ROUTES: frozenset[tuple[str, str]] = frozenset({ ("POST", "/hux/v1/runs/{id}/gate"), ("POST", "/hux/v1/runs/{id}/budget"), ("POST", "/hux/v1/runs/{id}/stop"), ("GET", "/hux/v1/runs/{id}/budget"), ("POST", "/hux/v1/conversations/{id}/events"), ("GET", "/hux/v1/conversations/{id}"), - ("GET", "/hux/v1/privacy/policy"), + ("GET", "/hux/v1/privacy/policy"), ("GET", "/hux/v1/conversations/{id}/privacy"), ("GET", "/hux/v1/memory"), ("POST", "/hux/v1/memory"), ("POST", "/hux/v1/sources"), ("POST", "/hux/v1/passages"), ("POST", "/hux/v1/messages/{id}/citations"), ("POST", "/hux/v1/artifacts"), ("POST", "/hux/v1/artifacts/{id}/versions"), diff --git a/dockerfiles/hermes-hux-foundation/hux/privacy.py b/dockerfiles/hermes-hux-foundation/hux/privacy.py index 720a0471..de87b666 100644 --- a/dockerfiles/hermes-hux-foundation/hux/privacy.py +++ b/dockerfiles/hermes-hux-foundation/hux/privacy.py @@ -17,7 +17,7 @@ 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, new_id, now_iso +from hux.store import TenantStore, check_id, new_id, now_iso FAMILY = "privacy" RETENTION_FAMILY = "retention" @@ -265,9 +265,31 @@ def get_audit(request: Request) -> Response: 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) diff --git a/dockerfiles/hermes-worker-hux/hux_hook/hooks.py b/dockerfiles/hermes-worker-hux/hux_hook/hooks.py index 659fb33b..4b08ea4e 100644 --- a/dockerfiles/hermes-worker-hux/hux_hook/hooks.py +++ b/dockerfiles/hermes-worker-hux/hux_hook/hooks.py @@ -181,11 +181,13 @@ def memory_gate(client: HuxClient, conversation_id: str) -> bool: The service still enforces forget and disable state on the write itself. """ try: - client.get("/hux/v1/privacy/policy") - except HuxServiceError: - return False - try: - record = client.get(f"/hux/v1/conversations/{conversation_id}").body + state = client.get(f"/hux/v1/conversations/{conversation_id}/privacy").body except HuxServiceError as error: - return error.status == 404 - return not (isinstance(record, dict) and record.get("mode") == "private") + if error.status != 404: + return False + try: + client.get("/hux/v1/privacy/policy") + except HuxServiceError: + return False + return True + return bool(isinstance(state, dict) and state.get("memory_writes_allowed")) diff --git a/docs/hermes_hux_contracts.md b/docs/hermes_hux_contracts.md index 102d8882..e874950b 100644 --- a/docs/hermes_hux_contracts.md +++ b/docs/hermes_hux_contracts.md @@ -63,7 +63,7 @@ tenant. Responses are the records below, wrapped as `{"items": [...], | Modes (HUX-06) | `GET /hux/v1/modes`, `PUT /hux/v1/conversations/{id}/mode` | `hux.mode.v1` | | Research (HUX-08) | `POST /hux/v1/sources`, `GET /hux/v1/sources/{id}`, `POST /hux/v1/passages`, `GET/POST /hux/v1/messages/{id}/citations`, `POST /hux/v1/notebooks`, `GET/PATCH /hux/v1/notebooks/{id}` | `hux.source.v1`, `hux.passage.v1`, `hux.citation.v1`, `hux.research_notebook.v1` | | Onboarding (HUX-09) | `GET /hux/v1/suggestions?context=`, `POST /hux/v1/suggestions/{id}/{dismiss,never,acted}` | `hux.suggestion.v1`, `hux.suggestion_state.v1` | -| Privacy (HUX-10) | `GET /hux/v1/privacy/policy` (+ `HUX-Audit-Stale` header), `POST /hux/v1/privacy/notices` (optional `chosen` control), `POST /hux/v1/conversations/{id}/forget`, `GET /hux/v1/privacy/audit` | `hux.privacy_policy.v1`, `hux.privacy_notice.v1`, `hux.retention_audit.v1` | +| Privacy (HUX-10) | `GET /hux/v1/privacy/policy` (+ `HUX-Audit-Stale` header), `POST /hux/v1/privacy/notices` (optional `chosen` control), `POST /hux/v1/conversations/{id}/forget`, `GET /hux/v1/privacy/audit`, `GET /hux/v1/conversations/{id}/privacy` (forgotten / memory_disabled / topics / mode / memory_writes_allowed — the hook reads this before proposing memory) | `hux.privacy_policy.v1`, `hux.privacy_notice.v1`, `hux.retention_audit.v1` | | Release (HUX-12) | `GET /hux/v1/releases` (operator surface, worker only) | `hux.release.v1` | Multimodal (HUX-07) reuses artifacts for images/audio (`type` image/audio, diff --git a/docs/hux/HANDOFF.md b/docs/hux/HANDOFF.md index 130bcfb0..3609dcd0 100644 --- a/docs/hux/HANDOFF.md +++ b/docs/hux/HANDOFF.md @@ -94,10 +94,13 @@ Suite after repairs: 377 tests, 99% line / 99% branch over `hermes-hux-foundatio | Tests | `test_hermes_hux_policy_hook.py` (11), `test_hermes_hux_contract_hook.py` (10) — real service in-process, end-to-end approval → human decision → gate released once, canary never persisted, unreachable service fails closed for side effects | | Codex needs | Agent container env `HUX_BASE_URL`, `HUX_TENANT_SLOT`, `HUX_SUBJECT`, `HUX_WORKER_KEY`; call order per `NOTES.md`: `before_tool` → execute only on `proceed` → `after_tool`; `record_spend` per turn; `on_stop` returning `None` means the stop is not done | +## Conversation privacy state — DONE + +`GET /hux/v1/conversations/{id}/privacy` (HUX-10, worker-callable) returns forgotten / memory_disabled / topics / mode / `memory_writes_allowed`; `hux_hook.memory_gate` now reads it and fails closed. Tests: `test_hermes_hux_privacy_topics.py::test_conversation_privacy_state_route`, hook memory-gate test extended. + ## Open items (not blockers for Codex integration) - SO-46 hash-chained audit, SO-48 single-writer lock, SO-53 rate limits. - Retention scheduler: `privacy.run_retention` is on-demand; Codex decides cron vs sidecar. -- `GET /hux/v1/conversations/{id}/privacy` (forgotten / memory_disabled state) would let the hook stop proposing memory earlier. - Search over `message_text`; artifact sharing (`shared_readonly`). - `testing/quality_contract.json` line-limit globs / managed modules for the two new package paths (Codex's file). diff --git a/testing/tests/test_hermes_hux_contract_hook.py b/testing/tests/test_hermes_hux_contract_hook.py index e5cf8a3f..59a667e5 100644 --- a/testing/tests/test_hermes_hux_contract_hook.py +++ b/testing/tests/test_hermes_hux_contract_hook.py @@ -206,6 +206,8 @@ def test_memory_gate(live, tmp_path): assert memory_gate(worker, private) is False assert memory_gate(worker, "conv_notknown01") is True assert memory_gate(worker, "bad id") is False + human.post(f"/hux/v1/conversations/{normal}/forget", {}) + assert memory_gate(worker, normal) is False assert memory_gate(HuxClient("http://127.0.0.1:1", WORKER, key="wk", timeout=1), normal) is False off_base, off_server = start(tmp_path / "off", "hux.foundation,hux.projects") try: diff --git a/testing/tests/test_hermes_hux_privacy_topics.py b/testing/tests/test_hermes_hux_privacy_topics.py index 45eda891..2aec7ec2 100644 --- a/testing/tests/test_hermes_hux_privacy_topics.py +++ b/testing/tests/test_hermes_hux_privacy_topics.py @@ -206,3 +206,29 @@ def test_f9_forget_tolerates_a_missing_organization_lane(tmp_path, monkeypatch): 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