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
This commit is contained in:
parent
e1ef110c8a
commit
18b980d6fa
@ -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"),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user