Compare commits
5 Commits
700d6fc326
...
124206b748
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
124206b748 | ||
|
|
1cb6f07c78 | ||
|
|
b3de70bacb | ||
|
|
ada74ce0d4 | ||
|
|
fc8ec28db3 |
407
dockerfiles/hermes-hux-foundation/hux/artifacts.py
Normal file
407
dockerfiles/hermes-hux-foundation/hux/artifacts.py
Normal file
@ -0,0 +1,407 @@
|
||||
"""HUX-04 artifact workspace: typed, versioned, diffable outputs.
|
||||
|
||||
Every artifact is one ``hux.artifact.v1`` document under the caller's tenant
|
||||
subtree. Content lives in the content-addressed blob store; the document only
|
||||
carries ``content_ref`` hashes, so a version can never be rewritten once it
|
||||
has been appended (SO-30). Blobs are served inside a JSON envelope with
|
||||
``nosniff`` and never as an executable response type (SO-32). Every
|
||||
referenced id is resolved under the caller's own subtree (SO-33); a foreign
|
||||
or unknown id is ``404`` so ids cannot be probed. Sharing is out of scope for
|
||||
this increment: ``access.mode`` is always ``owner`` (SO-34).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from hux import diffs
|
||||
from hux.contracts import load_all, validate_record
|
||||
from hux.errors import Conflict, Invalid, NotFound, TooLarge
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, check_id, new_id, now_iso
|
||||
|
||||
|
||||
MAX_UPLOAD_BODY = 34 * 1024 * 1024 # 25 MiB of content plus base64 overhead (SO-31, SO-54)
|
||||
|
||||
|
||||
def _project_exists(store, project_id: str) -> bool:
|
||||
"""Ask the organization family when it is present; otherwise only the id shape is known."""
|
||||
try:
|
||||
from hux.organization import project_exists
|
||||
except ModuleNotFoundError: # pragma: no cover - organization is always shipped with artifacts
|
||||
return True
|
||||
return project_exists(store, project_id)
|
||||
|
||||
FAMILY = "artifacts"
|
||||
MAX_VERSION_BYTES = 25 * 1024 * 1024
|
||||
MAX_VERSIONS = 200
|
||||
MAX_ARTIFACTS = 2000
|
||||
PAGE_SIZE = 50
|
||||
ARTIFACT_TYPES = ("markdown", "code", "html", "svg", "image", "json", "csv", "document", "audio")
|
||||
SENSITIVITIES = ("public", "personal", "sensitive", "restricted")
|
||||
_SCHEMAS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _schemas() -> dict[str, dict[str, Any]]:
|
||||
if not _SCHEMAS:
|
||||
_SCHEMAS.update(load_all())
|
||||
return _SCHEMAS
|
||||
|
||||
|
||||
# -- helpers shared with other lanes -------------------------------------------
|
||||
|
||||
def artifact_exists(store: TenantStore, artifact_id: str) -> bool:
|
||||
"""True when ``artifact_id`` is a well-formed id owned by the store's tenant."""
|
||||
try:
|
||||
return store.exists(FAMILY, artifact_id)
|
||||
except Invalid:
|
||||
return False
|
||||
|
||||
|
||||
def artifact_titles(store: TenantStore, ids: list[str]) -> dict[str, str]:
|
||||
"""Map each resolvable artifact id to its title; unknown ids are omitted."""
|
||||
titles: dict[str, str] = {}
|
||||
for artifact_id in ids:
|
||||
if artifact_exists(store, artifact_id):
|
||||
titles[artifact_id] = store.get(FAMILY, artifact_id)["title"]
|
||||
return titles
|
||||
|
||||
|
||||
def emit_event(store: TenantStore, identity: Identity, artifact: dict[str, Any], kind: str, summary: str, detail: dict[str, Any]) -> None:
|
||||
"""Record an activity event through the events lane when it is present.
|
||||
|
||||
Imported lazily so module import order never matters; a missing events
|
||||
module (earlier build) is not an error because the artifact write itself
|
||||
is the source of truth.
|
||||
"""
|
||||
conversation_id = artifact.get("conversation_id")
|
||||
if conversation_id is None:
|
||||
return
|
||||
try:
|
||||
from hux.events import emit
|
||||
except ModuleNotFoundError:
|
||||
return
|
||||
version = artifact["current_version"]
|
||||
evidence = [{"kind": "artifact_version", "id": f"{artifact['id']}@{version}", "hash": artifact["versions"][-1]["content_ref"]["hash"]}]
|
||||
emit(store, identity, conversation_id, kind, summary, detail=detail, evidence=evidence, sensitivity=artifact["sensitivity"])
|
||||
|
||||
|
||||
def replay(store: TenantStore, family: str, key: str) -> str | None:
|
||||
"""Record id previously created under an Idempotency-Key, if any."""
|
||||
if not key:
|
||||
return None
|
||||
for row in store.read(family, "idempotency"):
|
||||
if row.get("key") == key:
|
||||
return row["id"]
|
||||
return None
|
||||
|
||||
|
||||
def remember(store: TenantStore, family: str, key: str, record_id: str) -> None:
|
||||
"""Persist an Idempotency-Key to record id mapping."""
|
||||
if key:
|
||||
store.append(family, "idempotency", {"key": key, "id": record_id, "at": now_iso()})
|
||||
|
||||
|
||||
# -- record shaping ------------------------------------------------------------
|
||||
|
||||
def _actor(identity: Identity) -> dict[str, str]:
|
||||
if identity.trust == "worker":
|
||||
return {"type": "system", "id": "worker"}
|
||||
return {"type": "user", "id": identity.subject}
|
||||
|
||||
|
||||
def _body(request: Request) -> dict[str, Any]:
|
||||
if not isinstance(request.body, dict):
|
||||
raise Invalid("body must be a JSON object")
|
||||
return request.body
|
||||
|
||||
|
||||
def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -> str | None:
|
||||
value = body.get(key)
|
||||
if value is None:
|
||||
if required:
|
||||
raise Invalid(f"{key} is required")
|
||||
return None
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
||||
raise Invalid(f"{key} must be a non-empty string of at most {limit} characters")
|
||||
return value
|
||||
|
||||
|
||||
def _content(body: dict[str, Any]) -> tuple[bytes, str]:
|
||||
"""Decode the submitted content and return ``(bytes, mime)``."""
|
||||
text, encoded = body.get("content"), body.get("content_base64")
|
||||
if isinstance(text, str) and encoded is None:
|
||||
data = text.encode("utf-8")
|
||||
elif isinstance(encoded, str) and text is None:
|
||||
try:
|
||||
data = base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, TypeError) as error:
|
||||
raise Invalid("content_base64 is not valid base64") from error
|
||||
else:
|
||||
raise Invalid("exactly one of content (utf-8 text) or content_base64 is required")
|
||||
if len(data) > MAX_VERSION_BYTES:
|
||||
raise TooLarge("version exceeds 25 MiB")
|
||||
mime = _string(body, "mime", 120, required=False) or "application/octet-stream"
|
||||
return data, mime
|
||||
|
||||
|
||||
def _store_content(store: TenantStore, body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Hash, verify against any client-supplied hash, store the blob, return ``content_ref``."""
|
||||
data, mime = _content(body)
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
claimed = body.get("hash")
|
||||
if claimed is not None and claimed != f"sha256:{digest}":
|
||||
raise Invalid("hash does not match the submitted content")
|
||||
store.put_blob(digest, data)
|
||||
return {"hash": f"sha256:{digest}", "bytes": len(data), "mime": mime}
|
||||
|
||||
|
||||
def _optional_id(body: dict[str, Any], key: str) -> str | None:
|
||||
value = body.get(key)
|
||||
return None if value is None else check_id(value)
|
||||
|
||||
|
||||
def _load_owned(request: Request) -> dict[str, Any]:
|
||||
"""The artifact named in the path; unknown, malformed or foreign is 404."""
|
||||
try:
|
||||
artifact = request.store.get(FAMILY, request.params["id"])
|
||||
except Invalid as error:
|
||||
raise NotFound("artifact not found") from error
|
||||
if artifact.get("owner") != request.identity.subject:
|
||||
raise NotFound("artifact not found")
|
||||
return artifact
|
||||
|
||||
|
||||
def _version(artifact: dict[str, Any], number: Any) -> dict[str, Any]:
|
||||
if not isinstance(number, int) or isinstance(number, bool):
|
||||
raise Invalid("version must be an integer")
|
||||
for entry in artifact["versions"]:
|
||||
if entry["version"] == number:
|
||||
return entry
|
||||
raise NotFound(f"version {number} not found")
|
||||
|
||||
|
||||
def _lineage(request: Request, body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Resolve ``lineage`` under the caller's subtree; anything else is 404 (SO-33)."""
|
||||
lineage = body.get("lineage")
|
||||
if lineage is None:
|
||||
return None
|
||||
if not isinstance(lineage, dict):
|
||||
raise Invalid("lineage must be an object")
|
||||
try:
|
||||
parent = request.store.get(FAMILY, check_id(lineage.get("artifact_id")))
|
||||
except (Invalid, NotFound) as error:
|
||||
raise NotFound("lineage artifact not found") from error
|
||||
if parent.get("owner") != request.identity.subject:
|
||||
raise NotFound("lineage artifact not found")
|
||||
version = _version(parent, lineage.get("version"))
|
||||
return {"artifact_id": parent["id"], "version": version["version"]}
|
||||
|
||||
|
||||
def append_version(store: TenantStore, artifact: dict[str, Any], entry: dict[str, Any], expected_revision: int | None) -> dict[str, Any]:
|
||||
"""Append an immutable version entry and persist the document.
|
||||
|
||||
The version number must be exactly ``current_version + 1``; any attempt to
|
||||
write an existing number is a Conflict so history is never rewritten.
|
||||
"""
|
||||
if any(v["version"] == entry["version"] for v in artifact["versions"]):
|
||||
raise Conflict(f"version {entry['version']} already exists")
|
||||
if entry["version"] != artifact["current_version"] + 1:
|
||||
raise Conflict("versions are appended in order")
|
||||
if len(artifact["versions"]) >= MAX_VERSIONS:
|
||||
raise TooLarge("artifact has reached 200 versions")
|
||||
updated = {**artifact, "versions": [*artifact["versions"], entry], "current_version": entry["version"], "updated_at": now_iso()}
|
||||
_check(updated)
|
||||
return store.put(FAMILY, updated, expected_revision=expected_revision)
|
||||
|
||||
|
||||
def _check(artifact: dict[str, Any]) -> None:
|
||||
problems = validate_record(artifact, _schemas())
|
||||
if problems:
|
||||
raise Invalid("artifact does not satisfy hux.artifact.v1", problems)
|
||||
|
||||
|
||||
# -- handlers ------------------------------------------------------------------
|
||||
|
||||
def create(request: Request) -> Response:
|
||||
"""``POST /hux/v1/artifacts``: create an artifact with version 1."""
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
with request.store.lock(FAMILY):
|
||||
existing = replay(request.store, FAMILY, key)
|
||||
if existing is not None:
|
||||
request.audit("artifacts.create", existing, reason="idempotent_replay")
|
||||
return Response(200, request.store.get(FAMILY, existing), {"HUX-Replayed": "true"})
|
||||
if request.store.count(FAMILY) >= MAX_ARTIFACTS:
|
||||
raise TooLarge("tenant has reached 2000 artifacts")
|
||||
artifact_type = _string(body, "type", 40)
|
||||
if artifact_type not in ARTIFACT_TYPES:
|
||||
raise Invalid("unknown artifact type")
|
||||
sensitivity = body.get("sensitivity", "personal")
|
||||
if sensitivity not in SENSITIVITIES:
|
||||
raise Invalid("unknown sensitivity")
|
||||
stamp = now_iso()
|
||||
version: dict[str, Any] = {"version": 1, "created_at": stamp, "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body)}
|
||||
for field, limit in (("message_id", 120), ("note", 200)):
|
||||
if _string(body, field, limit, required=False) is not None:
|
||||
version[field] = body[field]
|
||||
lineage = _lineage(request, body)
|
||||
if lineage is not None:
|
||||
version["lineage"] = lineage
|
||||
artifact: dict[str, Any] = {
|
||||
"schema": "hux.artifact.v1",
|
||||
"id": new_id("art"),
|
||||
"owner": request.identity.subject,
|
||||
"type": artifact_type,
|
||||
"title": _string(body, "title", 200),
|
||||
"current_version": 1,
|
||||
"versions": [version],
|
||||
"sensitivity": sensitivity,
|
||||
"created_at": stamp,
|
||||
"updated_at": stamp,
|
||||
"revision": 1,
|
||||
"access": {"mode": "owner"},
|
||||
}
|
||||
for field in ("conversation_id", "project_id"):
|
||||
if _optional_id(body, field) is not None:
|
||||
artifact[field] = body[field]
|
||||
if _string(body, "language", 40, required=False) is not None:
|
||||
artifact["language"] = body["language"]
|
||||
_check(artifact)
|
||||
stored = request.store.put(FAMILY, artifact)
|
||||
remember(request.store, FAMILY, key, stored["id"])
|
||||
request.audit("artifacts.create", stored["id"])
|
||||
emit_event(request.store, request.identity, stored, "artifact.created", f"Created {stored['type']} artifact", {"artifact_id": stored["id"], "version": 1})
|
||||
return Response(201, stored, {"ETag": str(stored["revision"])})
|
||||
|
||||
|
||||
def list_artifacts(request: Request) -> Response:
|
||||
"""``GET /hux/v1/artifacts?conversation_id=&project_id=&cursor=``: owned artifacts, oldest first."""
|
||||
filters = {k: request.query[k] for k in ("conversation_id", "project_id") if request.query.get(k)}
|
||||
cursor = request.query.get("cursor", "0")
|
||||
if not cursor.isdigit():
|
||||
raise Invalid("cursor must be a non-negative integer")
|
||||
items = [
|
||||
artifact
|
||||
for artifact in request.store.scan(FAMILY)
|
||||
if artifact.get("owner") == request.identity.subject and all(artifact.get(k) == v for k, v in filters.items())
|
||||
]
|
||||
start = int(cursor)
|
||||
window = items[start : start + PAGE_SIZE]
|
||||
request.audit("artifacts.list", "artifacts")
|
||||
return page(window, str(start + PAGE_SIZE) if len(items) > start + PAGE_SIZE else None)
|
||||
|
||||
|
||||
def get(request: Request) -> Response:
|
||||
"""``GET /hux/v1/artifacts/{id}``: one artifact document."""
|
||||
artifact = _load_owned(request)
|
||||
request.audit("artifacts.get", artifact["id"])
|
||||
return Response(200, artifact, {"ETag": str(artifact["revision"])})
|
||||
|
||||
|
||||
def add_version(request: Request) -> Response:
|
||||
"""``POST /hux/v1/artifacts/{id}/versions``: append a new immutable version (If-Match required)."""
|
||||
body = _body(request)
|
||||
expected = request.if_match()
|
||||
if expected is None:
|
||||
raise Invalid("If-Match is required to append a version")
|
||||
key = request.idempotency_key()
|
||||
with request.store.lock(FAMILY):
|
||||
artifact = _load_owned(request)
|
||||
existing = replay(request.store, FAMILY, key)
|
||||
if existing is not None and existing.split("@")[0] == artifact["id"]:
|
||||
request.audit("artifacts.version", existing, reason="idempotent_replay")
|
||||
return Response(200, artifact, {"HUX-Replayed": "true", "ETag": str(artifact["revision"])})
|
||||
if expected != artifact["revision"]:
|
||||
raise Conflict(f"revision {expected} does not match current revision {artifact['revision']}", [str(artifact["revision"])])
|
||||
number = artifact["current_version"] + 1
|
||||
entry: dict[str, Any] = {"version": number, "created_at": now_iso(), "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body), "diff_from": artifact["current_version"]}
|
||||
if body.get("diff_from") is not None:
|
||||
entry["diff_from"] = _version(artifact, body["diff_from"])["version"]
|
||||
for field, limit in (("message_id", 120), ("note", 200)):
|
||||
if _string(body, field, limit, required=False) is not None:
|
||||
entry[field] = body[field]
|
||||
lineage = _lineage(request, body)
|
||||
if lineage is not None:
|
||||
entry["lineage"] = lineage
|
||||
stored = append_version(request.store, artifact, entry, expected)
|
||||
remember(request.store, FAMILY, key, f"{stored['id']}@{number}")
|
||||
request.audit("artifacts.version", f"{stored['id']}@{number}")
|
||||
emit_event(request.store, request.identity, stored, "artifact.version", f"New version {number}", {"artifact_id": stored["id"], "version": number})
|
||||
return Response(201, stored, {"ETag": str(stored["revision"])})
|
||||
|
||||
|
||||
def _version_number(request: Request) -> int:
|
||||
raw = request.params["n"]
|
||||
if not raw.isdigit() or int(raw) < 1:
|
||||
raise Invalid("version must be a positive integer")
|
||||
return int(raw)
|
||||
|
||||
|
||||
def get_version(request: Request) -> Response:
|
||||
"""``GET /hux/v1/artifacts/{id}/versions/{n}``: version metadata plus content."""
|
||||
artifact = _load_owned(request)
|
||||
entry = _version(artifact, _version_number(request))
|
||||
data = request.store.get_blob(entry["content_ref"]["hash"].split(":", 1)[1])
|
||||
text = diffs.decode_text(data) if diffs.is_text_type(artifact["type"]) else None
|
||||
body: dict[str, Any] = {"artifact_id": artifact["id"], "type": artifact["type"], "version": entry}
|
||||
if text is None:
|
||||
body["content_base64"] = base64.b64encode(data).decode("ascii")
|
||||
else:
|
||||
body["content"] = text
|
||||
request.audit("artifacts.get_version", f"{artifact['id']}@{entry['version']}")
|
||||
headers = {"X-Content-Type-Options": "nosniff", "Content-Disposition": "attachment", "ETag": str(artifact["revision"])}
|
||||
return Response(200, body, headers)
|
||||
|
||||
|
||||
def diff(request: Request) -> Response:
|
||||
"""``GET /hux/v1/artifacts/{id}/versions/{n}/diff?from=``: unified diff for text, sizes and hashes otherwise."""
|
||||
artifact = _load_owned(request)
|
||||
to_entry = _version(artifact, _version_number(request))
|
||||
raw_from = request.query.get("from", "")
|
||||
if raw_from and not raw_from.isdigit():
|
||||
raise Invalid("from must be a version number")
|
||||
from_number = int(raw_from) if raw_from else to_entry.get("diff_from", to_entry["version"])
|
||||
from_entry = _version(artifact, from_number)
|
||||
older = request.store.get_blob(from_entry["content_ref"]["hash"].split(":", 1)[1])
|
||||
newer = request.store.get_blob(to_entry["content_ref"]["hash"].split(":", 1)[1])
|
||||
request.audit("artifacts.diff", f"{artifact['id']}@{from_number}..{to_entry['version']}")
|
||||
return Response(200, diffs.unified(artifact["type"], older, newer, from_number, to_entry["version"]), {"X-Content-Type-Options": "nosniff"})
|
||||
|
||||
|
||||
def promote(request: Request) -> Response:
|
||||
"""``POST /hux/v1/artifacts/{id}/promote``: mark the current version as the project's copy."""
|
||||
body = _body(request)
|
||||
project_id = check_id(body.get("project_id"))
|
||||
if not _project_exists(request.store, project_id):
|
||||
raise NotFound("project not found")
|
||||
expected = request.if_match()
|
||||
with request.store.lock(FAMILY):
|
||||
artifact = _load_owned(request)
|
||||
if expected is not None and expected != artifact["revision"]:
|
||||
raise Conflict(f"revision {expected} does not match current revision {artifact['revision']}", [str(artifact["revision"])])
|
||||
version = artifact["current_version"]
|
||||
if body.get("version") is not None:
|
||||
version = _version(artifact, body["version"])["version"]
|
||||
stamp = now_iso()
|
||||
updated = {**artifact, "project_id": project_id, "promotion": {"project_id": project_id, "version": version, "at": stamp}, "updated_at": stamp}
|
||||
_check(updated)
|
||||
stored = request.store.put(FAMILY, updated, expected_revision=artifact["revision"])
|
||||
request.audit("artifacts.promote", f"{stored['id']}@{version}", reason="" if expected is not None else "unconditional_write")
|
||||
emit_event(request.store, request.identity, stored, "artifact.promoted", f"Promoted version {version} to project", {"artifact_id": stored["id"], "version": version, "project_id": project_id})
|
||||
return Response(200, stored, {"ETag": str(stored["revision"])})
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-04 routes."""
|
||||
card = "HUX-04"
|
||||
router.add("POST", "/hux/v1/artifacts", card, "artifacts.create", create, max_body=MAX_UPLOAD_BODY)
|
||||
router.add("GET", "/hux/v1/artifacts", card, "artifacts.list", list_artifacts)
|
||||
router.add("GET", "/hux/v1/artifacts/{id}", card, "artifacts.get", get)
|
||||
router.add("POST", "/hux/v1/artifacts/{id}/versions", card, "artifacts.version", add_version, max_body=MAX_UPLOAD_BODY)
|
||||
router.add("GET", "/hux/v1/artifacts/{id}/versions/{n}", card, "artifacts.get_version", get_version)
|
||||
router.add("GET", "/hux/v1/artifacts/{id}/versions/{n}/diff", card, "artifacts.diff", diff)
|
||||
router.add("POST", "/hux/v1/artifacts/{id}/promote", card, "artifacts.promote", promote)
|
||||
203
dockerfiles/hermes-hux-foundation/hux/budgets.py
Normal file
203
dockerfiles/hermes-hux-foundation/hux/budgets.py
Normal file
@ -0,0 +1,203 @@
|
||||
"""HUX-05 run controls: budgets, the pre-side-effect gate and cancellation receipts.
|
||||
|
||||
The agent hook reports spend per run and asks the gate before every side
|
||||
effect. The gate releases only against an approved, unexpired approval for the
|
||||
same run, capability and argument hash; a ``once`` approval is consumed by its
|
||||
first release (SO-36, SO-37). A stop is not done until a receipt says what
|
||||
actually happened to the run and its side effects (SO-41).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from hux import policy, rules
|
||||
from hux.errors import Invalid
|
||||
from hux.http import Request, Response, Router
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, check_id
|
||||
|
||||
BUDGETS = "budgets"
|
||||
RECEIPTS = "receipts"
|
||||
SPEND_KEYS = ("tokens", "tool_calls", "wall_clock_seconds", "delegations", "spend_units", "subagents")
|
||||
LIMIT_OF = {
|
||||
"tokens": "tokens_per_run", "tool_calls": "tool_calls_per_run", "wall_clock_seconds": "wall_clock_seconds",
|
||||
"delegations": "delegations_per_run", "spend_units": "spend_units", "subagents": "subagents_per_run",
|
||||
}
|
||||
|
||||
|
||||
def run_id_from(request: Request) -> str:
|
||||
"""The run id in the path; the router already bounded its alphabet."""
|
||||
run_id = request.params["id"]
|
||||
if len(run_id) > 120:
|
||||
raise Invalid("run id too long")
|
||||
return run_id
|
||||
|
||||
|
||||
# -- budgets -------------------------------------------------------------------
|
||||
|
||||
def exhausted(spent: dict[str, int], limits: dict[str, Any]) -> list[str]:
|
||||
"""Limit names whose spend has reached them; a zero limit is exhausted immediately."""
|
||||
return [LIMIT_OF[k] for k in SPEND_KEYS if LIMIT_OF[k] in limits and spent.get(k, 0) >= limits[LIMIT_OF[k]]]
|
||||
|
||||
|
||||
def budget_state(store: TenantStore, identity: Identity, run_id: str, conversation_id: str | None = None) -> dict[str, Any]:
|
||||
"""Current ``hux.budget_state.v1`` for a run measured against the effective policy."""
|
||||
doc_id = f"bud_{policy.run_key(run_id)}"
|
||||
stored = store.get(BUDGETS, doc_id) if store.exists(BUDGETS, doc_id) else {"spent": {}, "_conversation_id": None}
|
||||
conversation_id = conversation_id or stored.get("_conversation_id")
|
||||
level, scope_id = ("conversation", conversation_id) if conversation_id else ("global", None)
|
||||
limits = {k: v for k, v in policy.effective_policy(store, identity, level, scope_id)["budgets"].items() if k != "scope"}
|
||||
spent = {k: int(stored["spent"].get(k, 0)) for k in SPEND_KEYS}
|
||||
return policy.checked({
|
||||
"schema": "hux.budget_state.v1", "run_id": run_id[:120], "spent": spent, "limits": limits,
|
||||
"exhausted": exhausted(spent, limits), "_conversation_id": conversation_id, "_id": doc_id,
|
||||
})
|
||||
|
||||
|
||||
def get_budget(request: Request) -> Response:
|
||||
"""``GET /hux/v1/runs/{id}/budget``: spend so far and what is exhausted."""
|
||||
state = budget_state(request.store, request.identity, run_id_from(request))
|
||||
request.audit("budgets.read", state["_id"])
|
||||
return Response(200, policy.public(state))
|
||||
|
||||
|
||||
def post_budget(request: Request) -> Response:
|
||||
"""``POST /hux/v1/runs/{id}/budget``: add spend increments; emits budget.exhausted on the crossing."""
|
||||
body = policy.body_dict(request)
|
||||
run_id = run_id_from(request)
|
||||
conversation_id = body.get("conversation_id")
|
||||
if conversation_id is not None:
|
||||
conversation_id = check_id(conversation_id)
|
||||
increments = {}
|
||||
for key in SPEND_KEYS:
|
||||
value = body.get(key, 0)
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise Invalid(f"{key} must be a non-negative integer")
|
||||
increments[key] = value
|
||||
with request.store.lock(BUDGETS):
|
||||
before = budget_state(request.store, request.identity, run_id, conversation_id)
|
||||
known = before["exhausted"] if request.store.exists(BUDGETS, before["_id"]) else []
|
||||
spent = {k: before["spent"][k] + increments[k] for k in SPEND_KEYS}
|
||||
doc = {"id": before["_id"], "run_id": run_id, "spent": spent, "_conversation_id": before["_conversation_id"]}
|
||||
request.store.put(BUDGETS, doc)
|
||||
after = budget_state(request.store, request.identity, run_id, conversation_id)
|
||||
request.audit("budgets.write", after["_id"])
|
||||
newly = [k for k in after["exhausted"] if k not in known]
|
||||
if newly:
|
||||
policy.emit(request.store, request.identity, after["_conversation_id"], "budget.exhausted",
|
||||
f"Budget exhausted: {', '.join(newly)}", run_id=run_id)
|
||||
return Response(200, policy.public(after))
|
||||
|
||||
|
||||
# -- gate ----------------------------------------------------------------------
|
||||
|
||||
def hashes_of(approval: dict[str, Any]) -> set[str]:
|
||||
"""Argument hashes the approval was requested for (tool_call evidence with a hash)."""
|
||||
return {e["hash"] for e in approval["request"].get("evidence", []) if e.get("kind") == "tool_call" and e.get("hash")}
|
||||
|
||||
|
||||
def matching_approval(store: TenantStore, run_id: str, capability: str, argument_hash: str, external: bool, conversation_id: str | None) -> tuple[dict[str, Any] | None, str, str | None]:
|
||||
"""The approval that releases this side effect, or why none does, plus the run's conversation when an approval names it."""
|
||||
reason = "no approval for this run and capability"
|
||||
for record in store.scan(policy.APPROVALS):
|
||||
record = policy.refresh(store, record)
|
||||
same_run = record["run_id"] == run_id
|
||||
if same_run:
|
||||
conversation_id = conversation_id or record["conversation_id"]
|
||||
same_conv = bool(conversation_id) and record["conversation_id"] == conversation_id
|
||||
choice = record.get("decision", {}).get("choice")
|
||||
if record["capability"] != capability or not (same_run or (choice == "session" and same_conv)):
|
||||
continue
|
||||
if record["status"] != "approved":
|
||||
reason = f"approval {record['id']} is {record['status']}"
|
||||
continue
|
||||
if policy.parse(record["expires_at"]) <= policy.now():
|
||||
reason = f"approval {record['id']} has expired"
|
||||
continue
|
||||
if external and not record["request"]["external"]:
|
||||
reason = f"approval {record['id']} was not requested as external"
|
||||
continue
|
||||
if choice == "once" and argument_hash not in hashes_of(record):
|
||||
reason = f"approval {record['id']} was for different arguments"
|
||||
continue
|
||||
if choice == "once" and record.get("_consumed_at"):
|
||||
reason = f"approval {record['id']} was already consumed"
|
||||
continue
|
||||
return record, "released", conversation_id
|
||||
return None, reason, conversation_id
|
||||
|
||||
|
||||
def gate(request: Request) -> Response:
|
||||
"""``POST /hux/v1/runs/{id}/gate``: may this side effect proceed right now?"""
|
||||
body = policy.body_dict(request)
|
||||
run_id = run_id_from(request)
|
||||
capability = body.get("capability")
|
||||
argument_hash = body.get("argument_hash")
|
||||
if capability not in rules.CAPABILITIES:
|
||||
raise Invalid("unknown capability")
|
||||
if not isinstance(argument_hash, str) or not argument_hash.startswith("sha256:"):
|
||||
raise Invalid("argument_hash must be sha256:<hex>")
|
||||
external = bool(body.get("external", False))
|
||||
conversation_id = body.get("conversation_id")
|
||||
if conversation_id is not None:
|
||||
conversation_id = check_id(conversation_id)
|
||||
with request.store.lock(policy.APPROVALS):
|
||||
record, reason, conversation_id = matching_approval(request.store, run_id, capability, argument_hash, external, conversation_id)
|
||||
if record is not None and record["decision"]["choice"] == "once":
|
||||
request.store.put(policy.APPROVALS, {**record, "_consumed_at": policy.iso(policy.now())})
|
||||
if record is None:
|
||||
request.audit("gate.check", f"{run_id}:{capability}", outcome="deny", reason="policy_violation")
|
||||
policy.emit(request.store, request.identity, conversation_id, "side_effect.blocked", f"{capability} blocked: {reason}"[:280], run_id=run_id)
|
||||
return Response(200, {"proceed": False, "reason": reason})
|
||||
request.audit("gate.check", record["id"])
|
||||
policy.emit(request.store, request.identity, conversation_id, "side_effect.released", f"{capability} released by approval {record['id']}",
|
||||
run_id=run_id, evidence=[{"kind": "approval", "id": record["id"]}])
|
||||
return Response(200, {"proceed": True, "approval_id": record["id"], "reason": reason})
|
||||
|
||||
|
||||
# -- stop ----------------------------------------------------------------------
|
||||
|
||||
def stop(request: Request) -> Response:
|
||||
"""``POST /hux/v1/runs/{id}/stop``: write the cancellation receipt; a repeat returns it."""
|
||||
body = policy.body_dict(request)
|
||||
run_id = run_id_from(request)
|
||||
receipt_id = f"rcpt_{policy.run_key(run_id)}"
|
||||
with request.store.lock(RECEIPTS):
|
||||
if request.store.exists(RECEIPTS, receipt_id):
|
||||
existing = request.store.get(RECEIPTS, receipt_id)
|
||||
request.audit("runs.stop", receipt_id, reason="replayed")
|
||||
return Response(200, policy.public(existing))
|
||||
side_effects = body.get("side_effects", [])
|
||||
if not isinstance(side_effects, list):
|
||||
raise Invalid("side_effects must be a list")
|
||||
if body.get("already_complete"):
|
||||
outcome = "already_complete"
|
||||
elif body.get("process_registry_empty") is True:
|
||||
outcome = "cancelled"
|
||||
else:
|
||||
outcome = "failed_to_cancel"
|
||||
stamp = policy.iso(policy.now())
|
||||
record: dict[str, Any] = {
|
||||
"schema": "hux.cancel_receipt.v1", "id": receipt_id, "run_id": run_id, "requested_by": policy.actor_for(request.identity),
|
||||
"requested_at": stamp, "acknowledged_at": stamp, "outcome": outcome, "side_effects": side_effects,
|
||||
}
|
||||
if outcome != "failed_to_cancel":
|
||||
record["completed_at"] = stamp
|
||||
conversation_id = body.get("conversation_id")
|
||||
if conversation_id is not None:
|
||||
record["conversation_id"] = check_id(conversation_id)
|
||||
stored = request.store.put(RECEIPTS, policy.checked(record))
|
||||
request.audit("runs.stop", receipt_id, reason=outcome)
|
||||
policy.emit(request.store, request.identity, record.get("conversation_id"), "run.cancelled", f"Run stopped: {outcome}",
|
||||
run_id=run_id, evidence=[{"kind": "run", "id": receipt_id}])
|
||||
return Response(201, policy.public(stored))
|
||||
|
||||
|
||||
def register_routes(router: Router) -> None:
|
||||
"""Attach the run-scoped HUX-05 routes; called by ``hux.policy.register``."""
|
||||
router.add("GET", "/hux/v1/runs/{id}/budget", policy.CARD, "budgets.read", get_budget)
|
||||
router.add("POST", "/hux/v1/runs/{id}/budget", policy.CARD, "budgets.write", post_budget)
|
||||
router.add("POST", "/hux/v1/runs/{id}/gate", policy.CARD, "gate.check", gate)
|
||||
router.add("POST", "/hux/v1/runs/{id}/stop", policy.CARD, "runs.stop", stop)
|
||||
|
||||
50
dockerfiles/hermes-hux-foundation/hux/diffs.py
Normal file
50
dockerfiles/hermes-hux-foundation/hux/diffs.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""Version-to-version comparison for artifacts (HUX-04).
|
||||
|
||||
Text artifacts get a unified diff from ``difflib``; anything that is not
|
||||
valid UTF-8, or whose artifact type is binary, is described by sizes and
|
||||
hashes only so the UI never tries to render bytes as text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
TEXT_TYPES = frozenset({"markdown", "code", "html", "svg", "json", "csv"})
|
||||
|
||||
|
||||
def is_text_type(artifact_type: str) -> bool:
|
||||
"""True for artifact types whose content is served as UTF-8 text."""
|
||||
return artifact_type in TEXT_TYPES
|
||||
|
||||
|
||||
def decode_text(data: bytes) -> str | None:
|
||||
"""UTF-8 decode, or None when the bytes are not text."""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def unified(artifact_type: str, older: bytes, newer: bytes, from_n: int, to_n: int) -> dict[str, Any]:
|
||||
"""Diff record ``{"from", "to", "unified"|"binary"}`` for two versions."""
|
||||
record: dict[str, Any] = {"from": from_n, "to": to_n}
|
||||
old_text = decode_text(older) if is_text_type(artifact_type) else None
|
||||
new_text = decode_text(newer) if is_text_type(artifact_type) else None
|
||||
if old_text is None or new_text is None:
|
||||
record["binary"] = {
|
||||
"from_bytes": len(older),
|
||||
"to_bytes": len(newer),
|
||||
"from_hash": "sha256:" + hashlib.sha256(older).hexdigest(),
|
||||
"to_hash": "sha256:" + hashlib.sha256(newer).hexdigest(),
|
||||
}
|
||||
return record
|
||||
lines = difflib.unified_diff(
|
||||
old_text.splitlines(keepends=True),
|
||||
new_text.splitlines(keepends=True),
|
||||
fromfile=f"v{from_n}",
|
||||
tofile=f"v{to_n}",
|
||||
)
|
||||
record["unified"] = "".join(lines)
|
||||
return record
|
||||
@ -58,6 +58,12 @@ class Invalid(HuxError):
|
||||
status, code = 400, "invalid"
|
||||
|
||||
|
||||
class Unprocessable(HuxError):
|
||||
"""Well-formed body that violates a rule (hash mismatch, policy violation)."""
|
||||
|
||||
status, code = 422, "unprocessable"
|
||||
|
||||
|
||||
class TooLarge(HuxError):
|
||||
"""Body, record or family exceeds its bound."""
|
||||
|
||||
|
||||
283
dockerfiles/hermes-hux-foundation/hux/events.py
Normal file
283
dockerfiles/hermes-hux-foundation/hux/events.py
Normal file
@ -0,0 +1,283 @@
|
||||
"""HUX-01 activity events: the per-conversation append-only log and its readers.
|
||||
|
||||
``emit`` is the one write path every family uses. It runs the redaction
|
||||
pipeline, allocates ``seq`` under the conversation lock, honours idempotency
|
||||
keys and keeps a checkpoint document per conversation. Readers page by
|
||||
``after_seq`` or follow an SSE stream whose ``id:`` is the seq, and every
|
||||
served record passes serve-time redaction for the caller's surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
from collections.abc import Iterator
|
||||
|
||||
from hux import contracts, redaction
|
||||
from hux.errors import Invalid, NotFound, TooLarge
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, new_id, now_iso
|
||||
|
||||
FAMILY = "events"
|
||||
SEQ_FAMILY = "events_seq"
|
||||
IDEM_FAMILY = "events_idem"
|
||||
PAGE_MAX = 200
|
||||
PAGE_DEFAULT = 100
|
||||
STREAM_MAX_POLLS = 900
|
||||
STREAM_POLL_SECONDS = 1.0
|
||||
SCHEMAS = contracts.load_all()
|
||||
KINDS = frozenset(contracts.load_schema("event.schema.json")["properties"]["kind"]["enum"])
|
||||
USER_KINDS = frozenset({"message.user", "approval.resolved", "run.cancelled", "memory.forgotten", "suggestion.dismissed"})
|
||||
_streams: dict[str, int] = {}
|
||||
_streams_guard = threading.Lock()
|
||||
|
||||
|
||||
def _seq_id(conversation_id: str) -> str:
|
||||
return f"seq_{conversation_id}"
|
||||
|
||||
|
||||
def is_private(store: TenantStore, conversation_id: str) -> bool:
|
||||
"""True when the conversation document (HUX-03) says the mode is private (SO-28)."""
|
||||
try:
|
||||
return store.get("conversations", conversation_id).get("mode") == "private"
|
||||
except NotFound:
|
||||
return False
|
||||
|
||||
|
||||
def conversation_known(store: TenantStore, conversation_id: str) -> bool:
|
||||
"""Ownership check (SO-18): the conversation exists somewhere in this subject's tree."""
|
||||
return (
|
||||
store.exists("conversations", conversation_id)
|
||||
or store.exists(SEQ_FAMILY, _seq_id(conversation_id))
|
||||
)
|
||||
|
||||
|
||||
def _actor(identity: Identity, kind: str) -> dict[str, str]:
|
||||
if kind in USER_KINDS and identity.trust != "worker":
|
||||
return {"type": "user", "id": identity.subject}
|
||||
if identity.trust == "worker":
|
||||
return {"type": "system", "id": "hux-worker"}
|
||||
return {"type": "assistant", "id": "hermes"}
|
||||
|
||||
|
||||
def _find_replay(store: TenantStore, conversation_id: str, key: str) -> dict[str, Any] | None:
|
||||
for row in store.read(IDEM_FAMILY, conversation_id):
|
||||
if row.get("idempotency_key") == key:
|
||||
for event in store.read(FAMILY, conversation_id):
|
||||
if event.get("id") == row.get("event_id"):
|
||||
return event
|
||||
return None
|
||||
|
||||
|
||||
def build(identity: Identity, conversation_id: str, kind: str, summary: str, detail: dict | None, evidence: list | None,
|
||||
sensitivity: str, run_id: str | None, turn: int | None, correlation_id: str | None, idempotency_key: str | None) -> dict[str, Any]:
|
||||
"""Run the redaction pipeline and shape an unsequenced ``hux.event.v1`` record."""
|
||||
if kind not in KINDS:
|
||||
raise Invalid(f"unknown event kind {kind!r}")
|
||||
if sensitivity not in ("public", "personal", "sensitive", "restricted"):
|
||||
raise Invalid("unknown sensitivity")
|
||||
hits: list[str] = []
|
||||
summary_clean = redaction.scrub_value(str(summary or "").strip(), hits)[: redaction.SUMMARY_MAX] or "[empty]"
|
||||
detail_clean = redaction.scrub_value(redaction.filter_detail(kind, detail), hits)
|
||||
detail_clean, truncated = redaction.cap_detail(detail_clean)
|
||||
evidence_clean = redaction.scrub_value(redaction.filter_evidence(evidence), hits)
|
||||
stamp = now_iso()
|
||||
record: dict[str, Any] = {
|
||||
"schema": "hux.event.v1",
|
||||
"id": new_id("evt"),
|
||||
"seq": 0,
|
||||
"ts": stamp,
|
||||
"conversation_id": conversation_id,
|
||||
"kind": kind,
|
||||
"summary": summary_clean,
|
||||
"provenance": {"surface": identity.surface, "actor": _actor(identity, kind), "recorded_at": stamp, "conversation_id": conversation_id},
|
||||
"sensitivity": sensitivity,
|
||||
"redaction": redaction.derive_level(sensitivity, hits, truncated),
|
||||
"turn": max(0, int(turn or 0)),
|
||||
"identity": identity.record(),
|
||||
}
|
||||
if detail_clean:
|
||||
record["detail"] = detail_clean
|
||||
if evidence_clean:
|
||||
record["evidence"] = evidence_clean
|
||||
if run_id:
|
||||
record["run_id"] = str(run_id)[:120]
|
||||
record["provenance"]["run_id"] = record["run_id"]
|
||||
if correlation_id:
|
||||
record["correlation_id"] = str(correlation_id)[:120]
|
||||
if idempotency_key:
|
||||
record["idempotency_key"] = idempotency_key
|
||||
return record
|
||||
|
||||
|
||||
def emit_with_status(store: TenantStore, identity: Identity, conversation_id: str, kind: str, summary: str, detail: dict | None = None,
|
||||
evidence: list | None = None, sensitivity: str = "personal", run_id: str | None = None, turn: int | None = None,
|
||||
correlation_id: str | None = None, idempotency_key: str | None = None) -> tuple[dict[str, Any] | None, bool]:
|
||||
"""Like ``emit`` but also says whether the record was an idempotent replay. None means private mode (nothing written)."""
|
||||
if is_private(store, conversation_id):
|
||||
return None, False
|
||||
record = build(identity, conversation_id, kind, summary, detail, evidence, sensitivity, run_id, turn, correlation_id, idempotency_key)
|
||||
with store.lock(f"{FAMILY}:{conversation_id}"):
|
||||
if idempotency_key:
|
||||
existing = _find_replay(store, conversation_id, idempotency_key)
|
||||
if existing is not None:
|
||||
return existing, True
|
||||
seq_id = _seq_id(conversation_id)
|
||||
checkpoint = store.get(SEQ_FAMILY, seq_id) if store.exists(SEQ_FAMILY, seq_id) else {"id": seq_id, "next_seq": 1, "last_event_id": ""}
|
||||
record["seq"] = int(checkpoint["next_seq"])
|
||||
problems = contracts.validate_record(record, SCHEMAS)
|
||||
if problems:
|
||||
raise Invalid("event failed contract validation", problems)
|
||||
line = json.dumps(record, sort_keys=True, separators=(",", ":")).encode()
|
||||
if len(line) > redaction.LINE_CAP_BYTES:
|
||||
raise TooLarge("event line exceeds 64 KiB")
|
||||
store.append(FAMILY, conversation_id, record)
|
||||
store.put(SEQ_FAMILY, {**checkpoint, "next_seq": record["seq"] + 1, "last_event_id": record["id"], "checkpointed_at": now_iso()})
|
||||
if idempotency_key:
|
||||
store.append(IDEM_FAMILY, conversation_id, {"idempotency_key": idempotency_key, "event_id": record["id"], "seq": record["seq"], "at": record["ts"]})
|
||||
return record, False
|
||||
|
||||
|
||||
def emit(store: TenantStore, identity: Identity, conversation_id: str, kind: str, summary: str, detail: dict | None = None,
|
||||
evidence: list | None = None, sensitivity: str = "personal", run_id: str | None = None, turn: int | None = None,
|
||||
correlation_id: str | None = None, idempotency_key: str | None = None) -> dict[str, Any] | None:
|
||||
"""Append one event and return the stored record (None when the conversation is private)."""
|
||||
record, _ = emit_with_status(store, identity, conversation_id, kind, summary, detail, evidence, sensitivity, run_id, turn, correlation_id, idempotency_key)
|
||||
return record
|
||||
|
||||
|
||||
def read_after(store: TenantStore, conversation_id: str, after_seq: int, limit: int) -> list[dict[str, Any]]:
|
||||
"""Stored events with ``seq > after_seq``, oldest first, at most ``limit``."""
|
||||
rows = [row for row in store.read(FAMILY, conversation_id) if int(row.get("seq", 0)) > after_seq]
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def rewrite_full(store: TenantStore, conversation_id: str, summary: str, reason: str, match=None) -> int:
|
||||
"""Rewrite matching events of one conversation to ``redaction.level: full`` (SO-24, forget, decay). Returns the count."""
|
||||
with store.lock(f"{FAMILY}:{conversation_id}"):
|
||||
rows = store.read(FAMILY, conversation_id)
|
||||
changed = 0
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if row.get("redaction", {}).get("level") != "full" and (match is None or match(row)):
|
||||
row = redaction.full_redaction(row, summary, reason)
|
||||
changed += 1
|
||||
out.append(row)
|
||||
if changed:
|
||||
store.rewrite(FAMILY, conversation_id, out)
|
||||
return changed
|
||||
|
||||
|
||||
def redact_memory_references(store: TenantStore, memory_id: str) -> int:
|
||||
"""Fully redact every event, in any conversation, that names a forgotten memory id (SO-24)."""
|
||||
|
||||
def references(row: dict[str, Any]) -> bool:
|
||||
if row.get("detail", {}).get("memory_id") == memory_id:
|
||||
return True
|
||||
return any(ref.get("kind") == "memory" and ref.get("id") == memory_id for ref in row.get("evidence", []))
|
||||
|
||||
return sum(rewrite_full(store, name, "[forgotten memory]", "memory forgotten", references) for name in store.ledgers(FAMILY))
|
||||
|
||||
|
||||
# -- routes ------------------------------------------------------------------
|
||||
|
||||
def _int_query(request: Request, name: str, default: int, floor: int = 0) -> int:
|
||||
raw = request.query.get(name, "")
|
||||
if raw == "":
|
||||
return default
|
||||
if not raw.lstrip("-").isdigit():
|
||||
raise Invalid(f"{name} must be an integer")
|
||||
return max(floor, int(raw))
|
||||
|
||||
|
||||
def _require_conversation(request: Request) -> str:
|
||||
conversation_id = request.params["id"]
|
||||
if not conversation_known(request.store, conversation_id):
|
||||
raise NotFound("conversation not found")
|
||||
return conversation_id
|
||||
|
||||
|
||||
def list_events(request: Request) -> Response:
|
||||
"""``GET /hux/v1/conversations/{id}/events?after_seq=&limit=``: one page, ``next`` is the last seq served."""
|
||||
conversation_id = _require_conversation(request)
|
||||
after_seq = _int_query(request, "after_seq", 0)
|
||||
limit = min(_int_query(request, "limit", PAGE_DEFAULT, 1), PAGE_MAX)
|
||||
rows = read_after(request.store, conversation_id, after_seq, limit)
|
||||
items = [redaction.redact_record(row, request.identity.surface) for row in rows]
|
||||
request.audit("events.list", conversation_id)
|
||||
return page(items, items[-1]["seq"] if len(items) == limit else None)
|
||||
|
||||
|
||||
def append_event(request: Request) -> Response:
|
||||
"""``POST /hux/v1/conversations/{id}/events``: append from the trusted hop; ids and seq are server-assigned (SO-15)."""
|
||||
conversation_id = _require_conversation(request)
|
||||
body = request.body if isinstance(request.body, dict) else None
|
||||
if body is None:
|
||||
raise Invalid("body must be an object")
|
||||
if "id" in body or "seq" in body:
|
||||
raise Invalid("id and seq are server-assigned")
|
||||
if not isinstance(body.get("kind"), str) or not isinstance(body.get("summary"), str):
|
||||
raise Invalid("kind and summary are required")
|
||||
detail = body.get("detail") if isinstance(body.get("detail"), dict) else None
|
||||
evidence = body.get("evidence") if isinstance(body.get("evidence"), list) else None
|
||||
turn = body.get("turn") if isinstance(body.get("turn"), int) else None
|
||||
record, replayed = emit_with_status(
|
||||
request.store, request.identity, conversation_id, body["kind"], body["summary"], detail, evidence,
|
||||
body.get("sensitivity", "personal"), body.get("run_id"), turn, body.get("correlation_id"), request.idempotency_key() or None,
|
||||
)
|
||||
if record is None:
|
||||
request.audit("events.append", conversation_id, "allow", "private_mode")
|
||||
return Response(204)
|
||||
request.audit("events.append", f"{conversation_id}/{record['id']}", "allow", "replayed" if replayed else "")
|
||||
served = redaction.redact_record(record, request.identity.surface)
|
||||
return Response(200 if replayed else 201, served, {"HUX-Replayed": "true"} if replayed else {})
|
||||
|
||||
|
||||
def _sse(record: dict[str, Any]) -> bytes:
|
||||
return f"id: {record['seq']}\nevent: {record['kind']}\ndata: {json.dumps(record, sort_keys=True)}\n\n".encode()
|
||||
|
||||
|
||||
def stream_events(request: Request) -> Response:
|
||||
"""``GET /hux/v1/conversations/{id}/events/stream``: SSE replay from ``Last-Event-ID`` (or ``after_seq``), then a bounded live poll.
|
||||
|
||||
``max_polls`` and ``poll_ms`` (query) bound the live phase so a client, or a
|
||||
test, decides how long to wait; the server caps them at the 15 minute idle
|
||||
limit (SO-17). A second stream for the same (subject, conversation) closes
|
||||
the first.
|
||||
"""
|
||||
conversation_id = _require_conversation(request)
|
||||
last_id = request.header("Last-Event-ID")
|
||||
cursor = int(last_id) if last_id.isdigit() else _int_query(request, "after_seq", 0)
|
||||
max_polls = min(_int_query(request, "max_polls", STREAM_MAX_POLLS), STREAM_MAX_POLLS)
|
||||
poll_seconds = min(_int_query(request, "poll_ms", int(STREAM_POLL_SECONDS * 1000)), 60000) / 1000
|
||||
store, surface = request.store, request.identity.surface
|
||||
key = f"{request.identity.subject}:{conversation_id}"
|
||||
with _streams_guard:
|
||||
token = _streams[key] = _streams.get(key, 0) + 1
|
||||
request.audit("events.stream", conversation_id)
|
||||
|
||||
def generate() -> Iterator[bytes]:
|
||||
position = cursor
|
||||
yield b"retry: 2000\n\n"
|
||||
polls = 0
|
||||
while True:
|
||||
for record in read_after(store, conversation_id, position, PAGE_MAX):
|
||||
position = record["seq"]
|
||||
yield _sse(redaction.redact_record(record, surface))
|
||||
if polls >= max_polls or _streams.get(key) != token:
|
||||
break
|
||||
polls += 1
|
||||
yield b": keepalive\n\n"
|
||||
time.sleep(poll_seconds)
|
||||
|
||||
return Response(200, stream=generate)
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-01 routes."""
|
||||
router.add("GET", "/hux/v1/conversations/{id}/events", "HUX-01", "events.list", list_events)
|
||||
router.add("POST", "/hux/v1/conversations/{id}/events", "HUX-01", "events.append", append_event)
|
||||
router.add("GET", "/hux/v1/conversations/{id}/events/stream", "HUX-01", "events.stream", stream_events)
|
||||
@ -15,7 +15,7 @@ from hux.errors import FlagOff
|
||||
from hux.identity import Identity
|
||||
from hux.rules import flag_enabled, flag_registry
|
||||
|
||||
CONTRACT_VERSION = "1.0.0"
|
||||
CONTRACT_VERSION = "1.1.0"
|
||||
CARD_ROUTES: dict[str, list[str]] = {
|
||||
"HUX-11": ["/hux/v1/capabilities", "/hux/v1/manifest"],
|
||||
"HUX-01": ["/hux/v1/conversations/{id}/events", "/hux/v1/conversations/{id}/events/stream"],
|
||||
|
||||
@ -89,6 +89,7 @@ class Route:
|
||||
card: str
|
||||
action: str
|
||||
handler: Handler
|
||||
max_body: int = MAX_BODY_BYTES
|
||||
pattern: re.Pattern = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
@ -106,9 +107,9 @@ class Router:
|
||||
self.build = build_from_environ(environ)
|
||||
self.routes: list[Route] = []
|
||||
|
||||
def add(self, method: str, template: str, card: str, action: str, handler: Handler) -> None:
|
||||
"""Register a handler; ``action`` is the audit action name (family.verb)."""
|
||||
self.routes.append(Route(method, template, card, action, handler))
|
||||
def add(self, method: str, template: str, card: str, action: str, handler: Handler, max_body: int = MAX_BODY_BYTES) -> None:
|
||||
"""Register a handler; ``action`` is the audit action name (family.verb), ``max_body`` its byte cap."""
|
||||
self.routes.append(Route(method, template, card, action, handler, max_body))
|
||||
|
||||
def match(self, method: str, path: str) -> tuple[Route | None, dict[str, str], bool]:
|
||||
"""Return (route, params, path_known)."""
|
||||
@ -137,7 +138,7 @@ class Router:
|
||||
return Response(405 if known else 404, error.record())
|
||||
try:
|
||||
self.flags.require(route.card)
|
||||
payload = self._decode(body)
|
||||
payload = self._decode(body, route.max_body)
|
||||
request = Request(method, parts.path, params, query, headers, payload, identity, store, self.flags, self.build)
|
||||
response = route.handler(request)
|
||||
except HuxError as error:
|
||||
@ -147,11 +148,11 @@ class Router:
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _decode(body: bytes) -> Any:
|
||||
def _decode(body: bytes, max_body: int = MAX_BODY_BYTES) -> Any:
|
||||
if not body:
|
||||
return None
|
||||
if len(body) > MAX_BODY_BYTES:
|
||||
raise TooLarge("body exceeds 1 MiB")
|
||||
if len(body) > max_body:
|
||||
raise TooLarge(f"body exceeds {max_body} bytes")
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError as error:
|
||||
|
||||
387
dockerfiles/hermes-hux-foundation/hux/memory.py
Normal file
387
dockerfiles/hermes-hux-foundation/hux/memory.py
Normal file
@ -0,0 +1,387 @@
|
||||
"""HUX-02 memory control: an append-only ledger with explicit user consent.
|
||||
|
||||
Every entry is a ``hux.memory.v1`` snapshot. The newest snapshot per id is
|
||||
kept as a revisioned document (so If-Match works) and every snapshot is also
|
||||
appended to ``ledger.jsonl``. Status moves only along
|
||||
``rules.MEMORY_TRANSITIONS``; ``no_store`` and ``forgotten`` write a
|
||||
content-free line plus a tombstone before anything is returned (SO-22), and
|
||||
``retrieve`` consults the tombstones before the documents (SO-23).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from hux import contracts, redaction, rules
|
||||
from hux.errors import Conflict, Forbidden, Invalid, NotFound
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, new_id, now_iso
|
||||
|
||||
FAMILY = "memory"
|
||||
IDEM_FAMILY = "memory_idem"
|
||||
LEDGER = "ledger"
|
||||
TOMBSTONES = "tombstones"
|
||||
DEFAULT_CONVERSATION = "conv_memory"
|
||||
HUMAN_SURFACES = frozenset({"chat", "telegram", "voice"})
|
||||
ACTIONS = ("approve", "reject", "forget", "edit", "remove_retrieval", "restore_retrieval")
|
||||
KINDS = ("preference", "fact", "instruction", "context")
|
||||
DEFAULT_DECAY_DAYS = 180
|
||||
SCHEMAS = contracts.load_all()
|
||||
|
||||
|
||||
def _parse(stamp: str) -> datetime:
|
||||
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def _emit(store: TenantStore, identity: Identity, record: dict[str, Any], kind: str, summary: str) -> None:
|
||||
from hux import events
|
||||
|
||||
detail = {"memory_id": record["id"], "kind": record["kind"], "sensitivity": record["sensitivity"], "topic": record.get("topic", "general")}
|
||||
conversation_id = record["provenance"].get("conversation_id", DEFAULT_CONVERSATION)
|
||||
events.emit(store, identity, conversation_id, kind, summary, detail, [{"kind": "memory", "id": record["id"]}], sensitivity=record["sensitivity"])
|
||||
|
||||
|
||||
def tombstoned(store: TenantStore) -> set[str]:
|
||||
"""Ids that must never surface again, whatever the documents say."""
|
||||
return {row["memory_id"] for row in store.read(FAMILY, TOMBSTONES)}
|
||||
|
||||
|
||||
def _tombstone(store: TenantStore, memory_id: str, reason: str) -> None:
|
||||
store.append(FAMILY, TOMBSTONES, {"memory_id": memory_id, "at": now_iso(), "reason": reason, "purged": False})
|
||||
|
||||
|
||||
def effective_expiry(record: dict[str, Any]) -> datetime | None:
|
||||
"""When the entry stops being active: ``expires_at``, or decay from the last approval (or creation)."""
|
||||
ttl = record["ttl"]
|
||||
if ttl["policy"] == "expires_at":
|
||||
return _parse(ttl["expires_at"])
|
||||
if ttl["policy"] == "decay":
|
||||
approvals = [row["at"] for row in record["audit"] if row["action"] == "approved"]
|
||||
return _parse(approvals[-1] if approvals else record["created_at"]) + timedelta(days=ttl["decay_days"])
|
||||
return None
|
||||
|
||||
|
||||
def _persist(store: TenantStore, record: dict[str, Any], expected: int | None) -> dict[str, Any]:
|
||||
"""Validate against contract and rules, then write document + ledger line under the family lock."""
|
||||
problems = rules.memory_policy_violations(record) + contracts.validate_record({**record, "revision": record.get("revision") or 1}, SCHEMAS)
|
||||
if problems:
|
||||
raise Invalid("memory entry violates policy", problems)
|
||||
with store.lock(FAMILY):
|
||||
stored = store.put(FAMILY, record, expected)
|
||||
store.append(FAMILY, LEDGER, stored)
|
||||
return stored
|
||||
|
||||
|
||||
def _transition(store: TenantStore, identity: Identity, record: dict[str, Any], target: str, action: str, actor: dict[str, str],
|
||||
note: str = "", expected: int | None = None, **changes: Any) -> dict[str, Any]:
|
||||
if not rules.transition_allowed(rules.MEMORY_TRANSITIONS, record["status"], target):
|
||||
raise Conflict(f"{record['status']} -> {target} is not a legal memory transition")
|
||||
stamp = now_iso()
|
||||
updated = {**record, **changes, "status": target, "updated_at": stamp, "audit": [*record["audit"], {"at": stamp, "action": action, "actor": actor, **({"note": note[:200]} if note else {})}]}
|
||||
if target in {"forgotten", "rejected", "expired"}:
|
||||
updated["content"], updated["retrievable"] = "", False
|
||||
if target == "active":
|
||||
updated["retrievable"] = changes.get("retrievable", True)
|
||||
stored = _persist(store, updated, expected)
|
||||
if target == "forgotten":
|
||||
_tombstone(store, stored["id"], action)
|
||||
return stored
|
||||
|
||||
|
||||
def load(store: TenantStore, memory_id: str, now: datetime | None = None) -> dict[str, Any]:
|
||||
"""Newest snapshot with lazy expiry (SO-27): an entry past its expiry is written as expired before it is served."""
|
||||
record = store.get(FAMILY, memory_id)
|
||||
if record["status"] == "active":
|
||||
expiry = effective_expiry(record)
|
||||
if expiry is not None and expiry <= (now or datetime.now(timezone.utc)):
|
||||
record = _transition(store, None, record, "expired", "expired", {"type": "system", "id": "retention"}, expected=record["revision"])
|
||||
return record
|
||||
|
||||
|
||||
def expire_due(store: TenantStore, now: datetime) -> int:
|
||||
"""Retention pass: move every active entry past its expiry to expired. Returns the count."""
|
||||
before = {row["id"]: row["status"] for row in store.scan(FAMILY)}
|
||||
return sum(1 for memory_id, status in before.items() if status == "active" and load(store, memory_id, now)["status"] == "expired")
|
||||
|
||||
|
||||
def purge_forgotten(store: TenantStore) -> int:
|
||||
"""Retention pass (SO-47): blank ``content`` on every ledger snapshot of tombstoned ids, then mark them purged."""
|
||||
with store.lock(FAMILY):
|
||||
stones = store.read(FAMILY, TOMBSTONES)
|
||||
pending = {row["memory_id"] for row in stones if not row.get("purged")}
|
||||
if not pending:
|
||||
return 0
|
||||
rows = store.read(FAMILY, LEDGER)
|
||||
store.rewrite(FAMILY, LEDGER, [{**row, "content": ""} if row.get("id") in pending else row for row in rows])
|
||||
store.rewrite(FAMILY, TOMBSTONES, [{**row, "purged": True} for row in stones])
|
||||
return len(pending)
|
||||
|
||||
|
||||
def forget_from_conversation(store: TenantStore, identity: Identity, conversation_id: str) -> list[str]:
|
||||
"""Forget every entry sourced from ``conversation_id`` (used by the privacy forget); returns the ids."""
|
||||
from hux import events
|
||||
|
||||
forgotten: list[str] = []
|
||||
for row in list(store.scan(FAMILY)):
|
||||
if row["provenance"].get("conversation_id") != conversation_id:
|
||||
continue
|
||||
if row["status"] in {"proposed"}:
|
||||
row = _transition(store, identity, row, "rejected", "rejected", {"type": "system", "id": "forget"}, "conversation forgotten")
|
||||
if row["status"] in {"active", "expired"}:
|
||||
row = _transition(store, identity, row, "forgotten", "forgotten", {"type": "system", "id": "forget"}, "conversation forgotten")
|
||||
if row["status"] not in {"forgotten", "no_store"}:
|
||||
_tombstone(store, row["id"], "conversation forgotten")
|
||||
forgotten.append(row["id"])
|
||||
events.redact_memory_references(store, row["id"])
|
||||
return forgotten
|
||||
|
||||
|
||||
def retrieve(store: TenantStore, query_terms: list[str], scope: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||
"""Agent read hook (SO-23): tombstones first, then only active + retrievable entries in scope; never no_store, forgotten or expired."""
|
||||
from hux import privacy
|
||||
|
||||
stones = tombstoned(store)
|
||||
blocked = privacy.blocked_conversations(store)
|
||||
if scope and scope.get("level") == "conversation" and scope.get("scope_id") in blocked:
|
||||
return []
|
||||
terms = [term.lower() for term in query_terms if term]
|
||||
hits: list[dict[str, Any]] = []
|
||||
for row in store.scan(FAMILY):
|
||||
if row["id"] in stones or row["provenance"].get("conversation_id") in blocked:
|
||||
continue
|
||||
record = load(store, row["id"])
|
||||
if record["status"] != "active" or not record["retrievable"]:
|
||||
continue
|
||||
if scope and record["scope"]["level"] != "global" and record["scope"] != scope:
|
||||
continue
|
||||
text = record["content"].lower()
|
||||
if terms and not any(term in text for term in terms):
|
||||
continue
|
||||
hits.append(record)
|
||||
return sorted(hits, key=lambda r: r["updated_at"], reverse=True)
|
||||
|
||||
|
||||
# -- proposals ------------------------------------------------------------------
|
||||
|
||||
def _human(identity: Identity) -> bool:
|
||||
return identity.surface in HUMAN_SURFACES and identity.trust != "worker"
|
||||
|
||||
|
||||
def _actor(request: Request) -> dict[str, str]:
|
||||
if _human(request.identity) and request.body.get("proposed_by", "assistant") == "user":
|
||||
return {"type": "user", "id": request.identity.subject}
|
||||
return {"type": "assistant", "id": "hermes"}
|
||||
|
||||
|
||||
def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
"""Build the entry from a proposal body and decide its approval mode from policy; returns (entry, gate_reason)."""
|
||||
from hux import privacy
|
||||
|
||||
if "id" in body or "revision" in body or "status" in body:
|
||||
raise Invalid("id, revision and status are server-assigned")
|
||||
content = body.get("content")
|
||||
if not isinstance(content, str) or not content.strip() or len(content) > 2000:
|
||||
raise Invalid("content must be a non-empty string of at most 2000 chars")
|
||||
hits: list[str] = []
|
||||
content = redaction.scrub_value(content.strip(), hits)
|
||||
detected = privacy.detect_topics(content) + (["credentials"] if hits else [])
|
||||
topic = body.get("topic") if body.get("topic") in rules.PRIVACY_TOPICS or body.get("topic") == "general" else None
|
||||
topic = topic if topic and topic != "general" else (detected[0] if detected else "general")
|
||||
sensitivity = body.get("sensitivity", "personal")
|
||||
if sensitivity not in privacy.SENSITIVITY_RANK:
|
||||
raise Invalid("unknown sensitivity")
|
||||
floor = privacy.topic_sensitivity(detected + ([topic] if topic != "general" else []))
|
||||
if privacy.SENSITIVITY_RANK[floor] > privacy.SENSITIVITY_RANK[sensitivity]:
|
||||
sensitivity = floor
|
||||
scope = body.get("scope") if isinstance(body.get("scope"), dict) else {"level": "global"}
|
||||
source = body.get("source") if isinstance(body.get("source"), dict) else {"kind": "message", "id": "unspecified"}
|
||||
ttl = body.get("ttl") if isinstance(body.get("ttl"), dict) else None
|
||||
if ttl is None or (sensitivity == "sensitive" and ttl.get("policy") == "never"):
|
||||
days = rules.PRIVACY_TOPICS[topic]["decay_days"] if topic in rules.PRIVACY_TOPICS else DEFAULT_DECAY_DAYS
|
||||
ttl = {"policy": "decay", "decay_days": days}
|
||||
actor = _actor(request)
|
||||
stamp = now_iso()
|
||||
conversation_id = body.get("conversation_id") if isinstance(body.get("conversation_id"), str) else None
|
||||
provenance = {"surface": request.identity.surface, "actor": actor, "recorded_at": stamp}
|
||||
if conversation_id:
|
||||
provenance["conversation_id"] = conversation_id
|
||||
if isinstance(body.get("run_id"), str):
|
||||
provenance["run_id"] = body["run_id"][:120]
|
||||
entry: dict[str, Any] = {
|
||||
"schema": "hux.memory.v1", "id": new_id("mem"), "owner": request.identity.subject, "scope": scope,
|
||||
"kind": body.get("kind") if body.get("kind") in KINDS else "fact", "content": content, "status": "proposed",
|
||||
"approval_mode": "ask", "sensitivity": sensitivity, "topic": topic, "ttl": ttl, "source": source,
|
||||
"provenance": provenance, "created_at": stamp, "updated_at": stamp,
|
||||
"audit": [{"at": stamp, "action": "proposed", "actor": actor}], "reason": str(body.get("reason") or "Proposed to be remembered.")[:280],
|
||||
"retrievable": False, "identity": request.identity.record(),
|
||||
}
|
||||
if isinstance(body.get("supersedes"), str):
|
||||
entry["supersedes"] = body["supersedes"]
|
||||
allowed, why = privacy.memory_write_allowed(request.store, conversation_id, entry)
|
||||
if why == "private_mode":
|
||||
raise Forbidden("memory writes are refused in private mode")
|
||||
references_forgotten = entry.get("supersedes") in tombstoned(request.store) or source.get("id") in tombstoned(request.store)
|
||||
wants = body.get("approval_mode", "ask" if actor["type"] == "assistant" else "automatic")
|
||||
if not allowed or wants == "no_store":
|
||||
# Content-free decision record. The topic moves into the note because
|
||||
# rules.memory_policy_violations reads a deny topic on any non-rejected
|
||||
# status as a write, and a sensitive entry must say approval_mode=ask.
|
||||
why = why if not allowed else "declined"
|
||||
entry.pop("topic")
|
||||
entry.update(status="no_store", approval_mode="ask" if sensitivity == "sensitive" else "no_store", content="", retrievable=False,
|
||||
audit=[{"at": stamp, "action": "no_store", "actor": actor, "note": f"{why}; topic={topic}"[:200]}])
|
||||
elif sensitivity == "sensitive" or wants == "ask" or references_forgotten or actor["type"] != "user":
|
||||
entry["approval_mode"] = "ask"
|
||||
else:
|
||||
entry.update(status="active", approval_mode="automatic", retrievable=True)
|
||||
entry["audit"].append({"at": stamp, "action": "approved", "actor": actor, "note": "automatic"})
|
||||
return entry, why
|
||||
|
||||
|
||||
def post_memory(request: Request) -> Response:
|
||||
"""``POST /hux/v1/memory``: propose an entry; policy decides active, proposed (ask) or no_store (202)."""
|
||||
body = request.body if isinstance(request.body, dict) else None
|
||||
if body is None:
|
||||
raise Invalid("body must be an object")
|
||||
key = request.idempotency_key()
|
||||
if key:
|
||||
for row in request.store.read(IDEM_FAMILY, "keys"):
|
||||
if row["idempotency_key"] == key:
|
||||
request.audit("memory.propose", row["memory_id"], "allow", "replayed")
|
||||
return Response(200, load(request.store, row["memory_id"]), {"HUX-Replayed": "true"})
|
||||
entry, why = _shape(request, body)
|
||||
try:
|
||||
stored = _persist(request.store, entry, None)
|
||||
except Invalid:
|
||||
request.store.append(FAMILY, TOMBSTONES, {"memory_id": entry["id"], "at": now_iso(), "reason": "policy_violation", "purged": True})
|
||||
_emit(request.store, request.identity, entry, "memory.suppressed", "Memory proposal suppressed by policy")
|
||||
raise
|
||||
if key:
|
||||
request.store.append(IDEM_FAMILY, "keys", {"idempotency_key": key, "memory_id": stored["id"], "at": stored["created_at"]})
|
||||
if stored["status"] == "no_store":
|
||||
_tombstone(request.store, stored["id"], why)
|
||||
_emit(request.store, request.identity, stored, "memory.suppressed", f"Not remembered ({why})")
|
||||
request.audit("memory.propose", stored["id"], "allow", "no_store")
|
||||
return Response(202, stored)
|
||||
kind = "memory.committed" if stored["status"] == "active" else "memory.proposed"
|
||||
_emit(request.store, request.identity, stored, kind, "Remembered" if stored["status"] == "active" else "Proposed to remember")
|
||||
request.audit("memory.propose", stored["id"])
|
||||
return Response(201, stored)
|
||||
|
||||
|
||||
def list_memory(request: Request) -> Response:
|
||||
"""``GET /hux/v1/memory?status=&scope=``: owner-scoped list; ``scope`` is ``level`` or ``level:scope_id``."""
|
||||
status, scope = request.query.get("status"), request.query.get("scope")
|
||||
level, _, scope_id = (scope or "").partition(":")
|
||||
items = []
|
||||
for row in list(request.store.scan(FAMILY)):
|
||||
record = load(request.store, row["id"])
|
||||
if status and record["status"] != status:
|
||||
continue
|
||||
if level and (record["scope"]["level"] != level or (scope_id and record["scope"].get("scope_id") != scope_id)):
|
||||
continue
|
||||
items.append(record)
|
||||
request.audit("memory.list", "memory")
|
||||
return page(sorted(items, key=lambda r: r["updated_at"], reverse=True), None)
|
||||
|
||||
|
||||
def get_memory(request: Request) -> Response:
|
||||
"""``GET /hux/v1/memory/{id}``."""
|
||||
memory_id = request.params["id"]
|
||||
try:
|
||||
record = load(request.store, memory_id)
|
||||
except (NotFound, Invalid) as error:
|
||||
raise NotFound("memory entry not found") from error
|
||||
request.audit("memory.get", memory_id)
|
||||
return Response(200, record, {"ETag": str(record["revision"])})
|
||||
|
||||
|
||||
def _edit(request: Request, record: dict[str, Any], actor: dict[str, str], expected: int | None) -> dict[str, Any]:
|
||||
body = request.body if isinstance(request.body, dict) else {}
|
||||
content = body.get("content")
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise Invalid("edit needs new content")
|
||||
old = _transition(request.store, request.identity, record, "forgotten", "superseded", actor, "edited", expected)
|
||||
from hux import events
|
||||
|
||||
events.redact_memory_references(request.store, old["id"])
|
||||
stamp = now_iso()
|
||||
fresh = {
|
||||
**{k: v for k, v in record.items() if k not in {"revision", "supersedes"}}, "id": new_id("mem"), "content": redaction.scrub_value(content.strip(), [])[:2000],
|
||||
"status": "active", "approval_mode": "automatic", "retrievable": True, "supersedes": old["id"], "created_at": stamp, "updated_at": stamp,
|
||||
"audit": [{"at": stamp, "action": "edited", "actor": actor, "note": f"supersedes {old['id']}"}, {"at": stamp, "action": "approved", "actor": actor}],
|
||||
}
|
||||
if fresh["sensitivity"] == "sensitive":
|
||||
fresh.update(status="proposed", approval_mode="ask", retrievable=False)
|
||||
fresh["audit"] = fresh["audit"][:1]
|
||||
return _persist(request.store, fresh, None)
|
||||
|
||||
|
||||
def act_memory(request: Request) -> Response:
|
||||
"""``POST /hux/v1/memory/{id}/{action}``: approve, reject, forget, edit, remove_retrieval, restore_retrieval; every move emits a memory.* event."""
|
||||
memory_id, action = request.params["id"], request.params["action"]
|
||||
if action not in ACTIONS:
|
||||
raise NotFound("unknown memory action")
|
||||
if not _human(request.identity):
|
||||
raise Forbidden("memory decisions come from a human surface")
|
||||
try:
|
||||
record = load(request.store, memory_id)
|
||||
except (NotFound, Invalid) as error:
|
||||
raise NotFound("memory entry not found") from error
|
||||
expected = request.if_match()
|
||||
if expected is not None and expected != record["revision"]:
|
||||
raise Conflict(f"revision {expected} does not match current revision {record['revision']}", [str(record["revision"])])
|
||||
actor = {"type": "user", "id": request.identity.subject}
|
||||
store, identity = request.store, request.identity
|
||||
if action == "approve":
|
||||
stored = _transition(store, identity, record, "active", "approved", actor, expected=expected)
|
||||
_emit(store, identity, stored, "memory.committed", "Memory approved")
|
||||
elif action == "reject":
|
||||
stored = _transition(store, identity, record, "rejected", "rejected", actor, expected=expected)
|
||||
_emit(store, identity, stored, "memory.suppressed", "Memory rejected")
|
||||
elif action == "forget":
|
||||
stored = _transition(store, identity, record, "forgotten", "forgotten", actor, expected=expected)
|
||||
from hux import events
|
||||
|
||||
events.redact_memory_references(store, stored["id"])
|
||||
_emit(store, identity, stored, "memory.forgotten", "Memory forgotten")
|
||||
elif action == "edit":
|
||||
stored = _edit(request, record, actor, expected)
|
||||
_emit(store, identity, stored, "memory.committed" if stored["status"] == "active" else "memory.proposed", f"Memory edited, supersedes {record['id']}")
|
||||
else:
|
||||
if record["status"] != "active":
|
||||
raise Conflict("retrieval can only change on active entries")
|
||||
retrievable = action == "restore_retrieval"
|
||||
stamp = now_iso()
|
||||
stored = _persist(store, {**record, "retrievable": retrievable, "updated_at": stamp, "audit": [*record["audit"], {"at": stamp, "action": "retrieval_removed" if not retrievable else "approved", "actor": actor, "note": "retrieval restored" if retrievable else ""}]}, expected)
|
||||
_emit(store, identity, stored, "memory.retrieval_removed" if not retrievable else "memory.committed", "Retrieval removed" if not retrievable else "Retrieval restored")
|
||||
request.audit(f"memory.{action}", memory_id, "allow", "" if expected is not None else "unconditional_write")
|
||||
return Response(200, stored, {"ETag": str(stored["revision"])})
|
||||
|
||||
|
||||
def export_memory(request: Request) -> Response:
|
||||
"""``GET /hux/v1/memory/export``: active, retrievable entries only, each stamped ``exported`` (SO-26)."""
|
||||
stones = tombstoned(request.store)
|
||||
items = []
|
||||
for row in list(request.store.scan(FAMILY)):
|
||||
record = load(request.store, row["id"])
|
||||
if record["id"] in stones or record["status"] != "active" or not record["retrievable"]:
|
||||
continue
|
||||
stamp = now_iso()
|
||||
items.append(_persist(request.store, {**record, "audit": [*record["audit"], {"at": stamp, "action": "exported", "actor": {"type": "user", "id": request.identity.subject}}]}, record["revision"]))
|
||||
request.store.append(FAMILY, "exports", {"at": now_iso(), "ids": [item["id"] for item in items]})
|
||||
request.audit("memory.export", "memory")
|
||||
response = page(items, None)
|
||||
response.headers["Content-Disposition"] = 'attachment; filename="hux-memory-export.json"'
|
||||
return response
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-02 routes."""
|
||||
router.add("GET", "/hux/v1/memory/export", "HUX-02", "memory.export", export_memory)
|
||||
router.add("POST", "/hux/v1/memory", "HUX-02", "memory.propose", post_memory)
|
||||
router.add("GET", "/hux/v1/memory", "HUX-02", "memory.list", list_memory)
|
||||
router.add("GET", "/hux/v1/memory/{id}", "HUX-02", "memory.get", get_memory)
|
||||
router.add("POST", "/hux/v1/memory/{id}/{action}", "HUX-02", "memory.act", act_memory)
|
||||
297
dockerfiles/hermes-hux-foundation/hux/organization.py
Normal file
297
dockerfiles/hermes-hux-foundation/hux/organization.py
Normal file
@ -0,0 +1,297 @@
|
||||
"""HUX-03 organisation: projects, conversations, branch lineage and search.
|
||||
|
||||
Projects own conversations; conversations carry tags, pins, a mode and an
|
||||
optional branch pointer to the conversation they forked from. Search covers
|
||||
only the ``project.schema.json#/$defs/search_index`` fields this increment
|
||||
indexes (title, tags, project_name, and artifact titles when the artifacts
|
||||
lane is present); message text is not indexed here and the response says so.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from hux import contracts
|
||||
from hux.errors import Conflict, Invalid, NotFound
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.store import TenantStore, check_id, new_id, now_iso
|
||||
|
||||
CARD = "HUX-03"
|
||||
PROJECTS = "projects"
|
||||
CONVERSATIONS = "conversations"
|
||||
MAX_PROJECTS = 200
|
||||
MAX_CONVERSATIONS = 2000
|
||||
PROJECT_FIELDS = ("name", "description", "tags", "pinned", "archived", "default_mode")
|
||||
CONVERSATION_FIELDS = ("title", "tags", "pinned", "archived", "mode", "project_id")
|
||||
INDEXED = ("title", "tags", "project_name", "artifact_titles")
|
||||
NOT_INDEXED = ("message_text",)
|
||||
SCHEMAS = contracts.load_all()
|
||||
TOKEN_RE = re.compile(r"[a-z0-9]+")
|
||||
|
||||
|
||||
def project_exists(store: TenantStore, project_id: str) -> bool:
|
||||
"""True when this tenant owns a project with that id (helper for other lanes)."""
|
||||
return isinstance(project_id, str) and bool(re.match(r"^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$", project_id)) and store.exists(PROJECTS, project_id)
|
||||
|
||||
|
||||
def conversation_exists(store: TenantStore, conversation_id: str) -> bool:
|
||||
"""True when this tenant owns a conversation with that id (helper for other lanes)."""
|
||||
return isinstance(conversation_id, str) and bool(re.match(r"^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$", conversation_id)) and store.exists(CONVERSATIONS, conversation_id)
|
||||
|
||||
|
||||
def project_of(store: TenantStore, conversation_id: str | None) -> str | None:
|
||||
"""The project a conversation belongs to, or None when unknown or unfiled."""
|
||||
if not conversation_id or not conversation_exists(store, conversation_id):
|
||||
return None
|
||||
return store.get(CONVERSATIONS, conversation_id).get("project_id")
|
||||
|
||||
|
||||
def checked(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Raise Invalid unless ``record`` satisfies its contract."""
|
||||
problems = contracts.validate_record(record, SCHEMAS)
|
||||
if problems:
|
||||
raise Invalid("record fails contract", problems)
|
||||
return record
|
||||
|
||||
|
||||
def body_dict(request: Request) -> dict[str, Any]:
|
||||
"""The JSON object body or Invalid."""
|
||||
if not isinstance(request.body, dict):
|
||||
raise Invalid("body must be a JSON object")
|
||||
return request.body
|
||||
|
||||
|
||||
def pick(body: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
|
||||
"""Only the client-settable fields; ids, owner, timestamps and revision are server-set."""
|
||||
return {k: body[k] for k in fields if k in body}
|
||||
|
||||
|
||||
def replay(store: TenantStore, family: str, key: str) -> dict[str, Any] | None:
|
||||
"""The record an Idempotency-Key already created in this family, if any."""
|
||||
for row in store.read(family, "idempotency"):
|
||||
if row["key"] == key:
|
||||
return store.get(family, row["id"])
|
||||
return None
|
||||
|
||||
|
||||
def create(request: Request, family: str, record: dict[str, Any], key: str, cap: int) -> tuple[dict[str, Any], int]:
|
||||
"""Create under the family lock honouring Idempotency-Key and the family count cap."""
|
||||
with request.store.lock(family):
|
||||
if key:
|
||||
existing = replay(request.store, family, key)
|
||||
if existing is not None:
|
||||
return existing, 200
|
||||
if request.store.count(family) >= cap:
|
||||
raise Conflict(f"{family} cap of {cap} reached")
|
||||
stored = request.store.put(family, checked({**record, "revision": 1}))
|
||||
if key:
|
||||
request.store.append(family, "idempotency", {"key": key, "id": stored["id"]})
|
||||
return stored, 201
|
||||
|
||||
|
||||
def update(request: Request, family: str, fields: tuple[str, ...]) -> dict[str, Any]:
|
||||
"""PATCH under If-Match; a missing If-Match is accepted but audited as unconditional (SO-44)."""
|
||||
changes = pick(body_dict(request), fields)
|
||||
expected = request.if_match()
|
||||
with request.store.lock(family):
|
||||
current = request.store.get(family, check_id(request.params["id"]))
|
||||
if "project_id" in changes and changes["project_id"] is not None and not project_exists(request.store, changes["project_id"]):
|
||||
raise NotFound("project not found")
|
||||
record = {**current, **{k: v for k, v in changes.items() if v is not None}, "updated_at": now_iso()}
|
||||
if changes.get("project_id", "") is None:
|
||||
record.pop("project_id", None)
|
||||
stored = request.store.put(family, checked(record), expected_revision=expected)
|
||||
request.audit(f"{family}.update", stored["id"], reason="" if expected is not None else "unconditional_write")
|
||||
return stored
|
||||
|
||||
|
||||
def etag(record: dict[str, Any]) -> dict[str, str]:
|
||||
"""Revision as ETag so clients can send it back in If-Match."""
|
||||
return {"ETag": str(record["revision"])}
|
||||
|
||||
|
||||
# -- projects ------------------------------------------------------------------
|
||||
|
||||
def create_project(request: Request) -> Response:
|
||||
"""``POST /hux/v1/projects``."""
|
||||
body = pick(body_dict(request), PROJECT_FIELDS)
|
||||
stamp = now_iso()
|
||||
record = {
|
||||
"schema": "hux.project.v1", "id": new_id("prj"), "owner": request.identity.subject,
|
||||
"tags": [], "pinned": False, "archived": False, **body, "created_at": stamp, "updated_at": stamp,
|
||||
}
|
||||
stored, status = create(request, PROJECTS, record, request.idempotency_key(), MAX_PROJECTS)
|
||||
request.audit("projects.create", stored["id"], reason="replayed" if status == 200 else "")
|
||||
return Response(status, stored, etag(stored))
|
||||
|
||||
|
||||
def list_projects(request: Request) -> Response:
|
||||
"""``GET /hux/v1/projects?archived=``: pinned first, then most recently updated."""
|
||||
archived = request.query.get("archived")
|
||||
items = [p for p in request.store.scan(PROJECTS) if archived is None or p["archived"] == (archived == "true")]
|
||||
items.sort(key=lambda p: p["updated_at"], reverse=True)
|
||||
items.sort(key=lambda p: not p["pinned"])
|
||||
request.audit("projects.list", "projects")
|
||||
return page(items)
|
||||
|
||||
|
||||
def get_project(request: Request) -> Response:
|
||||
"""``GET /hux/v1/projects/{id}``."""
|
||||
record = request.store.get(PROJECTS, check_id(request.params["id"]))
|
||||
request.audit("projects.read", record["id"])
|
||||
return Response(200, record, etag(record))
|
||||
|
||||
|
||||
def patch_project(request: Request) -> Response:
|
||||
"""``PATCH /hux/v1/projects/{id}`` with If-Match."""
|
||||
stored = update(request, PROJECTS, PROJECT_FIELDS)
|
||||
return Response(200, stored, etag(stored))
|
||||
|
||||
|
||||
# -- conversations -------------------------------------------------------------
|
||||
|
||||
def new_conversation(request: Request, body: dict[str, Any], extra: dict[str, Any] | None = None) -> Response:
|
||||
"""Build, validate and store a conversation from client fields plus server-set extras."""
|
||||
if body.get("project_id") is not None and not project_exists(request.store, body["project_id"]):
|
||||
raise NotFound("project not found")
|
||||
stamp = now_iso()
|
||||
record = {
|
||||
"schema": "hux.conversation.v1", "id": new_id("conv"), "owner": request.identity.subject,
|
||||
"tags": [], "pinned": False, "archived": False, **{k: v for k, v in body.items() if v is not None},
|
||||
**(extra or {}), "artifact_ids": [], "created_at": stamp, "updated_at": stamp,
|
||||
}
|
||||
stored, status = create(request, CONVERSATIONS, record, request.idempotency_key(), MAX_CONVERSATIONS)
|
||||
request.audit("conversations.create", stored["id"], reason="replayed" if status == 200 else "")
|
||||
return Response(status, stored, etag(stored))
|
||||
|
||||
|
||||
def create_conversation(request: Request) -> Response:
|
||||
"""``POST /hux/v1/conversations``."""
|
||||
return new_conversation(request, pick(body_dict(request), CONVERSATION_FIELDS))
|
||||
|
||||
|
||||
def list_conversations(request: Request) -> Response:
|
||||
"""``GET /hux/v1/conversations?project_id=&tag=&pinned=&archived=``: newest activity first."""
|
||||
q = request.query
|
||||
flags = {k: q[k] == "true" for k in ("pinned", "archived") if k in q}
|
||||
items = []
|
||||
for index, record in enumerate(request.store.scan(CONVERSATIONS)):
|
||||
if "project_id" in q and record.get("project_id") != q["project_id"]:
|
||||
continue
|
||||
if "tag" in q and q["tag"] not in record["tags"]:
|
||||
continue
|
||||
if any(record[k] != v for k, v in flags.items()):
|
||||
continue
|
||||
items.append((record.get("last_message_at", record["updated_at"]), index, record))
|
||||
items.sort(key=lambda item: item[:2], reverse=True)
|
||||
request.audit("conversations.list", "conversations")
|
||||
return page([record for _, _, record in items])
|
||||
|
||||
|
||||
def get_conversation(request: Request) -> Response:
|
||||
"""``GET /hux/v1/conversations/{id}``."""
|
||||
record = request.store.get(CONVERSATIONS, check_id(request.params["id"]))
|
||||
request.audit("conversations.read", record["id"])
|
||||
return Response(200, record, etag(record))
|
||||
|
||||
|
||||
def patch_conversation(request: Request) -> Response:
|
||||
"""``PATCH /hux/v1/conversations/{id}`` with If-Match."""
|
||||
stored = update(request, CONVERSATIONS, CONVERSATION_FIELDS)
|
||||
return Response(200, stored, etag(stored))
|
||||
|
||||
|
||||
def branch_conversation(request: Request) -> Response:
|
||||
"""``POST /hux/v1/conversations/{id}/branch``: fork at a message, keeping project, tags and mode."""
|
||||
body = body_dict(request)
|
||||
point = body.get("branch_point_message_id")
|
||||
if not isinstance(point, str) or not 1 <= len(point) <= 120:
|
||||
raise Invalid("branch_point_message_id required")
|
||||
parent = request.store.get(CONVERSATIONS, check_id(request.params["id"]))
|
||||
fields = {"title": body.get("title") or f"{parent['title']} (branch)"[:200], "tags": list(parent["tags"]),
|
||||
"project_id": parent.get("project_id"), "mode": parent.get("mode")}
|
||||
return new_conversation(request, fields, {"branch": {"parent_conversation_id": parent["id"], "branch_point_message_id": point}})
|
||||
|
||||
|
||||
def lineage(request: Request) -> Response:
|
||||
"""``GET /hux/v1/conversations/{id}/lineage``: ancestors root-first plus direct children."""
|
||||
record = request.store.get(CONVERSATIONS, check_id(request.params["id"]))
|
||||
ancestors: list[dict[str, Any]] = []
|
||||
cursor, seen = record, {record["id"]}
|
||||
while "branch" in cursor and len(ancestors) < 64:
|
||||
parent_id = cursor["branch"]["parent_conversation_id"]
|
||||
if parent_id in seen or not request.store.exists(CONVERSATIONS, parent_id):
|
||||
break
|
||||
cursor = request.store.get(CONVERSATIONS, parent_id)
|
||||
seen.add(parent_id)
|
||||
ancestors.insert(0, cursor)
|
||||
children = [c for c in request.store.scan(CONVERSATIONS) if c.get("branch", {}).get("parent_conversation_id") == record["id"]]
|
||||
request.audit("conversations.lineage", record["id"])
|
||||
return Response(200, {"conversation": record, "ancestors": ancestors, "children": children})
|
||||
|
||||
|
||||
# -- search --------------------------------------------------------------------
|
||||
|
||||
def artifact_titles(store: TenantStore, conversation: dict[str, Any]) -> list[str]:
|
||||
"""Artifact titles for a conversation via the artifacts lane, or nothing when it is absent."""
|
||||
try:
|
||||
from hux import artifacts
|
||||
except ModuleNotFoundError:
|
||||
return []
|
||||
helper = getattr(artifacts, "artifact_titles", None)
|
||||
return list(helper(store, conversation["id"])) if helper else []
|
||||
|
||||
|
||||
def tokens(text: str) -> list[str]:
|
||||
"""Lowercased alphanumeric terms."""
|
||||
return TOKEN_RE.findall(text.lower())
|
||||
|
||||
|
||||
def score(record: dict[str, Any], terms: list[str], project_name: str, titles: list[str]) -> int:
|
||||
"""Title hit beats tag hit beats project/artifact hit; every term must match somewhere."""
|
||||
fields = {"title": tokens(record["title"]), "tags": [t for tag in record["tags"] for t in tokens(tag)],
|
||||
"project_name": tokens(project_name), "artifact_titles": [t for title in titles for t in tokens(title)]}
|
||||
weight = {"title": 4, "tags": 3, "project_name": 2, "artifact_titles": 1}
|
||||
total = 0
|
||||
for term in terms:
|
||||
hit = sum(weight[f] * words.count(term) for f, words in fields.items())
|
||||
if not hit:
|
||||
return 0
|
||||
total += hit
|
||||
return total
|
||||
|
||||
|
||||
def search(request: Request) -> Response:
|
||||
"""``GET /hux/v1/search?q=&project_id=``: token match over the indexed fields, best first."""
|
||||
terms = tokens(request.query.get("q", ""))
|
||||
if not terms:
|
||||
raise Invalid("q is required")
|
||||
project_filter = request.query.get("project_id")
|
||||
names = {p["id"]: p["name"] for p in request.store.scan(PROJECTS)}
|
||||
ranked = []
|
||||
for record in request.store.scan(CONVERSATIONS):
|
||||
if project_filter and record.get("project_id") != project_filter:
|
||||
continue
|
||||
points = score(record, terms, names.get(record.get("project_id", ""), ""), artifact_titles(request.store, record))
|
||||
if points:
|
||||
ranked.append((points, record))
|
||||
ranked.sort(key=lambda pair: (-pair[0], pair[1]["updated_at"]))
|
||||
request.audit("search.query", "conversations")
|
||||
return Response(200, {"items": [r for _, r in ranked], "next": None, "scores": {r["id"]: s for s, r in ranked},
|
||||
"indexed": list(INDEXED), "not_indexed": list(NOT_INDEXED)})
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-03 routes."""
|
||||
router.add("POST", "/hux/v1/projects", CARD, "projects.create", create_project)
|
||||
router.add("GET", "/hux/v1/projects", CARD, "projects.list", list_projects)
|
||||
router.add("GET", "/hux/v1/projects/{id}", CARD, "projects.read", get_project)
|
||||
router.add("PATCH", "/hux/v1/projects/{id}", CARD, "projects.update", patch_project)
|
||||
router.add("POST", "/hux/v1/conversations", CARD, "conversations.create", create_conversation)
|
||||
router.add("GET", "/hux/v1/conversations", CARD, "conversations.list", list_conversations)
|
||||
router.add("GET", "/hux/v1/conversations/{id}", CARD, "conversations.read", get_conversation)
|
||||
router.add("PATCH", "/hux/v1/conversations/{id}", CARD, "conversations.update", patch_conversation)
|
||||
router.add("POST", "/hux/v1/conversations/{id}/branch", CARD, "conversations.branch", branch_conversation)
|
||||
router.add("GET", "/hux/v1/conversations/{id}/lineage", CARD, "conversations.lineage", lineage)
|
||||
router.add("GET", "/hux/v1/search", CARD, "search.query", search)
|
||||
354
dockerfiles/hermes-hux-foundation/hux/policy.py
Normal file
354
dockerfiles/hermes-hux-foundation/hux/policy.py
Normal file
@ -0,0 +1,354 @@
|
||||
"""HUX-05 autonomy: policy documents per scope and the approval queue.
|
||||
|
||||
A policy fixes the autonomy level, explicit grants and budgets for a scope
|
||||
(global, project or conversation). ``rules.effective_decision`` is the only
|
||||
resolver: an approval request resolves to allow, ask or deny against the most
|
||||
specific policy, and every external side effect asks regardless. Budgets,
|
||||
the pre-side-effect gate and cancellation receipts live in ``hux.budgets``;
|
||||
this module registers their routes so the family stays one card.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from hux import contracts, rules
|
||||
from hux.errors import BudgetExhausted, Conflict, Forbidden, Invalid
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, check_id, new_id
|
||||
|
||||
CARD = "HUX-05"
|
||||
FAMILY = "policy"
|
||||
APPROVALS = "approvals"
|
||||
SCOPES = ("global", "project", "conversation")
|
||||
APPROVAL_TTL = timedelta(hours=24)
|
||||
ALWAYS_TTL = timedelta(days=30)
|
||||
SESSION_TTL = timedelta(hours=24)
|
||||
HUMAN_SURFACES = frozenset({"chat", "telegram", "voice"})
|
||||
HUMAN_TRUSTS = frozenset({"router", "relay"})
|
||||
DEFAULT_BUDGETS = {
|
||||
"tokens_per_run": 200000, "tool_calls_per_run": 40, "wall_clock_seconds": 900,
|
||||
"delegations_per_run": 4, "spend_units": 50, "subagents_per_run": 2,
|
||||
}
|
||||
SCHEMAS = contracts.load_all()
|
||||
|
||||
|
||||
def clock() -> datetime:
|
||||
"""Current UTC time; tests replace this to move approvals past expiry."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
"""Indirection so monkeypatching ``clock`` reaches every module."""
|
||||
return clock()
|
||||
|
||||
|
||||
def iso(when: datetime) -> str:
|
||||
"""RFC 3339 second-precision UTC string."""
|
||||
return when.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def parse(stamp: str) -> datetime:
|
||||
"""Inverse of ``iso``."""
|
||||
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def run_key(run_id: str) -> str:
|
||||
"""Stable hex handle for a free-form run id so it can name a document."""
|
||||
return hashlib.sha256(run_id.encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
def actor_for(identity: Identity) -> dict[str, str]:
|
||||
"""The actor a record attributes to this caller: humans are users, hops are system."""
|
||||
if identity.trust in HUMAN_TRUSTS and identity.surface in HUMAN_SURFACES:
|
||||
return {"type": "user", "id": identity.subject}
|
||||
return {"type": "system", "id": identity.surface}
|
||||
|
||||
|
||||
def provenance(identity: Identity) -> dict[str, Any]:
|
||||
"""Server-set provenance; nothing here comes from the body."""
|
||||
return {"surface": identity.surface, "actor": actor_for(identity), "recorded_at": iso(now())}
|
||||
|
||||
|
||||
def public(record: dict[str, Any], revisioned: bool = False) -> dict[str, Any]:
|
||||
"""Strip store-internal fields before a record leaves the service."""
|
||||
return {k: v for k, v in record.items() if not k.startswith("_") and (revisioned or k != "revision")}
|
||||
|
||||
|
||||
def emit(store: TenantStore, identity: Identity, conversation_id: str | None, kind: str, summary: str, **extra: Any) -> None:
|
||||
"""Record an activity event through the events lane when it is present."""
|
||||
if not conversation_id:
|
||||
return
|
||||
try:
|
||||
from hux import events
|
||||
except ModuleNotFoundError:
|
||||
return
|
||||
events.emit(store, identity, conversation_id, kind, summary, **extra)
|
||||
|
||||
|
||||
def checked(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Raise Invalid unless ``record`` satisfies its contract; internal fields are ignored."""
|
||||
if record["schema"] == "hux.policy.v1":
|
||||
candidate = public({"revision": 1, **record}, revisioned=True)
|
||||
else:
|
||||
candidate = public(record)
|
||||
problems = contracts.validate_record(candidate, SCHEMAS)
|
||||
if problems:
|
||||
raise Invalid("record fails contract", problems)
|
||||
return record
|
||||
|
||||
|
||||
def body_dict(request: Request) -> dict[str, Any]:
|
||||
"""The JSON object body or Invalid."""
|
||||
if not isinstance(request.body, dict):
|
||||
raise Invalid("body must be a JSON object")
|
||||
return request.body
|
||||
|
||||
|
||||
# -- policy documents --------------------------------------------------------
|
||||
|
||||
def policy_id(level: str, scope_id: str | None) -> str:
|
||||
"""Document id for a scope; global has exactly one."""
|
||||
if level not in SCOPES:
|
||||
raise Invalid("scope must be global, project or conversation")
|
||||
if level == "global":
|
||||
return "pol_global"
|
||||
if scope_id is None or len(scope_id) > 60:
|
||||
raise Invalid("scope_id required and at most 60 characters for project and conversation scopes")
|
||||
return f"pol_{level}.{check_id(scope_id)}"
|
||||
|
||||
|
||||
def default_policy(identity: Identity) -> dict[str, Any]:
|
||||
"""The ``safe`` policy every tenant starts with."""
|
||||
return {
|
||||
"schema": "hux.policy.v1", "id": "pol_global", "owner": identity.subject, "scope": {"level": "global"},
|
||||
"autonomy": "safe", "grants": [], "budgets": dict(DEFAULT_BUDGETS),
|
||||
"provenance": provenance(identity), "updated_at": iso(now()),
|
||||
}
|
||||
|
||||
|
||||
def global_policy(store: TenantStore, identity: Identity) -> dict[str, Any]:
|
||||
"""Read the global policy, creating the default on first touch."""
|
||||
with store.lock(FAMILY):
|
||||
if store.exists(FAMILY, "pol_global"):
|
||||
return store.get(FAMILY, "pol_global")
|
||||
return store.put(FAMILY, checked({**default_policy(identity), "revision": 1}))
|
||||
|
||||
|
||||
def effective_policy(store: TenantStore, identity: Identity, level: str, scope_id: str | None) -> dict[str, Any]:
|
||||
"""Most specific policy along conversation -> project -> global."""
|
||||
chain: list[tuple[str, str | None]] = [(level, scope_id)]
|
||||
if level == "conversation":
|
||||
from hux import organization # lazy: organization never imports policy, but keep import order free
|
||||
parent = organization.project_of(store, scope_id)
|
||||
if parent:
|
||||
chain.append(("project", parent))
|
||||
for lvl, sid in chain:
|
||||
if lvl != "global" and store.exists(FAMILY, policy_id(lvl, sid)):
|
||||
return store.get(FAMILY, policy_id(lvl, sid))
|
||||
return global_policy(store, identity)
|
||||
|
||||
|
||||
def normalise_grants(grants: Any, identity: Identity) -> list[dict[str, Any]]:
|
||||
"""Validate grants and pin every one to a server-set expiry of at most 30 days (SO-38)."""
|
||||
if not isinstance(grants, list):
|
||||
raise Invalid("grants must be a list")
|
||||
ceiling = now() + ALWAYS_TTL
|
||||
out = []
|
||||
for grant in grants:
|
||||
if not isinstance(grant, dict) or grant.get("capability") not in rules.CAPABILITIES:
|
||||
raise Invalid("grant needs a known capability")
|
||||
if grant.get("decision") not in ("allow", "ask", "deny"):
|
||||
raise Invalid("grant decision must be allow, ask or deny")
|
||||
expires = grant.get("expires_at")
|
||||
if not isinstance(expires, str) or parse(expires) > ceiling:
|
||||
expires = iso(ceiling)
|
||||
out.append({"capability": grant["capability"], "decision": grant["decision"], "expires_at": expires, "granted_by": actor_for(identity)})
|
||||
return out
|
||||
|
||||
|
||||
def get_policy(request: Request) -> Response:
|
||||
"""``GET /hux/v1/policy?scope=&scope_id=``: the effective policy for a scope."""
|
||||
level = request.query.get("scope", "global")
|
||||
scope_id = request.query.get("scope_id")
|
||||
policy_id(level, scope_id)
|
||||
record = effective_policy(request.store, request.identity, level, scope_id)
|
||||
request.audit("policy.read", record["id"])
|
||||
return Response(200, public(record, revisioned=True), {"ETag": str(record["revision"])})
|
||||
|
||||
|
||||
def put_policy(request: Request) -> Response:
|
||||
"""``PUT /hux/v1/policy``: replace the policy for the scope named in the body."""
|
||||
body = body_dict(request)
|
||||
scope = body.get("scope") or {}
|
||||
if not isinstance(scope, dict):
|
||||
raise Invalid("scope must be an object")
|
||||
record_id = policy_id(scope.get("level", ""), scope.get("scope_id"))
|
||||
if body.get("autonomy") not in ("ask_first", "safe", "autonomous"):
|
||||
raise Invalid("autonomy must be ask_first, safe or autonomous")
|
||||
budgets = body.get("budgets", dict(DEFAULT_BUDGETS))
|
||||
record = {
|
||||
"schema": "hux.policy.v1", "id": record_id, "owner": request.identity.subject, "scope": scope,
|
||||
"autonomy": body["autonomy"], "grants": normalise_grants(body.get("grants", []), request.identity),
|
||||
"budgets": budgets, "provenance": provenance(request.identity), "updated_at": iso(now()),
|
||||
}
|
||||
expected = request.if_match()
|
||||
with request.store.lock(FAMILY):
|
||||
exists = request.store.exists(FAMILY, record_id)
|
||||
stored = request.store.put(FAMILY, checked(record), expected_revision=expected)
|
||||
reason = "unconditional_write" if exists and expected is None else ""
|
||||
request.audit("policy.write", record_id, reason=reason)
|
||||
return Response(200, public(stored, revisioned=True), {"ETag": str(stored["revision"])})
|
||||
|
||||
|
||||
def add_grant(store: TenantStore, identity: Identity, level: str, scope_id: str | None, capability: str, ttl: timedelta) -> None:
|
||||
"""Append an allow grant to a scope's policy after a session/always decision."""
|
||||
record_id = policy_id(level, scope_id)
|
||||
with store.lock(FAMILY):
|
||||
if store.exists(FAMILY, record_id):
|
||||
record = store.get(FAMILY, record_id)
|
||||
else:
|
||||
scope = {"level": level, **({"scope_id": scope_id} if scope_id else {})}
|
||||
record = {**global_policy(store, identity), "id": record_id, "scope": scope}
|
||||
record.pop("revision")
|
||||
expected = record.pop("revision", None)
|
||||
grants = [g for g in record["grants"] if g["capability"] != capability]
|
||||
grants.append({"capability": capability, "decision": "allow", "expires_at": iso(now() + min(ttl, ALWAYS_TTL)), "granted_by": actor_for(identity)})
|
||||
record = {**record, "grants": grants[-64:], "provenance": provenance(identity), "updated_at": iso(now())}
|
||||
store.put(FAMILY, checked(record), expected_revision=expected)
|
||||
|
||||
|
||||
# -- approvals -----------------------------------------------------------------
|
||||
|
||||
def refresh(store: TenantStore, record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Expire a pending approval whose window has closed (SO-42); terminal records never change."""
|
||||
if record["status"] == "pending" and parse(record["expires_at"]) <= now():
|
||||
with store.lock(APPROVALS):
|
||||
record = store.put(APPROVALS, {**record, "status": "expired"})
|
||||
return record
|
||||
|
||||
|
||||
def load_approval(store: TenantStore, approval_id: str) -> dict[str, Any]:
|
||||
"""Read one approval for this tenant; unknown ids are 404 whoever owns them."""
|
||||
return refresh(store, store.get(APPROVALS, check_id(approval_id)))
|
||||
|
||||
|
||||
def replay(store: TenantStore, key: str) -> dict[str, Any] | None:
|
||||
"""The approval an Idempotency-Key already created, if any."""
|
||||
for row in store.read(APPROVALS, "idempotency"):
|
||||
if row["key"] == key:
|
||||
return load_approval(store, row["id"])
|
||||
return None
|
||||
|
||||
|
||||
def resolve_request(policy: dict[str, Any], capability: str, external: bool) -> str:
|
||||
"""Effective decision for a request; external side effects never auto-allow (SO-39)."""
|
||||
decision = rules.effective_decision(policy, capability, now())
|
||||
if external and decision == "allow":
|
||||
return "ask"
|
||||
return decision
|
||||
|
||||
|
||||
def create_approval(request: Request) -> Response:
|
||||
"""``POST /hux/v1/approvals``: the agent hook asks before a gated action."""
|
||||
body = body_dict(request)
|
||||
key = request.idempotency_key()
|
||||
if key:
|
||||
existing = replay(request.store, key)
|
||||
if existing is not None:
|
||||
request.audit("approvals.create", existing["id"], reason="replayed")
|
||||
return Response(200, public(existing))
|
||||
conversation_id = check_id(body.get("conversation_id"))
|
||||
capability = body.get("capability")
|
||||
if capability not in rules.CAPABILITIES:
|
||||
raise Invalid("unknown capability")
|
||||
from hux import budgets # lazy: budgets imports this module
|
||||
state = budgets.budget_state(request.store, request.identity, str(body.get("run_id", "")), conversation_id)
|
||||
if state["exhausted"]:
|
||||
emit(request.store, request.identity, conversation_id, "budget.exhausted", f"Budget exhausted: {', '.join(state['exhausted'])}", run_id=state["run_id"])
|
||||
raise BudgetExhausted("run budget exhausted", state["exhausted"])
|
||||
policy = effective_policy(request.store, request.identity, "conversation", conversation_id)
|
||||
req = body.get("request") if isinstance(body.get("request"), dict) else {}
|
||||
external = bool(req.get("external", False))
|
||||
decision = resolve_request(policy, capability, external)
|
||||
stamp = now()
|
||||
record: dict[str, Any] = {
|
||||
"schema": "hux.approval.v1", "id": new_id("apr"), "run_id": body.get("run_id"), "conversation_id": conversation_id,
|
||||
"capability": capability, "request": {**req, "external": external},
|
||||
"status": {"allow": "approved", "deny": "denied", "ask": "pending"}[decision],
|
||||
"requested_at": iso(stamp), "expires_at": iso(stamp + APPROVAL_TTL),
|
||||
}
|
||||
if decision != "ask":
|
||||
record["decision"] = {"choice": "once" if decision == "allow" else "deny", "by": {"type": "system", "id": "policy"}, "at": iso(stamp)}
|
||||
if key:
|
||||
record["idempotency_key"] = key
|
||||
with request.store.lock(APPROVALS):
|
||||
stored = request.store.put(APPROVALS, checked(record))
|
||||
if key:
|
||||
request.store.append(APPROVALS, "idempotency", {"key": key, "id": stored["id"]})
|
||||
request.audit("approvals.create", stored["id"], reason=f"policy_{decision}")
|
||||
kind = "approval.requested" if decision == "ask" else "approval.resolved"
|
||||
emit(request.store, request.identity, conversation_id, kind, f"{capability}: {record['request'].get('summary', '')}"[:280],
|
||||
run_id=record["run_id"], evidence=[{"kind": "approval", "id": stored["id"]}])
|
||||
return Response(201, public(stored))
|
||||
|
||||
|
||||
def list_approvals(request: Request) -> Response:
|
||||
"""``GET /hux/v1/approvals?status=``: the queue, oldest request first."""
|
||||
wanted = request.query.get("status")
|
||||
if wanted and wanted not in rules.APPROVAL_TRANSITIONS:
|
||||
raise Invalid("unknown status")
|
||||
items = [refresh(request.store, r) for r in request.store.scan(APPROVALS)]
|
||||
items = [public(r) for r in items if not wanted or r["status"] == wanted]
|
||||
items.sort(key=lambda r: (r["requested_at"], r["id"]))
|
||||
request.audit("approvals.list", wanted or "all")
|
||||
return page(items)
|
||||
|
||||
|
||||
def decide_approval(request: Request) -> Response:
|
||||
"""``POST /hux/v1/approvals/{id}``: a human answers once, session, always or deny (SO-35)."""
|
||||
identity = request.identity
|
||||
if identity.trust not in HUMAN_TRUSTS or identity.surface not in HUMAN_SURFACES:
|
||||
raise Forbidden("approvals are decided only from a human surface")
|
||||
choice = body_dict(request).get("choice")
|
||||
if choice not in ("once", "session", "always", "deny"):
|
||||
raise Invalid("choice must be once, session, always or deny")
|
||||
target = "denied" if choice == "deny" else "approved"
|
||||
with request.store.lock(APPROVALS):
|
||||
record = load_approval(request.store, request.params["id"])
|
||||
if not rules.transition_allowed(rules.APPROVAL_TRANSITIONS, record["status"], target):
|
||||
raise Conflict(f"approval is {record['status']}; only pending approvals can be decided")
|
||||
record = {**record, "status": target, "decision": {"choice": choice, "by": actor_for(identity), "at": iso(now())}}
|
||||
stored = request.store.put(APPROVALS, checked(record))
|
||||
if choice == "session":
|
||||
add_grant(request.store, identity, "conversation", record["conversation_id"], record["capability"], SESSION_TTL)
|
||||
elif choice == "always":
|
||||
add_grant(request.store, identity, "global", None, record["capability"], ALWAYS_TTL)
|
||||
request.audit("approvals.decide", stored["id"], reason=choice)
|
||||
emit(request.store, identity, record["conversation_id"], "approval.resolved", f"{record['capability']} {target} ({choice})",
|
||||
run_id=record["run_id"], evidence=[{"kind": "approval", "id": stored["id"]}])
|
||||
return Response(200, public(stored))
|
||||
|
||||
|
||||
def get_approval(request: Request) -> Response:
|
||||
"""``GET /hux/v1/approvals/{id}``: one record."""
|
||||
record = load_approval(request.store, request.params["id"])
|
||||
request.audit("approvals.read", record["id"])
|
||||
return Response(200, public(record))
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-05 routes, including the budget, gate and stop routes from ``hux.budgets``."""
|
||||
from hux import budgets
|
||||
|
||||
router.add("GET", "/hux/v1/policy", CARD, "policy.read", get_policy)
|
||||
router.add("PUT", "/hux/v1/policy", CARD, "policy.write", put_policy)
|
||||
router.add("POST", "/hux/v1/approvals", CARD, "approvals.create", create_approval)
|
||||
router.add("GET", "/hux/v1/approvals", CARD, "approvals.list", list_approvals)
|
||||
router.add("GET", "/hux/v1/approvals/{id}", CARD, "approvals.read", get_approval)
|
||||
router.add("POST", "/hux/v1/approvals/{id}", CARD, "approvals.decide", decide_approval)
|
||||
budgets.register_routes(router)
|
||||
|
||||
260
dockerfiles/hermes-hux-foundation/hux/privacy.py
Normal file
260
dockerfiles/hermes-hux-foundation/hux/privacy.py
Normal file
@ -0,0 +1,260 @@
|
||||
"""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, 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}
|
||||
store.append(FAMILY, "forgotten", {"conv_id": conversation_id, "requested_at": now_iso(), "purged_at": "", "counts": counts})
|
||||
return {"conversation_id": conversation_id, "forgotten": True, **counts}
|
||||
|
||||
|
||||
# -- 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)
|
||||
events.emit(request.store, request.identity, conversation_id, "privacy.notice", text, notice, sensitivity=rules.PRIVACY_TOPICS[topic]["sensitivity"])
|
||||
request.audit("privacy.notice", conversation_id)
|
||||
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 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)
|
||||
225
dockerfiles/hermes-hux-foundation/hux/redaction.py
Normal file
225
dockerfiles/hermes-hux-foundation/hux/redaction.py
Normal file
@ -0,0 +1,225 @@
|
||||
"""Redaction pipeline shared by every HUX family (SO-11..SO-14, SO-19).
|
||||
|
||||
Order matters and is fixed here: drop detail keys outside the per-kind
|
||||
allowlist, scrub secret patterns from every remaining string, cap sizes,
|
||||
then derive ``redaction.level``. Serve-time redaction (``redact_record``) is
|
||||
applied again on the way out so a reader that ignores the level still never
|
||||
sees more than the surface is allowed to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
DETAIL_CAP_BYTES = 32 * 1024
|
||||
LINE_CAP_BYTES = 64 * 1024
|
||||
SUMMARY_MAX = 280
|
||||
URI_SCHEMES = ("https://", "hux://", "artifact://")
|
||||
WORKSPACE_PREFIX = "/opt/data/workspace"
|
||||
HASH_KEYS = frozenset({"hash", "argument_hash"})
|
||||
HUMAN_SURFACES = frozenset({"telegram", "voice"})
|
||||
FULL_KEEP = frozenset({"schema", "id", "seq", "ts", "conversation_id", "kind", "provenance", "sensitivity", "redaction", "turn", "identity"})
|
||||
|
||||
# Each pattern is (class, regex). Classes end up in the placeholder so a
|
||||
# reviewer can tell what kind of thing was removed without seeing it.
|
||||
SECRET_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||
("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)")),
|
||||
("bearer", re.compile(r"(?i)bearer\s+[A-Za-z0-9._-]{16,}")),
|
||||
("jwt", re.compile(r"eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}(?:\.[A-Za-z0-9_-]+)?")),
|
||||
("openai_key", re.compile(r"sk-[A-Za-z0-9_-]{20,}")),
|
||||
("github_token", re.compile(r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}")),
|
||||
("gitlab_token", re.compile(r"glpat-[A-Za-z0-9_-]{20,}")),
|
||||
("aws_key", re.compile(r"AKIA[0-9A-Z]{16}")),
|
||||
("slack_token", re.compile(r"xox[abp]-[A-Za-z0-9-]{10,}")),
|
||||
("vault_token", re.compile(r"\b(?:hvs|hvb|hvr)\.[A-Za-z0-9_-]{20,}")),
|
||||
("password", re.compile(r"(?i)(?:password|passwd|pwd|secret|token)\s*[=:]\s*\S+")),
|
||||
("hex", re.compile(r"\b[0-9a-fA-F]{32,}\b")),
|
||||
("base64", re.compile(r"\b(?=[A-Za-z0-9+/]*[0-9])(?=[A-Za-z0-9+/]*[a-z])(?=[A-Za-z0-9+/]*[A-Z])[A-Za-z0-9+/]{40,}={0,2}")),
|
||||
)
|
||||
|
||||
# Detail keys each event kind may persist. Everything else is dropped before
|
||||
# a byte reaches disk, never filtered on read only.
|
||||
DETAIL_ALLOWLIST: dict[str, frozenset[str]] = {
|
||||
"message.user": frozenset({"message_id", "chars", "has_attachments"}),
|
||||
"message.assistant": frozenset({"message_id", "chars", "has_attachments"}),
|
||||
"decision.route": frozenset({"requested", "resolved_target", "provider", "effort", "reason"}),
|
||||
"decision.plan": frozenset({"steps"}),
|
||||
"tool.call": frozenset({"tool", "capability", "argument_names", "argument_hash", "argument_bytes", "target_path"}),
|
||||
"tool.result": frozenset({"tool", "ok", "duration_ms", "bytes", "exit_code"}),
|
||||
"approval.requested": frozenset({"approval_id", "capability", "choice", "external"}),
|
||||
"approval.resolved": frozenset({"approval_id", "capability", "choice", "external"}),
|
||||
"side_effect.blocked": frozenset({"approval_id", "capability", "choice", "external"}),
|
||||
"side_effect.released": frozenset({"approval_id", "capability", "choice", "external"}),
|
||||
"memory.proposed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
|
||||
"memory.committed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
|
||||
"memory.forgotten": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
|
||||
"memory.suppressed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
|
||||
"memory.retrieval_removed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
|
||||
"artifact.created": frozenset({"artifact_id", "version", "type", "bytes", "hash"}),
|
||||
"artifact.version": frozenset({"artifact_id", "version", "type", "bytes", "hash"}),
|
||||
"artifact.promoted": frozenset({"artifact_id", "version", "type", "bytes", "hash"}),
|
||||
"run.started": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"run.cancelled": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"run.completed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"run.failed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"delegation.started": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"delegation.completed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"delegation.failed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"budget.exhausted": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
|
||||
"privacy.notice": frozenset({"schema", "topic", "conversation_id", "text", "controls", "shown_at"}),
|
||||
"citation.attached": frozenset({"citation_id", "message_id", "passage_ids", "verdict"}),
|
||||
"mode.changed": frozenset({"mode", "previous_mode", "route_id"}),
|
||||
"suggestion.shown": frozenset({"suggestion_id", "action"}),
|
||||
"suggestion.dismissed": frozenset({"suggestion_id", "action"}),
|
||||
"release.transition": frozenset({"release_id", "state", "previous_state"}),
|
||||
}
|
||||
|
||||
|
||||
def canaries(environ: dict[str, str] | None = None) -> list[str]:
|
||||
"""Literal secrets the pod was started with (SO-07); every one is scrubbed wherever it appears."""
|
||||
environ = os.environ if environ is None else environ
|
||||
values = [environ.get(name, "") for name in ("HUX_RELAY_KEY", "HUX_WORKER_KEY")]
|
||||
path = environ.get("HUX_CANARY_FILE", "")
|
||||
if path and os.path.exists(path):
|
||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||
for line in handle:
|
||||
_, _, value = line.strip().partition("=")
|
||||
values.append(value.strip().strip("'\""))
|
||||
return [v for v in values if len(v) >= 8]
|
||||
|
||||
|
||||
def scrub_text(text: str, environ: dict[str, str] | None = None) -> tuple[str, list[str]]:
|
||||
"""Replace secret-looking substrings with ``[redacted:<class>]``; return the text and the classes hit."""
|
||||
hits: list[str] = []
|
||||
for canary in canaries(environ):
|
||||
if canary in text:
|
||||
text = text.replace(canary, "[redacted:canary]")
|
||||
hits.append("canary")
|
||||
for name, pattern in SECRET_PATTERNS:
|
||||
text, count = pattern.subn(f"[redacted:{name}]", text)
|
||||
if count:
|
||||
hits.append(name)
|
||||
return text, hits
|
||||
|
||||
|
||||
def scrub_value(value: Any, hits: list[str], environ: dict[str, str] | None = None) -> Any:
|
||||
"""Scrub every string inside a JSON-like value, collecting classes into ``hits``.
|
||||
|
||||
Content hashes (``hash``, ``argument_hash``) are digests by construction and
|
||||
are kept verbatim so the hex rule does not eat them.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
text, found = scrub_text(value, environ)
|
||||
hits.extend(found)
|
||||
return text
|
||||
if isinstance(value, list):
|
||||
return [scrub_value(item, hits, environ) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): item if key in HASH_KEYS and isinstance(item, str) else scrub_value(item, hits, environ)
|
||||
for key, item in value.items()
|
||||
}
|
||||
return value
|
||||
|
||||
|
||||
def filter_detail(kind: str, detail: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Keep only the allowlisted keys for ``kind`` (SO-11) and apply the kind-specific shape rules."""
|
||||
if not detail:
|
||||
return {}
|
||||
allowed = DETAIL_ALLOWLIST.get(kind, frozenset())
|
||||
kept = {key: value for key, value in detail.items() if key in allowed}
|
||||
if kind == "decision.plan" and "steps" in kept:
|
||||
steps = kept["steps"] if isinstance(kept["steps"], list) else []
|
||||
kept["steps"] = [str(step)[:200] for step in steps[:20]]
|
||||
if kind == "tool.call":
|
||||
# Raw arguments never persist: only their names, a hash and a byte count.
|
||||
path = kept.get("target_path")
|
||||
if not (isinstance(path, str) and path.startswith(WORKSPACE_PREFIX + "/")):
|
||||
kept.pop("target_path", None)
|
||||
if "argument_names" in kept:
|
||||
names = kept["argument_names"] if isinstance(kept["argument_names"], list) else []
|
||||
kept["argument_names"] = sorted({str(name)[:80] for name in names})[:64]
|
||||
return kept
|
||||
|
||||
|
||||
def cap_detail(detail: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
"""Replace detail over 32 KiB with a truncation marker (SO-13)."""
|
||||
size = len(json.dumps(detail, sort_keys=True, separators=(",", ":")).encode())
|
||||
if size > DETAIL_CAP_BYTES:
|
||||
return {"truncated": True, "bytes": size}, True
|
||||
return detail, False
|
||||
|
||||
|
||||
def filter_evidence(evidence: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||
"""Keep well-formed refs, drop URIs outside the allowlisted schemes (SO-19), cap at 64."""
|
||||
kept: list[dict[str, Any]] = []
|
||||
for ref in evidence or []:
|
||||
if not isinstance(ref, dict) or not isinstance(ref.get("kind"), str) or not isinstance(ref.get("id"), str):
|
||||
continue
|
||||
clean = {"kind": ref["kind"], "id": ref["id"][:200]}
|
||||
uri = ref.get("uri")
|
||||
if isinstance(uri, str) and uri.startswith(URI_SCHEMES):
|
||||
clean["uri"] = uri[:2000]
|
||||
if isinstance(ref.get("hash"), str):
|
||||
clean["hash"] = ref["hash"]
|
||||
kept.append(clean)
|
||||
return kept[:64]
|
||||
|
||||
|
||||
def derive_level(sensitivity: str, hits: list[str], truncated: bool) -> dict[str, str]:
|
||||
"""Redaction block for a new event from its sensitivity and what the pipeline did."""
|
||||
reasons: list[str] = []
|
||||
level = "none"
|
||||
if sensitivity == "sensitive":
|
||||
level, reasons = "partial", ["sensitive topic"]
|
||||
if hits:
|
||||
level = "partial" if level == "none" else level
|
||||
reasons.append("scrubbed " + ",".join(sorted(set(hits)))[:120])
|
||||
if truncated:
|
||||
level = "partial" if level == "none" else level
|
||||
reasons.append("detail truncated")
|
||||
if sensitivity == "restricted":
|
||||
level, reasons = "full", ["restricted content"]
|
||||
block = {"level": level}
|
||||
if reasons:
|
||||
block["reason"] = "; ".join(reasons)[:200]
|
||||
return block
|
||||
|
||||
|
||||
def redact_record(record: dict[str, Any], surface: str) -> dict[str, Any]:
|
||||
"""Serve-time redaction (SO-14) for any record that carries ``redaction``; strips ``_meta`` (SO-51).
|
||||
|
||||
``none`` is served as stored except that telegram and voice are lifted to
|
||||
``partial``; ``partial`` drops ``detail``; ``full`` keeps only the
|
||||
contract-required envelope with the summary replaced by a placeholder.
|
||||
"""
|
||||
served = {key: value for key, value in record.items() if key != "_meta"}
|
||||
block = served.get("redaction")
|
||||
if not isinstance(block, dict):
|
||||
return served
|
||||
level = block.get("level", "none")
|
||||
if level == "none" and surface in HUMAN_SURFACES:
|
||||
level = "partial"
|
||||
served["redaction"] = {"level": "partial", "reason": f"{surface} surface never receives unredacted detail"}
|
||||
if level == "partial":
|
||||
served.pop("detail", None)
|
||||
if level == "full":
|
||||
served = {key: value for key, value in served.items() if key in FULL_KEEP}
|
||||
if "summary" in record:
|
||||
served["summary"] = "[redacted]"
|
||||
if "content" in record:
|
||||
served["content"] = ""
|
||||
return served
|
||||
|
||||
|
||||
def full_redaction(record: dict[str, Any], summary: str, reason: str) -> dict[str, Any]:
|
||||
"""Rewrite a stored event to ``redaction.level: full`` in place (forget and topic decay)."""
|
||||
kept = {key: value for key, value in record.items() if key in FULL_KEEP}
|
||||
kept["summary"] = summary[:SUMMARY_MAX] or "[redacted]"
|
||||
kept["redaction"] = {"level": "full", "reason": reason[:200]}
|
||||
if "idempotency_key" in record:
|
||||
kept["idempotency_key"] = record["idempotency_key"]
|
||||
return kept
|
||||
378
dockerfiles/hermes-hux-foundation/hux/research.py
Normal file
378
dockerfiles/hermes-hux-foundation/hux/research.py
Normal file
@ -0,0 +1,378 @@
|
||||
"""HUX-08 research model: sources, passages, citations and notebooks.
|
||||
|
||||
A source says where evidence came from, a passage is the exact excerpt, a
|
||||
citation ties a claim in a message to passages with a support verdict, and a
|
||||
notebook collects them per research question. The service never fetches a
|
||||
``uri`` (SO-19, SO-29): URIs are stored metadata under an allowlisted scheme.
|
||||
Every referenced id resolves under the caller's own subtree (SO-33); a foreign
|
||||
or unknown id is ``404`` so ids cannot be probed. Duplicates are merged on a
|
||||
server-computed ``dedupe_key`` and the original record is returned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from hux.artifacts import remember, replay
|
||||
from hux.contracts import load_all, validate_record
|
||||
from hux.errors import Conflict, Invalid, NotFound, TooLarge
|
||||
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 = "research"
|
||||
SOURCES, PASSAGES, CITATIONS, NOTEBOOKS = "sources", "passages", "citations", "notebooks"
|
||||
SCHEMES = frozenset({"http", "https", "artifact", "memory", "file"})
|
||||
SOURCE_KINDS = ("web", "document", "artifact", "memory", "tool_output", "dataset")
|
||||
CLASSIFICATIONS = ("primary", "secondary", "unknown")
|
||||
SUPPORT = ("supports", "partially_supports", "contradicts", "unverified")
|
||||
NOTEBOOK_TRANSITIONS = {"open": frozenset({"answered", "abandoned"}), "answered": frozenset(), "abandoned": frozenset()}
|
||||
MAX_SOURCES, MAX_PASSAGES = 10000, 20000
|
||||
_SCHEMAS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _schemas() -> dict[str, dict[str, Any]]:
|
||||
if not _SCHEMAS:
|
||||
_SCHEMAS.update(load_all())
|
||||
return _SCHEMAS
|
||||
|
||||
|
||||
def _check(record: dict[str, Any]) -> None:
|
||||
problems = validate_record(record, _schemas())
|
||||
if problems:
|
||||
raise Invalid(f"{record.get('schema')} record is not valid", problems)
|
||||
|
||||
|
||||
def _body(request: Request) -> dict[str, Any]:
|
||||
if not isinstance(request.body, dict):
|
||||
raise Invalid("body must be a JSON object")
|
||||
return request.body
|
||||
|
||||
|
||||
def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -> str | None:
|
||||
value = body.get(key)
|
||||
if value is None:
|
||||
if required:
|
||||
raise Invalid(f"{key} is required")
|
||||
return None
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
||||
raise Invalid(f"{key} must be a non-empty string of at most {limit} characters")
|
||||
return value
|
||||
|
||||
|
||||
def _sha(*parts: str) -> str:
|
||||
return "sha256:" + hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _lookup(store: TenantStore, ledger: str, key: str) -> str | None:
|
||||
for row in store.read(FAMILY, ledger):
|
||||
if row.get("key") == key:
|
||||
return row["id"]
|
||||
return None
|
||||
|
||||
|
||||
def _resolve(store: TenantStore, family: str, record_id: Any) -> dict[str, Any]:
|
||||
"""Load a record under the caller's subtree; malformed or missing is 404 (SO-33).
|
||||
|
||||
Sources, passages and citations are immutable, so the store's ``revision``
|
||||
bookkeeping is stripped before they are served; notebooks keep theirs.
|
||||
"""
|
||||
try:
|
||||
record = store.get(family, check_id(record_id))
|
||||
except (Invalid, NotFound) as error:
|
||||
raise NotFound(f"{family[:-1]} not found") from error
|
||||
return record if family == NOTEBOOKS else {k: v for k, v in record.items() if k != "revision"}
|
||||
|
||||
|
||||
def _id_list(body: dict[str, Any], key: str, maximum: int = 32) -> list[str]:
|
||||
values = body.get(key, [])
|
||||
if not isinstance(values, list) or not all(isinstance(v, str) for v in values):
|
||||
raise Invalid(f"{key} must be a list of ids")
|
||||
unique = list(dict.fromkeys(values))
|
||||
if len(unique) > maximum:
|
||||
raise TooLarge(f"{key} exceeds {maximum} ids")
|
||||
return unique
|
||||
|
||||
|
||||
def _emit(store: TenantStore, identity: Identity, conversation_id: str | None, kind: str, summary: str, detail: dict[str, Any], evidence: list[dict[str, Any]]) -> None:
|
||||
if conversation_id is None:
|
||||
return
|
||||
try:
|
||||
from hux.events import emit
|
||||
except ModuleNotFoundError:
|
||||
return
|
||||
emit(store, identity, conversation_id, kind, summary, detail=detail, evidence=evidence)
|
||||
|
||||
|
||||
# -- sources ---------------------------------------------------------------------
|
||||
|
||||
def normalise_uri(uri: str) -> str:
|
||||
"""Canonical form used for dedupe: lowercase scheme and host, no fragment, no trailing slash."""
|
||||
parts = urlsplit(uri.strip())
|
||||
if parts.scheme.lower() not in SCHEMES:
|
||||
raise Invalid("uri scheme must be one of http, https, artifact, memory, file")
|
||||
path = parts.path.rstrip("/") or ("/" if parts.netloc else "")
|
||||
return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), path, parts.query, ""))
|
||||
|
||||
|
||||
def create_source(request: Request) -> Response:
|
||||
"""``POST /hux/v1/sources``: record where evidence came from; duplicates return the original."""
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
kind = _string(body, "kind", 40)
|
||||
if kind not in SOURCE_KINDS:
|
||||
raise Invalid("unknown source kind")
|
||||
classification = body.get("classification", "unknown")
|
||||
if classification not in CLASSIFICATIONS:
|
||||
raise Invalid("unknown classification")
|
||||
title = _string(body, "title", 300)
|
||||
uri = _string(body, "uri", 2000, required=False)
|
||||
dedupe = _sha("uri", normalise_uri(uri)) if uri is not None else _sha("title", kind, title.strip().lower())
|
||||
stamp = now_iso()
|
||||
with request.store.lock(FAMILY):
|
||||
existing = replay(request.store, FAMILY, key) or _lookup(request.store, "source_keys", dedupe)
|
||||
if existing is not None:
|
||||
request.audit("research.source_create", existing, reason="deduplicated")
|
||||
return Response(200, _resolve(request.store, SOURCES, existing), {"HUX-Replayed": "true"})
|
||||
if request.store.count(SOURCES) >= MAX_SOURCES:
|
||||
raise TooLarge("tenant has reached 10000 sources")
|
||||
record: dict[str, Any] = {
|
||||
"schema": "hux.source.v1", "id": new_id("src"), "kind": kind, "title": title,
|
||||
"classification": classification, "retrieved_at": stamp, "dedupe_key": dedupe,
|
||||
"provenance": {"surface": request.identity.surface, "actor": _actor(request.identity), "recorded_at": stamp},
|
||||
}
|
||||
if uri is not None:
|
||||
record["uri"] = uri
|
||||
for field, limit in (("publisher", 200), ("published_at", 40), ("content_hash", 71)):
|
||||
if _string(body, field, limit, required=False) is not None:
|
||||
record[field] = body[field]
|
||||
conversation_id = body.get("conversation_id")
|
||||
if conversation_id is not None:
|
||||
record["provenance"]["conversation_id"] = check_id(conversation_id)
|
||||
_check(record)
|
||||
stored = record | {}
|
||||
request.store.put(SOURCES, record)
|
||||
request.store.append(FAMILY, "source_keys", {"key": dedupe, "id": stored["id"]})
|
||||
remember(request.store, FAMILY, key, stored["id"])
|
||||
request.audit("research.source_create", stored["id"])
|
||||
return Response(201, stored)
|
||||
|
||||
|
||||
def _actor(identity: Identity) -> dict[str, str]:
|
||||
if identity.trust == "worker":
|
||||
return {"type": "system", "id": "worker"}
|
||||
return {"type": "user", "id": identity.subject}
|
||||
|
||||
|
||||
def get_source(request: Request) -> Response:
|
||||
"""``GET /hux/v1/sources/{id}``: one source record."""
|
||||
record = _resolve(request.store, SOURCES, request.params["id"])
|
||||
request.audit("research.source_get", record["id"])
|
||||
return Response(200, record)
|
||||
|
||||
|
||||
# -- passages --------------------------------------------------------------------
|
||||
|
||||
def create_passage(request: Request) -> Response:
|
||||
"""``POST /hux/v1/passages``: an exact excerpt of a source; hash and dedupe key are server-computed."""
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
source = _resolve(request.store, SOURCES, body.get("source_id"))
|
||||
text = _string(body, "text", 4000)
|
||||
text_hash = "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
dedupe = _sha("passage", source["id"], text_hash)
|
||||
with request.store.lock(FAMILY):
|
||||
existing = replay(request.store, FAMILY, key) or _lookup(request.store, "passage_keys", dedupe)
|
||||
if existing is not None:
|
||||
request.audit("research.passage_create", existing, reason="deduplicated")
|
||||
return Response(200, _resolve(request.store, PASSAGES, existing), {"HUX-Replayed": "true"})
|
||||
if request.store.count(PASSAGES) >= MAX_PASSAGES:
|
||||
raise TooLarge("tenant has reached 20000 passages")
|
||||
record: dict[str, Any] = {"schema": "hux.passage.v1", "id": new_id("psg"), "source_id": source["id"], "text": text, "hash": text_hash, "dedupe_key": dedupe}
|
||||
if body.get("locator") is not None:
|
||||
record["locator"] = body["locator"]
|
||||
_check(record)
|
||||
stored = record | {}
|
||||
request.store.put(PASSAGES, record)
|
||||
request.store.append(FAMILY, "passage_keys", {"key": dedupe, "id": stored["id"]})
|
||||
remember(request.store, FAMILY, key, stored["id"])
|
||||
request.audit("research.passage_create", stored["id"])
|
||||
return Response(201, stored)
|
||||
|
||||
|
||||
# -- citations -------------------------------------------------------------------
|
||||
|
||||
def _message_ledger(message_id: str) -> str:
|
||||
return "msg_" + hashlib.sha256(message_id.encode("utf-8")).hexdigest()[:40]
|
||||
|
||||
|
||||
def attach_citation(request: Request) -> Response:
|
||||
"""``POST /hux/v1/messages/{id}/citations``: tie a claim to passages with a support verdict."""
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
message_id = request.params["id"]
|
||||
if len(message_id) > 120:
|
||||
raise Invalid("message id is too long")
|
||||
claim = _string(body, "claim", 1000)
|
||||
support = body.get("support", "unverified")
|
||||
if support not in SUPPORT:
|
||||
raise Invalid("unknown support verdict")
|
||||
passage_ids = _id_list(body, "passage_ids")
|
||||
if not passage_ids:
|
||||
raise Invalid("at least one passage_id is required")
|
||||
passages = [_resolve(request.store, PASSAGES, pid) for pid in passage_ids]
|
||||
dedupe = _sha("citation", message_id, claim, *sorted(passage_ids))
|
||||
with request.store.lock(FAMILY):
|
||||
existing = replay(request.store, FAMILY, key) or _lookup(request.store, "citation_keys", dedupe)
|
||||
if existing is not None:
|
||||
request.audit("research.citation_attach", existing, reason="deduplicated")
|
||||
return Response(200, _resolve(request.store, CITATIONS, existing), {"HUX-Replayed": "true"})
|
||||
record: dict[str, Any] = {"schema": "hux.citation.v1", "id": new_id("cit"), "message_id": message_id, "claim": claim, "passage_ids": passage_ids, "support": support, "dedupe_key": dedupe}
|
||||
if _string(body, "note", 500, required=False) is not None:
|
||||
record["note"] = body["note"]
|
||||
_check(record)
|
||||
stored = record | {}
|
||||
request.store.put(CITATIONS, record)
|
||||
request.store.append(FAMILY, "citation_keys", {"key": dedupe, "id": stored["id"]})
|
||||
request.store.append(FAMILY, _message_ledger(message_id), {"id": stored["id"]})
|
||||
remember(request.store, FAMILY, key, stored["id"])
|
||||
request.audit("research.citation_attach", stored["id"])
|
||||
evidence = [{"kind": "passage", "id": p["id"], "hash": p["hash"]} for p in passages]
|
||||
conversation_id = body.get("conversation_id")
|
||||
_emit(request.store, request.identity, None if conversation_id is None else check_id(conversation_id), "citation.attached", f"Citation attached ({support})", {"message_id": message_id, "citation_id": stored["id"], "support": support}, evidence)
|
||||
return Response(201, stored)
|
||||
|
||||
|
||||
def list_citations(request: Request) -> Response:
|
||||
"""``GET /hux/v1/messages/{id}/citations``: citations with passages and sources embedded."""
|
||||
items = []
|
||||
for row in request.store.read(FAMILY, _message_ledger(request.params["id"])):
|
||||
citation = _resolve(request.store, CITATIONS, row["id"])
|
||||
passages = [_resolve(request.store, PASSAGES, pid) for pid in citation["passage_ids"]]
|
||||
sources = {p["source_id"]: _resolve(request.store, SOURCES, p["source_id"]) for p in passages}
|
||||
items.append({"citation": citation, "passages": passages, "sources": list(sources.values())})
|
||||
request.audit("research.citation_list", request.params["id"])
|
||||
return page(items)
|
||||
|
||||
|
||||
def validate_citations(store: TenantStore, citation_ids: list[str]) -> list[str]:
|
||||
"""Integrity problems for a set of citations: dangling passages, passage/source mismatch, contradictions without a note."""
|
||||
problems: list[str] = []
|
||||
for citation_id in citation_ids:
|
||||
try:
|
||||
citation = _resolve(store, CITATIONS, citation_id)
|
||||
except NotFound:
|
||||
problems.append(f"{citation_id}: citation does not resolve")
|
||||
continue
|
||||
if citation["support"] == "contradicts" and not citation.get("note"):
|
||||
problems.append(f"{citation_id}: contradicts without a note")
|
||||
for passage_id in citation["passage_ids"]:
|
||||
try:
|
||||
passage = _resolve(store, PASSAGES, passage_id)
|
||||
except NotFound:
|
||||
problems.append(f"{citation_id}: passage {passage_id} does not resolve")
|
||||
continue
|
||||
if not store.exists(SOURCES, passage["source_id"]):
|
||||
problems.append(f"{citation_id}: passage {passage_id} names missing source {passage['source_id']}")
|
||||
return problems
|
||||
|
||||
|
||||
# -- notebooks -------------------------------------------------------------------
|
||||
|
||||
def _text_list(body: dict[str, Any], key: str, current: list[str]) -> list[str]:
|
||||
values = body.get(key)
|
||||
if values is None:
|
||||
return current
|
||||
if not isinstance(values, list) or not all(isinstance(v, str) and v.strip() for v in values):
|
||||
raise Invalid(f"{key} must be a list of non-empty strings")
|
||||
return values
|
||||
|
||||
|
||||
def create_notebook(request: Request) -> Response:
|
||||
"""``POST /hux/v1/notebooks``: open a research question for a conversation."""
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
with request.store.lock(FAMILY):
|
||||
existing = replay(request.store, FAMILY, key)
|
||||
if existing is not None:
|
||||
request.audit("research.notebook_create", existing, reason="idempotent_replay")
|
||||
return Response(200, request.store.get(NOTEBOOKS, existing), {"HUX-Replayed": "true"})
|
||||
record: dict[str, Any] = {
|
||||
"schema": "hux.research_notebook.v1", "id": new_id("nb"), "conversation_id": check_id(body.get("conversation_id")),
|
||||
"question": _string(body, "question", 1000), "status": "open", "source_ids": [], "passage_ids": [], "citation_ids": [],
|
||||
"assumptions": _text_list(body, "assumptions", []), "unresolved_questions": _text_list(body, "unresolved_questions", []),
|
||||
"updated_at": now_iso(), "notes": [], "revision": 1,
|
||||
}
|
||||
_check(record)
|
||||
stored = request.store.put(NOTEBOOKS, record)
|
||||
remember(request.store, FAMILY, key, stored["id"])
|
||||
request.audit("research.notebook_create", stored["id"])
|
||||
return Response(201, stored, {"ETag": "1"})
|
||||
|
||||
|
||||
def get_notebook(request: Request) -> Response:
|
||||
"""``GET /hux/v1/notebooks/{id}``: one notebook."""
|
||||
record = _resolve(request.store, NOTEBOOKS, request.params["id"])
|
||||
request.audit("research.notebook_get", record["id"])
|
||||
return Response(200, record, {"ETag": str(record["revision"])})
|
||||
|
||||
|
||||
def _merged_ids(store: TenantStore, family: str, current: list[str], body: dict[str, Any], key: str) -> list[str]:
|
||||
added = _id_list(body, key, maximum=500)
|
||||
for record_id in added:
|
||||
_resolve(store, family, record_id)
|
||||
return list(dict.fromkeys([*current, *added]))
|
||||
|
||||
|
||||
def patch_notebook(request: Request) -> Response:
|
||||
"""``PATCH /hux/v1/notebooks/{id}``: add references and notes, replace assumptions, move status (If-Match required)."""
|
||||
body = _body(request)
|
||||
expected = request.if_match()
|
||||
if expected is None:
|
||||
raise Invalid("If-Match is required")
|
||||
with request.store.lock(FAMILY):
|
||||
notebook = _resolve(request.store, NOTEBOOKS, request.params["id"])
|
||||
if expected != notebook["revision"]:
|
||||
raise Conflict(f"revision {expected} does not match current revision {notebook['revision']}", [str(notebook["revision"])])
|
||||
stamp = now_iso()
|
||||
updated = {
|
||||
**notebook,
|
||||
"source_ids": _merged_ids(request.store, SOURCES, notebook["source_ids"], body, "add_source_ids"),
|
||||
"passage_ids": _merged_ids(request.store, PASSAGES, notebook["passage_ids"], body, "add_passage_ids"),
|
||||
"citation_ids": _merged_ids(request.store, CITATIONS, notebook["citation_ids"], body, "add_citation_ids"),
|
||||
"assumptions": _text_list(body, "assumptions", notebook["assumptions"]),
|
||||
"unresolved_questions": _text_list(body, "unresolved_questions", notebook["unresolved_questions"]),
|
||||
"updated_at": stamp,
|
||||
}
|
||||
notes = body.get("add_notes", [])
|
||||
if not isinstance(notes, list) or not all(isinstance(n, dict) for n in notes):
|
||||
raise Invalid("add_notes must be a list of objects")
|
||||
for note in notes:
|
||||
entry = {"at": stamp, "text": _string(note, "text", 2000)}
|
||||
if note.get("source_id") is not None:
|
||||
entry["source_id"] = _resolve(request.store, SOURCES, note["source_id"])["id"]
|
||||
updated["notes"] = [*updated["notes"], entry]
|
||||
status = body.get("status")
|
||||
if status is not None and status != notebook["status"]:
|
||||
if status not in NOTEBOOK_TRANSITIONS or status not in NOTEBOOK_TRANSITIONS[notebook["status"]]:
|
||||
raise Conflict(f"notebook cannot move from {notebook['status']} to {status}")
|
||||
updated["status"] = status
|
||||
_check(updated)
|
||||
stored = request.store.put(NOTEBOOKS, updated, expected_revision=expected)
|
||||
request.audit("research.notebook_patch", stored["id"])
|
||||
return Response(200, stored, {"ETag": str(stored["revision"])})
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-08 routes."""
|
||||
card = "HUX-08"
|
||||
router.add("POST", "/hux/v1/sources", card, "research.source_create", create_source)
|
||||
router.add("GET", "/hux/v1/sources/{id}", card, "research.source_get", get_source)
|
||||
router.add("POST", "/hux/v1/passages", card, "research.passage_create", create_passage)
|
||||
router.add("POST", "/hux/v1/messages/{id}/citations", card, "research.citation_attach", attach_citation)
|
||||
router.add("GET", "/hux/v1/messages/{id}/citations", card, "research.citation_list", list_citations)
|
||||
router.add("POST", "/hux/v1/notebooks", card, "research.notebook_create", create_notebook)
|
||||
router.add("GET", "/hux/v1/notebooks/{id}", card, "research.notebook_get", get_notebook)
|
||||
router.add("PATCH", "/hux/v1/notebooks/{id}", card, "research.notebook_patch", patch_notebook)
|
||||
@ -124,11 +124,12 @@ def memory_policy_violations(entry: dict[str, Any]) -> list[str]:
|
||||
topic = entry.get("topic", "general")
|
||||
if sensitivity == "restricted" and entry.get("status") in {"proposed", "active"}:
|
||||
problems.append("restricted content may not be remembered")
|
||||
if sensitivity == "sensitive" and entry.get("approval_mode") != "ask":
|
||||
stored = entry.get("status") not in {"rejected", "no_store", "forgotten"}
|
||||
if sensitivity == "sensitive" and stored and entry.get("approval_mode") != "ask":
|
||||
problems.append("sensitive memory requires approval_mode=ask")
|
||||
if sensitivity == "sensitive" and entry.get("ttl", {}).get("policy") == "never":
|
||||
if sensitivity == "sensitive" and stored and entry.get("ttl", {}).get("policy") == "never":
|
||||
problems.append("sensitive memory must expire or decay")
|
||||
if topic in PRIVACY_TOPICS and PRIVACY_TOPICS[topic]["memory_write"] == "deny" and entry.get("status") != "rejected":
|
||||
if topic in PRIVACY_TOPICS and PRIVACY_TOPICS[topic]["memory_write"] == "deny" and stored:
|
||||
problems.append(f"topic {topic} may not be written to memory")
|
||||
ttl = entry.get("ttl", {})
|
||||
if ttl.get("policy") == "expires_at" and "expires_at" not in ttl:
|
||||
|
||||
@ -14,7 +14,7 @@ suggestion gating, flag dependencies) live in
|
||||
validates records without external packages.
|
||||
`testing/tests/test_hermes_hux_contract_schemas.py` keeps schemas, examples,
|
||||
rules and the live Switchyard catalog in agreement. The contract is frozen at
|
||||
`1.0.0` (see `docs/hux/ADR-0001-hux-v1-contract-freeze.md` for identity
|
||||
`1.1.0` (see `docs/hux/ADR-0001-hux-v1-contract-freeze.md` for identity
|
||||
headers, `If-Match`, idempotency and compatibility rules).
|
||||
|
||||
## Where the backend lives
|
||||
@ -55,15 +55,15 @@ tenant. Responses are the records below, wrapped as `{"items": [...],
|
||||
| Area | Routes | Record |
|
||||
|---|---|---|
|
||||
| Foundation (HUX-11) | `GET /hux/v1/capabilities`, `GET /hux/v1/manifest` | `hux.capabilities.v1`, `hux.manifest.v1`, errors as `hux.error.v1` |
|
||||
| Events (HUX-01) | `GET /hux/v1/conversations/{id}/events?after_seq=N` (JSON), `GET .../events/stream` (SSE, `id:` = seq) | `hux.event.v1` |
|
||||
| Memory (HUX-02) | `GET/POST /hux/v1/memory`, `POST /hux/v1/memory/{id}/{approve,reject,forget}`, `GET /hux/v1/memory/export` | `hux.memory.v1` |
|
||||
| Projects (HUX-03) | `GET/POST/PATCH /hux/v1/projects`, `GET/PATCH /hux/v1/conversations`, `POST /hux/v1/conversations/{id}/branch`, `GET /hux/v1/search?q=` | `hux.project.v1`, `hux.conversation.v1` |
|
||||
| Events (HUX-01) | `GET/POST /hux/v1/conversations/{id}/events?after_seq=N&limit=` (JSON, ≤200), `GET .../events/stream` (SSE, `id:` = seq, resumes from `Last-Event-ID`) | `hux.event.v1` |
|
||||
| Memory (HUX-02) | `GET/POST /hux/v1/memory`, `GET /hux/v1/memory/{id}`, `POST /hux/v1/memory/{id}/{approve,reject,forget,edit,remove_retrieval,restore_retrieval}`, `GET /hux/v1/memory/export` | `hux.memory.v1` |
|
||||
| Projects (HUX-03) | `GET/POST /hux/v1/projects`, `GET/PATCH /hux/v1/projects/{id}`, `GET/POST /hux/v1/conversations`, `GET/PATCH /hux/v1/conversations/{id}`, `POST /hux/v1/conversations/{id}/branch`, `GET /hux/v1/conversations/{id}/lineage`, `GET /hux/v1/search?q=&project_id=` (message_text not indexed yet; response says `indexed`/`not_indexed`) | `hux.project.v1`, `hux.conversation.v1` |
|
||||
| Artifacts (HUX-04) | `GET/POST /hux/v1/artifacts`, `POST /hux/v1/artifacts/{id}/versions`, `GET .../versions/{n}/diff?from=`, `POST .../promote` | `hux.artifact.v1` |
|
||||
| Autonomy (HUX-05) | `GET/PUT /hux/v1/policy?scope=`, `GET /hux/v1/approvals`, `POST /hux/v1/approvals/{id}` (`once|session|always|deny`, the gateway's own choices), `POST /hux/v1/runs/{id}/stop` returns the receipt, `GET /hux/v1/runs/{id}/budget` | `hux.policy.v1`, `hux.approval.v1`, `hux.cancel_receipt.v1`, `hux.budget_state.v1` |
|
||||
| Autonomy (HUX-05) | `GET/PUT /hux/v1/policy?scope=`, `GET/POST /hux/v1/approvals` (agent hook creates with `request.evidence[{kind: tool_call, id, hash}]`), `GET/POST /hux/v1/approvals/{id}` (human decision `once|session|always|deny`; worker/api trust gets 403), `POST /hux/v1/runs/{id}/gate` (`{capability, argument_hash, external}` → `{proceed, approval_id?, reason}`), `GET/POST /hux/v1/runs/{id}/budget`, `POST /hux/v1/runs/{id}/stop` returns the receipt (repeat returns the same one) | `hux.policy.v1`, `hux.approval.v1`, `hux.cancel_receipt.v1`, `hux.budget_state.v1` |
|
||||
| Modes (HUX-06) | `GET /hux/v1/modes`, `PUT /hux/v1/conversations/{id}/mode` | `hux.mode.v1` |
|
||||
| Research (HUX-08) | `GET /hux/v1/messages/{id}/citations`, `GET /hux/v1/sources/{id}`, `GET/PATCH /hux/v1/notebooks/{id}` | `hux.source.v1`, `hux.passage.v1`, `hux.citation.v1`, `hux.research_notebook.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`, `POST /hux/v1/conversations/{id}/forget` | `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` | `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,
|
||||
|
||||
@ -60,6 +60,17 @@ format with Codex: a change to a fixture is a change to the contract.
|
||||
4. Anything outside 1–3 is `hux.v2`, served beside v1 until every surface
|
||||
has moved.
|
||||
|
||||
## Revision 1.1.0 (2026-08-24, additive)
|
||||
|
||||
Raised after the three Wave A lanes reported. Adds `receipt` to
|
||||
`evidence_ref.kind`, `unprocessable` (422) to error codes, optional
|
||||
`revision` on source/passage/citation, optional `audit_stale` on the privacy
|
||||
policy, and per-route body caps (artifact uploads up to 25 MiB content). The
|
||||
argument hash a `once` approval is released against travels as
|
||||
`request.evidence[] = {kind: "tool_call", id, hash}`; the agent hook must
|
||||
send it or the gate can never release. Memory rules skip content-free
|
||||
statuses (`no_store`, `forgotten`, `rejected`).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Codex codes UI against the fixtures, not against the running service.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# hermes-next: Claude backend handoff ledger
|
||||
|
||||
Branch `feature/hermes-next-hux` (origin, pushed after every card). Board
|
||||
Branch `feature/hermes-next-hux` (origin, pushed after every card); draft PR #55 https://scm.bstein.dev/atlas/titan-iac/pulls/55 (opened through the SCM broker, never merged by Claude). Board
|
||||
`hermes-next` on the operator Hermes Kanban; cards HUX-01..HUX-12 are parked
|
||||
(`blocked`) on purpose so the CLI lane runner never auto-claims them — the
|
||||
Claude coordinator and the Codex root session are the only implementers.
|
||||
@ -46,3 +46,19 @@ Run everything with the CI interpreter:
|
||||
| Flag | `hux.foundation` (root of every dependency chain) |
|
||||
| Risks | Rate limiting (SO-53) and hash-chained audit (SO-46) not yet implemented — tracked for the Wave A review; relay callers still carry `X-Hux-Subject` (deviates from SO-06; the slot→owner mapping lives in the router, so the header is redundant but harmless and lets the service pin the subject) |
|
||||
| Codex needs | Wire the service into the tenant pod + Worker, NetworkPolicy, router header contract above, `HUX_FLAGS=hux.foundation` for the first canary; `testing/quality_contract.json` may add `dockerfiles/hermes-hux-foundation/**/*.py` to `line_limit_globs` and the two moved modules to `managed_modules` (Claude does not edit that file) |
|
||||
|
||||
## Wave A backends — DONE, awaiting adversarial review and Codex integration
|
||||
|
||||
Full suite: `testing/tests/test_hermes_hux_*.py` = 325 tests, 99% line / 99% branch over `dockerfiles/hermes-hux-foundation/hux/` (every family module 99–100%). Every module ≤ 500 LOC (guarded by tests). Contract revised additively to 1.1.0 (ADR-0001).
|
||||
|
||||
| Card | Commit | Files | Tests | Flag | Codex needs |
|
||||
|---|---|---|---|---|---|
|
||||
| HUX-01 activity timeline | `1cb6f07c` | `hux/events.py`, `hux/redaction.py` | `test_hermes_hux_contract_events.py` (34): ordering, idempotency, replay/reconnect, redaction by surface, cancellation receipts, cross-tenant | `hux.activity_timeline` | Router forwards `Last-Event-ID`; telegram/voice surfaces get partial redaction; agent hook posts events with `Idempotency-Key`; `HUX_CANARY_FILE=/opt/data/.env` so secrets are scrubbed |
|
||||
| HUX-02 memory center | `1cb6f07c` | `hux/memory.py` | `test_hermes_hux_memory_ledger.py` (20), `test_hermes_hux_memory_retrieval.py` (7): no-store, suggest-only, edit/supersede, forget, retrieval removal, export, If-Match | `hux.memory_control` (needs `hux.privacy`) | Agent memory tool must call `hux.memory.retrieve` semantics (tombstones first) instead of the upstream memory file; UI per-message controls call `/memory/{id}/{action}` with `If-Match` |
|
||||
| HUX-10 privacy | `1cb6f07c` | `hux/privacy.py` | `test_hermes_hux_privacy_topics.py` (19), `test_hermes_hux_privacy_retention.py` (6) | `hux.privacy` | Schedule `run_retention` daily (no thread inside the service by design); surface `HUX-Audit-Stale`; show notices and post `chosen` |
|
||||
| HUX-04 artifacts | `ada74ce0` (+ upload caps, project check in the 1.1.0 batch) | `hux/artifacts.py`, `hux/diffs.py` | `test_hermes_hux_artifact_versions.py`, `test_hermes_hux_artifact_auth.py` (immutable versions, concurrency, lineage forgery 404, caps 413, diffs, promotion) | `hux.artifacts` (needs `hux.projects`) | Version content served with `nosniff` + attachment; uploads ≤ 25 MiB content via base64; sharing intentionally not implemented (`access.mode` always `owner`) |
|
||||
| HUX-08 research | `ada74ce0` | `hux/research.py` | `test_hermes_hux_research_citations.py`, `test_hermes_hux_research_notebook.py` (dedupe, integrity, notebook state machine) | `hux.research` | Service never fetches URIs (SO-19): the web tool records sources/passages after its own fetch; citation strip = `GET /messages/{id}/citations` (passages + sources embedded) |
|
||||
| HUX-05 autonomy | `b3de70ba` | `hux/policy.py`, `hux/budgets.py` | `test_hermes_hux_policy_matrix.py` (22), `_approvals.py` (22), `_receipts.py` (13) | `hux.autonomy` (needs `hux.activity_timeline`) | Agent hook: `POST /approvals` before any external side effect, `POST /runs/{id}/gate` immediately before executing with the canonical argument hash, `POST /runs/{id}/budget` per turn, `POST /runs/{id}/stop` with `process_registry_empty` from the real process registry; decisions only from human surfaces (worker trust → 403) |
|
||||
| HUX-03 organization API | `b3de70ba` | `hux/organization.py` | `test_hermes_hux_contract_organization.py` (14) | `hux.projects` | Codex owns the UI + migration of upstream WebUI projects; search over `message_text` is a later increment |
|
||||
|
||||
Known gaps carried to the Wave A review: rate limiting (SO-53), hash-chained audit (SO-46), retention scheduler ownership (Codex cron vs service thread), approval expiry applied lazily on read, linear scans for idempotency/dedupe (fine at documented caps).
|
||||
|
||||
@ -1 +1 @@
|
||||
1.0.0
|
||||
1.1.0
|
||||
|
||||
@ -70,6 +70,9 @@
|
||||
"type": "string",
|
||||
"description": "Stable key used to merge duplicates: sha256 of normalised uri (source), of source_id+text hash (passage), of message_id+claim+passages (citation).",
|
||||
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||
},
|
||||
"revision": {
|
||||
"$ref": "common.schema.json#/$defs/revision"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -135,6 +138,9 @@
|
||||
"type": "string",
|
||||
"description": "Stable key used to merge duplicates: sha256 of normalised uri (source), of source_id+text hash (passage), of message_id+claim+passages (citation).",
|
||||
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||
},
|
||||
"revision": {
|
||||
"$ref": "common.schema.json#/$defs/revision"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -192,6 +198,9 @@
|
||||
"type": "string",
|
||||
"description": "Stable key used to merge duplicates: sha256 of normalised uri (source), of source_id+text hash (passage), of message_id+claim+passages (citation).",
|
||||
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||
},
|
||||
"revision": {
|
||||
"$ref": "common.schema.json#/$defs/revision"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -216,7 +216,8 @@
|
||||
"file",
|
||||
"build",
|
||||
"flux",
|
||||
"pod"
|
||||
"pod",
|
||||
"receipt"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
|
||||
@ -199,5 +199,5 @@
|
||||
"rollback": "disable flag; release lane evidence archive remains the source of truth"
|
||||
}
|
||||
],
|
||||
"contract_version": "1.0.0"
|
||||
"contract_version": "1.1.0"
|
||||
}
|
||||
|
||||
@ -125,7 +125,8 @@
|
||||
"too_large",
|
||||
"rate_limited",
|
||||
"approval_required",
|
||||
"budget_exhausted"
|
||||
"budget_exhausted",
|
||||
"unprocessable"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
|
||||
@ -4,47 +4,129 @@
|
||||
"title": "HUX privacy policy, topic scoping and retention",
|
||||
"description": "Sensitive-topic behaviour (HUX-10). The policy names each topic class, whether memory may be written from it, how long conversation-scoped context survives, and which just-in-time notice the surface must show. A retention audit record proves the rules ran.",
|
||||
"$defs": {
|
||||
"topic": {"type": "string", "enum": ["health", "finance", "legal", "relationships", "credentials", "minors", "location", "biometric"]},
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"health",
|
||||
"finance",
|
||||
"legal",
|
||||
"relationships",
|
||||
"credentials",
|
||||
"minors",
|
||||
"location",
|
||||
"biometric"
|
||||
]
|
||||
},
|
||||
"policy": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "version", "topics", "topic_scoping", "retention_audit"],
|
||||
"required": [
|
||||
"schema",
|
||||
"version",
|
||||
"topics",
|
||||
"topic_scoping",
|
||||
"retention_audit"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "hux.privacy_policy.v1"},
|
||||
"version": {"type": "integer", "minimum": 1},
|
||||
"schema": {
|
||||
"const": "hux.privacy_policy.v1"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"topics": {
|
||||
"type": "array",
|
||||
"minItems": 8,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["topic", "sensitivity", "memory_write", "decay_days", "notice"],
|
||||
"required": [
|
||||
"topic",
|
||||
"sensitivity",
|
||||
"memory_write",
|
||||
"decay_days",
|
||||
"notice"
|
||||
],
|
||||
"properties": {
|
||||
"topic": {"$ref": "#/$defs/topic"},
|
||||
"sensitivity": {"$ref": "common.schema.json#/$defs/sensitivity"},
|
||||
"memory_write": {"type": "string", "enum": ["ask", "deny"]},
|
||||
"decay_days": {"type": "integer", "minimum": 1, "maximum": 365},
|
||||
"notice": {"type": "string", "minLength": 1, "maxLength": 280}
|
||||
"topic": {
|
||||
"$ref": "#/$defs/topic"
|
||||
},
|
||||
"sensitivity": {
|
||||
"$ref": "common.schema.json#/$defs/sensitivity"
|
||||
},
|
||||
"memory_write": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"decay_days": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 365
|
||||
},
|
||||
"notice": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 280
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"topic_scoping": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["scope_to_conversation", "cross_surface_sharing"],
|
||||
"required": [
|
||||
"scope_to_conversation",
|
||||
"cross_surface_sharing"
|
||||
],
|
||||
"properties": {
|
||||
"scope_to_conversation": {"const": true},
|
||||
"cross_surface_sharing": {"type": "string", "enum": ["never", "same_owner_only"]}
|
||||
"scope_to_conversation": {
|
||||
"const": true
|
||||
},
|
||||
"cross_surface_sharing": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"never",
|
||||
"same_owner_only"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"retention_audit": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["interval_days", "actions"],
|
||||
"required": [
|
||||
"interval_days",
|
||||
"actions"
|
||||
],
|
||||
"properties": {
|
||||
"interval_days": {"type": "integer", "minimum": 1, "maximum": 30},
|
||||
"actions": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "enum": ["expire_memory", "decay_topic_context", "purge_forgotten_content", "report"]}}
|
||||
"interval_days": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 30
|
||||
},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"expire_memory",
|
||||
"decay_topic_context",
|
||||
"purge_forgotten_content",
|
||||
"report"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"audit_stale": {
|
||||
"type": "boolean",
|
||||
"description": "True when the last retention audit is older than 48 hours (SO-27)."
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -52,34 +134,95 @@
|
||||
"type": "object",
|
||||
"description": "Just-in-time notice a surface shows when a sensitive topic is detected. Emitted as event kind privacy.notice.",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "topic", "conversation_id", "text", "controls", "shown_at"],
|
||||
"required": [
|
||||
"schema",
|
||||
"topic",
|
||||
"conversation_id",
|
||||
"text",
|
||||
"controls",
|
||||
"shown_at"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "hux.privacy_notice.v1"},
|
||||
"topic": {"$ref": "#/$defs/topic"},
|
||||
"conversation_id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"text": {"type": "string", "minLength": 1, "maxLength": 280},
|
||||
"controls": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "enum": ["forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss"]}},
|
||||
"shown_at": {"$ref": "common.schema.json#/$defs/timestamp"}
|
||||
"schema": {
|
||||
"const": "hux.privacy_notice.v1"
|
||||
},
|
||||
"topic": {
|
||||
"$ref": "#/$defs/topic"
|
||||
},
|
||||
"conversation_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 280
|
||||
},
|
||||
"controls": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"forget_this_conversation",
|
||||
"switch_to_private",
|
||||
"disable_memory_here",
|
||||
"dismiss"
|
||||
]
|
||||
}
|
||||
},
|
||||
"shown_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
}
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "id", "ran_at", "policy_version", "results"],
|
||||
"required": [
|
||||
"schema",
|
||||
"id",
|
||||
"ran_at",
|
||||
"policy_version",
|
||||
"results"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "hux.retention_audit.v1"},
|
||||
"id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"ran_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"policy_version": {"type": "integer", "minimum": 1},
|
||||
"schema": {
|
||||
"const": "hux.retention_audit.v1"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"ran_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"policy_version": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["action", "count"],
|
||||
"required": [
|
||||
"action",
|
||||
"count"
|
||||
],
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["expire_memory", "decay_topic_context", "purge_forgotten_content", "report"]},
|
||||
"count": {"type": "integer", "minimum": 0}
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"expire_memory",
|
||||
"decay_topic_context",
|
||||
"purge_forgotten_content",
|
||||
"report"
|
||||
]
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -87,8 +230,14 @@
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/policy"},
|
||||
{"$ref": "#/$defs/notice"},
|
||||
{"$ref": "#/$defs/audit"}
|
||||
{
|
||||
"$ref": "#/$defs/policy"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/notice"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/audit"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
130
testing/tests/test_hermes_hux_artifact_auth.py
Normal file
130
testing/tests/test_hermes_hux_artifact_auth.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""HUX-04 artifacts: authorization boundaries and tenant isolation.
|
||||
|
||||
Security obligations exercised: a second subject sees 404, never 403, for
|
||||
every artifact route (SO-33, SO-34); malformed ids never reach a path (SO-43);
|
||||
a forged owner field inside the tenant directory is still refused; every
|
||||
denial leaves an audit outcome; the card serves nothing while its flag is off.
|
||||
"""
|
||||
|
||||
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 artifacts, audit, contracts, errors, identity, store # noqa: E402
|
||||
from hux.server import build_router # noqa: E402
|
||||
|
||||
SCHEMAS = contracts.load_all()
|
||||
OWNER = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
|
||||
OTHER = {**OWNER, "X-Hux-Subject": "usr_fedcba9876543210"}
|
||||
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def router(tmp_path):
|
||||
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
|
||||
|
||||
|
||||
def call(router, method, path, body=None, headers=OWNER):
|
||||
payload = json.dumps(body).encode() if body is not None else b""
|
||||
response = router.dispatch(method, path, headers, payload)
|
||||
return response.status, response.body
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def artifact(router):
|
||||
status, record = call(router, "POST", "/hux/v1/artifacts", {"type": "markdown", "title": "Mine", "content": "secret\n", "conversation_id": "conv_0001abcd"})
|
||||
assert status == 201
|
||||
return record
|
||||
|
||||
|
||||
def test_second_subject_gets_404_everywhere(router, artifact):
|
||||
art = artifact["id"]
|
||||
probes = [
|
||||
("GET", f"/hux/v1/artifacts/{art}", None),
|
||||
("GET", f"/hux/v1/artifacts/{art}/versions/1", None),
|
||||
("GET", f"/hux/v1/artifacts/{art}/versions/1/diff", None),
|
||||
("POST", f"/hux/v1/artifacts/{art}/versions", {"content": "x"}),
|
||||
("POST", f"/hux/v1/artifacts/{art}/promote", {"project_id": "prj_0001aaaa"}),
|
||||
]
|
||||
for method, path, body in probes:
|
||||
status, error = call(router, method, path, body, {**OTHER, "If-Match": "1"})
|
||||
assert (status, error["code"]) == (404, "not_found"), path
|
||||
assert contracts.validate_record(error, SCHEMAS) == []
|
||||
status, body = call(router, "GET", "/hux/v1/artifacts?conversation_id=conv_0001abcd", headers=OTHER)
|
||||
assert status == 200 and body["items"] == []
|
||||
rows = audit.recent(store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_fedcba9876543210", "chat", "router")))
|
||||
assert [r["outcome"] for r in rows if r["action"].startswith("artifacts.")].count("not_found") == 5
|
||||
|
||||
|
||||
def test_cross_tenant_lineage_forgery_is_404(router, artifact):
|
||||
body = {"type": "markdown", "title": "Derived", "content": "copy", "lineage": {"artifact_id": artifact["id"], "version": 1}}
|
||||
status, error = call(router, "POST", "/hux/v1/artifacts", body, OTHER)
|
||||
assert (status, error["code"]) == (404, "not_found")
|
||||
status, own = call(router, "POST", "/hux/v1/artifacts", {**body, "lineage": None}, OTHER)
|
||||
assert status == 201
|
||||
status, error = call(router, "POST", f"/hux/v1/artifacts/{own['id']}/versions", {"content": "v2", "lineage": {"artifact_id": artifact["id"], "version": 1}}, {**OTHER, "If-Match": "1"})
|
||||
assert status == 404
|
||||
assert call(router, "GET", f"/hux/v1/artifacts/{own['id']}", headers=OTHER)[1]["current_version"] == 1
|
||||
|
||||
|
||||
def test_malformed_ids_never_reach_the_filesystem(router):
|
||||
for path in ("/hux/v1/artifacts/..", "/hux/v1/artifacts/art_x", "/hux/v1/artifacts/ART_0000000000"):
|
||||
status, error = call(router, "GET", path)
|
||||
assert status == 404 and error["code"] == "not_found", path
|
||||
tenant = store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router"))
|
||||
assert not (tenant.root / "artifacts" / "...json").exists()
|
||||
|
||||
|
||||
def test_forged_owner_inside_tenant_directory_is_refused(router, artifact):
|
||||
tenant = store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router"))
|
||||
forged = {**tenant.get("artifacts", artifact["id"]), "id": "art_forged000001", "owner": "usr_fedcba9876543210"}
|
||||
tenant.put("artifacts", forged)
|
||||
assert call(router, "GET", "/hux/v1/artifacts/art_forged000001")[0] == 404
|
||||
status, error = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c", "lineage": {"artifact_id": "art_forged000001", "version": 1}})
|
||||
assert status == 404
|
||||
status, body = call(router, "GET", "/hux/v1/artifacts")
|
||||
assert [a["id"] for a in body["items"]] == [artifact["id"]]
|
||||
assert artifacts.artifact_exists(tenant, "art_forged000001")
|
||||
|
||||
|
||||
def test_contract_validation_guards_every_write(router, artifact):
|
||||
tenant = store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router"))
|
||||
current = tenant.get("artifacts", artifact["id"])
|
||||
with pytest.raises(errors.Invalid) as raised:
|
||||
artifacts.append_version(tenant, current, {"version": 2, "created_at": "now", "created_by": {"type": "user", "id": "x"}, "content_ref": {"hash": "sha256:" + "a" * 64, "bytes": 1, "mime": "x"}}, 1)
|
||||
assert raised.value.details and tenant.get("artifacts", artifact["id"])["current_version"] == 1
|
||||
|
||||
|
||||
def test_flag_off_hides_the_card(tmp_path, artifact):
|
||||
router = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation"})
|
||||
status, error = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c"})
|
||||
assert (status, error["code"]) == (404, "flag_off")
|
||||
status, error = call(router, "GET", "/hux/v1/artifacts")
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_idempotency_replay_is_scoped_to_the_caller(router):
|
||||
body = {"type": "code", "title": "t", "content": "c"}
|
||||
status, mine = call(router, "POST", "/hux/v1/artifacts", body, {**OWNER, "Idempotency-Key": "shared-key-01"})
|
||||
status, theirs = call(router, "POST", "/hux/v1/artifacts", body, {**OTHER, "Idempotency-Key": "shared-key-01"})
|
||||
assert status == 201 and theirs["id"] != mine["id"] and theirs["owner"] == "usr_fedcba9876543210"
|
||||
status, again = call(router, "POST", "/hux/v1/artifacts", body, {**OWNER, "Idempotency-Key": "shared-key-02"})
|
||||
assert status == 201 and again["id"] != mine["id"]
|
||||
status, error = call(router, "POST", f"/hux/v1/artifacts/{mine['id']}/versions", {"content": "v"}, {**OWNER, "If-Match": "1", "Idempotency-Key": "shared-key-02"})
|
||||
assert status == 201
|
||||
|
||||
|
||||
def test_audit_rows_name_family_verbs(router, artifact):
|
||||
call(router, "GET", f"/hux/v1/artifacts/{artifact['id']}")
|
||||
rows = audit.recent(store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")))
|
||||
assert [r["action"] for r in rows] == ["artifacts.create", "artifacts.get"]
|
||||
assert all(contracts.validate("common.schema.json", r, SCHEMAS, "/$defs/audit_outcome") == [] for r in rows)
|
||||
322
testing/tests/test_hermes_hux_artifact_versions.py
Normal file
322
testing/tests/test_hermes_hux_artifact_versions.py
Normal file
@ -0,0 +1,322 @@
|
||||
"""HUX-04 artifacts: immutable versions, diffs, lineage, promotion and caps.
|
||||
|
||||
Security obligations exercised: server-side sha256 with mismatch rejection and
|
||||
never-rewritten blobs (SO-30), per-version / per-artifact / per-tenant caps
|
||||
(SO-31), nosniff attachment delivery (SO-32), lineage resolved under the
|
||||
caller's subtree (SO-33), owner-only access (SO-34) and If-Match on every
|
||||
revisioned write (SO-44).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
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 artifacts, contracts, diffs, errors, identity, 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"}
|
||||
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
||||
|
||||
|
||||
def ident() -> identity.Identity:
|
||||
return identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def router(tmp_path):
|
||||
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
|
||||
|
||||
|
||||
def call(router, method, path, body=None, headers=None, raw=None):
|
||||
payload = raw if raw is not None else (json.dumps(body).encode() if body is not None else b"")
|
||||
response = router.dispatch(method, path, {**HEADERS, **(headers or {})}, payload)
|
||||
return response.status, response.body, response.headers
|
||||
|
||||
|
||||
def valid(record) -> bool:
|
||||
return contracts.validate_record(record, SCHEMAS) == []
|
||||
|
||||
|
||||
def make(router, text="# one\nline\n", **extra):
|
||||
body = {"type": "markdown", "title": "Doc", "content": text, "mime": "text/markdown", "conversation_id": "conv_0001abcd", **extra}
|
||||
status, record, _ = call(router, "POST", "/hux/v1/artifacts", body)
|
||||
assert status == 201, record
|
||||
return record
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def events(monkeypatch):
|
||||
"""Fake events lane capturing emit() calls with the agreed signature."""
|
||||
calls = []
|
||||
module = types.ModuleType("hux.events")
|
||||
module.emit = lambda store, identity, conversation_id, kind, summary, detail=None, evidence=None, sensitivity="personal", run_id=None, turn=None, correlation_id=None: calls.append((conversation_id, kind, summary, detail, evidence, sensitivity))
|
||||
monkeypatch.setitem(sys.modules, "hux.events", module)
|
||||
return calls
|
||||
|
||||
|
||||
# --- create ------------------------------------------------------------------------
|
||||
|
||||
def test_create_computes_hash_and_serves_contract_valid_record(router, events):
|
||||
record = make(router, language="markdown", message_id="msg-1", note="first")
|
||||
assert valid(record)
|
||||
digest = hashlib.sha256(b"# one\nline\n").hexdigest()
|
||||
version = record["versions"][0]
|
||||
assert version["content_ref"] == {"hash": f"sha256:{digest}", "bytes": 11, "mime": "text/markdown"}
|
||||
assert version["created_by"] == {"type": "user", "id": "usr_0123456789abcdef"}
|
||||
assert (version["message_id"], version["note"], record["language"]) == ("msg-1", "first", "markdown")
|
||||
assert record["owner"] == "usr_0123456789abcdef" and record["access"] == {"mode": "owner"}
|
||||
assert record["current_version"] == 1 and record["revision"] == 1
|
||||
assert events[0][:2] == ("conv_0001abcd", "artifact.created") and events[0][5] == "personal"
|
||||
assert events[0][4][0]["kind"] == "artifact_version"
|
||||
assert store.TenantStore(router.data_root, ident()).get_blob(digest) == b"# one\nline\n"
|
||||
|
||||
|
||||
def test_create_accepts_base64_and_verifies_claimed_hash(router):
|
||||
data = bytes(range(256))
|
||||
body = {"type": "image", "title": "Pixels", "content_base64": base64.b64encode(data).decode(), "mime": "image/png", "sensitivity": "sensitive", "project_id": "prj_0001aaaa"}
|
||||
status, record, _ = call(router, "POST", "/hux/v1/artifacts", {**body, "hash": "sha256:" + hashlib.sha256(data).hexdigest()})
|
||||
assert status == 201 and valid(record) and record["versions"][0]["content_ref"]["bytes"] == 256
|
||||
assert record["sensitivity"] == "sensitive" and record["project_id"] == "prj_0001aaaa"
|
||||
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {**body, "hash": "sha256:" + "0" * 64})
|
||||
assert (status, error["code"]) == (400, "invalid") and valid(error)
|
||||
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {**body, "content_base64": "not*base64"})
|
||||
assert status == 400
|
||||
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {**body, "content": "both"})
|
||||
assert status == 400
|
||||
status, record, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "audio", "title": "Beep", "content_base64": base64.b64encode(b"\x00\x01").decode()})
|
||||
assert status == 201 and record["versions"][0]["content_ref"]["mime"] == "application/octet-stream"
|
||||
|
||||
|
||||
def test_worker_trust_is_recorded_as_system_actor(tmp_path):
|
||||
router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk"})
|
||||
headers = {"X-Hux-Trust": "worker", "X-Hux-Surface": "worker", "X-Hux-Relay-Key": "wk"}
|
||||
status, record, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "gen.py", "content": "print(1)\n"}, headers)
|
||||
assert status == 201 and record["versions"][0]["created_by"] == {"type": "system", "id": "worker"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
{"type": "binary"}, {"type": None}, {"sensitivity": "secret"}, {"title": ""}, {"title": "x" * 201}, {"content": 5},
|
||||
{"conversation_id": "../etc"}, {"lineage": "art_x"}, {"note": "n" * 201}, {"mime": ""},
|
||||
])
|
||||
def test_create_rejects_bad_bodies(router, bad):
|
||||
body = {"type": "markdown", "title": "Doc", "content": "x", **bad}
|
||||
status, error, _ = call(router, "POST", "/hux/v1/artifacts", body)
|
||||
assert status == 400 and valid(error)
|
||||
status, error, _ = call(router, "POST", "/hux/v1/artifacts", raw=b"[]")
|
||||
assert status == 400
|
||||
|
||||
|
||||
def test_idempotency_key_replays_the_original(router):
|
||||
body = {"type": "json", "title": "Data", "content": "{}"}
|
||||
status, first, _ = call(router, "POST", "/hux/v1/artifacts", body, {"Idempotency-Key": "create-0001"})
|
||||
status, again, headers = call(router, "POST", "/hux/v1/artifacts", body, {"Idempotency-Key": "create-0001"})
|
||||
assert (status, headers.get("HUX-Replayed")) == (200, "true") and again["id"] == first["id"]
|
||||
status, _, _ = call(router, "POST", "/hux/v1/artifacts", body, {"Idempotency-Key": "bad key"})
|
||||
assert status == 400
|
||||
assert store.TenantStore(router.data_root, ident()).count("artifacts") == 1
|
||||
|
||||
|
||||
# --- list / get ----------------------------------------------------------------------
|
||||
|
||||
def test_list_filters_and_pages(router, monkeypatch):
|
||||
monkeypatch.setattr(artifacts, "PAGE_SIZE", 2)
|
||||
ids = [make(router)["id"] for _ in range(3)]
|
||||
other = make(router, conversation_id="conv_0002abcd", project_id="prj_0001aaaa")["id"]
|
||||
status, body, _ = call(router, "GET", "/hux/v1/artifacts?conversation_id=conv_0001abcd")
|
||||
assert status == 200 and [a["id"] for a in body["items"]] == ids[:2] and body["next"] == "2"
|
||||
status, body, _ = call(router, "GET", "/hux/v1/artifacts?conversation_id=conv_0001abcd&cursor=2")
|
||||
assert [a["id"] for a in body["items"]] == ids[2:] and body["next"] is None
|
||||
status, body, _ = call(router, "GET", "/hux/v1/artifacts?project_id=prj_0001aaaa")
|
||||
assert [a["id"] for a in body["items"]] == [other] and all(valid(a) for a in body["items"])
|
||||
assert call(router, "GET", "/hux/v1/artifacts?cursor=x")[0] == 400
|
||||
status, record, headers = call(router, "GET", f"/hux/v1/artifacts/{other}")
|
||||
assert status == 200 and record["id"] == other and headers["ETag"] == "1"
|
||||
|
||||
|
||||
# --- versions --------------------------------------------------------------------------
|
||||
|
||||
def test_versions_append_immutably_with_if_match(router, events):
|
||||
record = make(router)
|
||||
path = f"/hux/v1/artifacts/{record['id']}/versions"
|
||||
body = {"content": "# one\nline two\n", "mime": "text/markdown", "note": "edit", "message_id": "msg-2"}
|
||||
assert call(router, "POST", path, body)[0] == 400
|
||||
status, error, _ = call(router, "POST", path, body, {"If-Match": "7"})
|
||||
assert (status, error["code"], error["details"]) == (409, "conflict", ["1"])
|
||||
status, updated, headers = call(router, "POST", path, body, {"If-Match": "1"})
|
||||
assert status == 201 and valid(updated) and headers["ETag"] == "2"
|
||||
entry = updated["versions"][1]
|
||||
assert (entry["version"], entry["diff_from"], entry["note"], entry["message_id"]) == (2, 1, "edit", "msg-2")
|
||||
assert updated["current_version"] == 2 and updated["versions"][0] == record["versions"][0]
|
||||
assert events[-1][1] == "artifact.version" and events[-1][3]["version"] == 2
|
||||
status, third, _ = call(router, "POST", path, {"content": "third", "diff_from": 1}, {"If-Match": "2"})
|
||||
assert third["versions"][2]["diff_from"] == 1
|
||||
assert call(router, "POST", path, {"content": "x", "diff_from": 9}, {"If-Match": "3"})[0] == 404
|
||||
assert call(router, "POST", path, {"content": "x", "diff_from": "1"}, {"If-Match": "3"})[0] == 400
|
||||
status, error, _ = call(router, "POST", path, raw=b"[1]", headers={"If-Match": "3"})
|
||||
assert status == 400
|
||||
|
||||
|
||||
def test_version_replay_and_old_blob_untouched(router):
|
||||
record = make(router)
|
||||
tenant = store.TenantStore(router.data_root, ident())
|
||||
path = f"/hux/v1/artifacts/{record['id']}/versions"
|
||||
status, second, _ = call(router, "POST", path, {"content": "v2"}, {"If-Match": "1", "Idempotency-Key": "ver-00001"})
|
||||
status, replay, headers = call(router, "POST", path, {"content": "v2"}, {"If-Match": "1", "Idempotency-Key": "ver-00001"})
|
||||
assert (status, headers["HUX-Replayed"], replay["revision"]) == (200, "true", 2)
|
||||
old_digest = record["versions"][0]["content_ref"]["hash"].split(":")[1]
|
||||
tenant.put_blob(old_digest, b"tampered")
|
||||
assert tenant.get_blob(old_digest) == b"# one\nline\n"
|
||||
with pytest.raises(errors.Conflict):
|
||||
artifacts.append_version(tenant, second, dict(second["versions"][1]), 2)
|
||||
with pytest.raises(errors.Conflict):
|
||||
artifacts.append_version(tenant, second, {**second["versions"][1], "version": 5}, 2)
|
||||
assert tenant.get("artifacts", record["id"])["versions"] == second["versions"]
|
||||
|
||||
|
||||
def test_concurrent_writers_never_lose_a_version(router):
|
||||
record = make(router)
|
||||
path = f"/hux/v1/artifacts/{record['id']}/versions"
|
||||
statuses = []
|
||||
|
||||
def worker(n: int) -> None:
|
||||
for _ in range(6):
|
||||
_, current, _ = call(router, "GET", f"/hux/v1/artifacts/{record['id']}")
|
||||
status, _, _ = call(router, "POST", path, {"content": f"w{n}"}, {"If-Match": str(current["revision"])})
|
||||
statuses.append(status)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(n,)) for n in range(3)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
_, final, _ = call(router, "GET", f"/hux/v1/artifacts/{record['id']}")
|
||||
assert statuses.count(201) == final["current_version"] - 1
|
||||
assert [v["version"] for v in final["versions"]] == list(range(1, final["current_version"] + 1))
|
||||
assert final["revision"] == final["current_version"]
|
||||
|
||||
|
||||
def test_lineage_resolves_only_under_the_caller(router):
|
||||
parent = make(router)
|
||||
child = make(router, lineage={"artifact_id": parent["id"], "version": 1})
|
||||
assert child["versions"][0]["lineage"] == {"artifact_id": parent["id"], "version": 1}
|
||||
path = f"/hux/v1/artifacts/{child['id']}/versions"
|
||||
status, updated, _ = call(router, "POST", path, {"content": "v2", "lineage": {"artifact_id": parent["id"], "version": 1}}, {"If-Match": "1"})
|
||||
assert status == 201 and valid(updated) and updated["versions"][1]["lineage"]["artifact_id"] == parent["id"]
|
||||
for bad in ({"artifact_id": parent["id"], "version": 9}, {"artifact_id": "art_missing0000", "version": 1}, {"artifact_id": "../x", "version": 1}):
|
||||
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c", "lineage": bad})
|
||||
assert (status, error["code"]) == (404, "not_found"), bad
|
||||
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c", "lineage": {"artifact_id": parent["id"], "version": "1"}})
|
||||
assert status == 400
|
||||
|
||||
|
||||
def test_caps_are_413(router, monkeypatch):
|
||||
record = make(router)
|
||||
monkeypatch.setattr(artifacts, "MAX_VERSION_BYTES", 8)
|
||||
status, error, _ = call(router, "POST", f"/hux/v1/artifacts/{record['id']}/versions", {"content": "123456789"}, {"If-Match": "1"})
|
||||
assert (status, error["code"]) == (413, "too_large")
|
||||
monkeypatch.setattr(artifacts, "MAX_VERSION_BYTES", 25 * 1024 * 1024)
|
||||
monkeypatch.setattr(artifacts, "MAX_VERSIONS", 2)
|
||||
assert call(router, "POST", f"/hux/v1/artifacts/{record['id']}/versions", {"content": "v2"}, {"If-Match": "1"})[0] == 201
|
||||
assert call(router, "POST", f"/hux/v1/artifacts/{record['id']}/versions", {"content": "v3"}, {"If-Match": "2"})[0] == 413
|
||||
monkeypatch.setattr(artifacts, "MAX_ARTIFACTS", 1)
|
||||
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c"})
|
||||
assert (status, error["code"]) == (413, "too_large")
|
||||
|
||||
|
||||
# --- content and diffs ------------------------------------------------------------------
|
||||
|
||||
def test_get_version_serves_text_or_base64_with_nosniff(router):
|
||||
record = make(router)
|
||||
status, body, headers = call(router, "GET", f"/hux/v1/artifacts/{record['id']}/versions/1")
|
||||
assert status == 200 and body["content"] == "# one\nline\n" and body["version"] == record["versions"][0]
|
||||
assert headers["X-Content-Type-Options"] == "nosniff" and headers["Content-Disposition"] == "attachment"
|
||||
status, body, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "image", "title": "i", "content_base64": base64.b64encode(b"\xff\xfe").decode()})
|
||||
status, body, _ = call(router, "GET", f"/hux/v1/artifacts/{body['id']}/versions/1")
|
||||
assert base64.b64decode(body["content_base64"]) == b"\xff\xfe" and "content" not in body
|
||||
status, body, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "bin", "content_base64": base64.b64encode(b"\xff\xfe").decode()})
|
||||
status, body, _ = call(router, "GET", f"/hux/v1/artifacts/{body['id']}/versions/1")
|
||||
assert "content_base64" in body
|
||||
assert call(router, "GET", f"/hux/v1/artifacts/{record['id']}/versions/2")[0] == 404
|
||||
assert call(router, "GET", f"/hux/v1/artifacts/{record['id']}/versions/0")[0] == 400
|
||||
|
||||
|
||||
def test_diff_unified_for_text_and_hashes_for_binary(router):
|
||||
record = make(router)
|
||||
path = f"/hux/v1/artifacts/{record['id']}"
|
||||
call(router, "POST", f"{path}/versions", {"content": "# one\nline two\n"}, {"If-Match": "1"})
|
||||
call(router, "POST", f"{path}/versions", {"content": "# one\nline three\n"}, {"If-Match": "2"})
|
||||
status, body, headers = call(router, "GET", f"{path}/versions/2/diff")
|
||||
assert status == 200 and body["from"] == 1 and body["to"] == 2 and headers["X-Content-Type-Options"] == "nosniff"
|
||||
assert "-line\n" in body["unified"] and "+line two\n" in body["unified"] and body["unified"].startswith("--- v1")
|
||||
status, body, _ = call(router, "GET", f"{path}/versions/3/diff?from=1")
|
||||
assert body["from"] == 1 and "+line three\n" in body["unified"]
|
||||
status, body, _ = call(router, "GET", f"{path}/versions/1/diff")
|
||||
assert body == {"from": 1, "to": 1, "unified": ""}
|
||||
assert call(router, "GET", f"{path}/versions/3/diff?from=x")[0] == 400
|
||||
assert call(router, "GET", f"{path}/versions/3/diff?from=9")[0] == 404
|
||||
blob = base64.b64encode(b"\x00\x01").decode()
|
||||
status, image, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "image", "title": "i", "content_base64": blob})
|
||||
call(router, "POST", f"/hux/v1/artifacts/{image['id']}/versions", {"content_base64": base64.b64encode(b"\x00\x01\x02").decode()}, {"If-Match": "1"})
|
||||
status, body, _ = call(router, "GET", f"/hux/v1/artifacts/{image['id']}/versions/2/diff")
|
||||
assert body["binary"]["from_bytes"] == 2 and body["binary"]["to_bytes"] == 3 and "unified" not in body
|
||||
assert body["binary"]["to_hash"] == "sha256:" + hashlib.sha256(b"\x00\x01\x02").hexdigest()
|
||||
assert diffs.unified("code", b"\xff", b"ok", 1, 2)["binary"]["from_bytes"] == 1
|
||||
|
||||
|
||||
# --- promotion ---------------------------------------------------------------------------
|
||||
|
||||
def test_promote_sets_promotion_and_emits(router, events):
|
||||
record = make(router)
|
||||
p1 = call(router, "POST", "/hux/v1/projects", {"name": "one"})[1]["id"]
|
||||
p2 = call(router, "POST", "/hux/v1/projects", {"name": "two"})[1]["id"]
|
||||
call(router, "POST", f"/hux/v1/artifacts/{record['id']}/versions", {"content": "v2"}, {"If-Match": "1"})
|
||||
path = f"/hux/v1/artifacts/{record['id']}/promote"
|
||||
status, error, _ = call(router, "POST", path, {"project_id": p1}, {"If-Match": "1"})
|
||||
assert status == 409
|
||||
status, promoted, headers = call(router, "POST", path, {"project_id": p1}, {"If-Match": "2"})
|
||||
assert status == 200 and valid(promoted) and headers["ETag"] == "3"
|
||||
assert promoted["promotion"]["project_id"] == p1 and promoted["promotion"]["version"] == 2
|
||||
assert promoted["project_id"] == p1
|
||||
assert events[-1][1] == "artifact.promoted" and events[-1][3]["project_id"] == p1
|
||||
status, promoted, _ = call(router, "POST", path, {"project_id": p2, "version": 1})
|
||||
assert status == 200 and promoted["promotion"]["version"] == 1
|
||||
assert call(router, "POST", path, {"project_id": "nope"})[0] == 400
|
||||
assert call(router, "POST", path, {"project_id": "prj_0009zzzz"})[0] == 404
|
||||
assert call(router, "POST", path, {"project_id": p2, "version": 7})[0] == 404
|
||||
assert call(router, "POST", path, raw=b"1")[0] == 400
|
||||
from hux import audit
|
||||
rows = [r for r in audit.recent(store.TenantStore(router.data_root, ident())) if r["action"] == "artifacts.promote" and r["outcome"] == "allow"]
|
||||
assert [r.get("reason", "") for r in rows][-2:] == ["", "unconditional_write"]
|
||||
|
||||
|
||||
# --- helpers for other lanes and hygiene ---------------------------------------------------
|
||||
|
||||
def test_helpers_and_event_fallbacks(router, monkeypatch):
|
||||
record = make(router)
|
||||
tenant = store.TenantStore(router.data_root, ident())
|
||||
assert artifacts.artifact_exists(tenant, record["id"]) and not artifacts.artifact_exists(tenant, "bad id")
|
||||
assert artifacts.artifact_titles(tenant, [record["id"], "art_missing0000"]) == {record["id"]: "Doc"}
|
||||
monkeypatch.setitem(sys.modules, "hux.events", None)
|
||||
artifacts.emit_event(tenant, ident(), record, "artifact.created", "s", {})
|
||||
artifacts.emit_event(tenant, ident(), {**record, "conversation_id": None}, "artifact.created", "s", {})
|
||||
status, plain, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "csv", "title": "no conv", "content": "a,b\n"})
|
||||
assert status == 201 and "conversation_id" not in plain
|
||||
|
||||
|
||||
def test_modules_stay_under_500_lines():
|
||||
for name in ("artifacts.py", "diffs.py"):
|
||||
assert len((FOUNDATION / "hux" / name).read_text().splitlines()) <= 500
|
||||
366
testing/tests/test_hermes_hux_contract_events.py
Normal file
366
testing/tests/test_hermes_hux_contract_events.py
Normal file
@ -0,0 +1,366 @@
|
||||
"""HUX-01 activity events: ordering, idempotency, replay, reconnect, redaction, cancellation.
|
||||
|
||||
Security obligations exercised: SO-10..SO-19 (server-set provenance, detail
|
||||
allowlist, secret scrub, size caps, serve-time redaction, server-assigned seq,
|
||||
idempotent replay after ownership, bounded pages and streams, 404 for foreign
|
||||
conversations, evidence URI allowlist) and SO-28 (private mode writes nothing).
|
||||
"""
|
||||
|
||||
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, errors, events, identity, redaction, 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(**overrides) -> identity.Identity:
|
||||
base = {"tenant_slot": "slot-3", "subject": "usr_0123456789abcdef", "surface": "chat", "trust": "router"}
|
||||
return identity.Identity(**{**base, **overrides})
|
||||
|
||||
|
||||
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, who=None) -> store.TenantStore:
|
||||
return store.TenantStore(tmp_path, who or ident())
|
||||
|
||||
|
||||
def valid(record) -> None:
|
||||
assert contracts.validate_record(record, SCHEMAS) == [], record
|
||||
|
||||
|
||||
# --- emit: ordering and idempotency (SO-15, SO-16) ---------------------------------
|
||||
|
||||
def test_emit_assigns_monotonic_seq_and_validates(tmp_path):
|
||||
s = tenant(tmp_path)
|
||||
first = events.emit(s, ident(), CONV, "message.user", "hello", {"message_id": "m1", "chars": 5, "junk": "x"}, turn=1)
|
||||
second = events.emit(s, ident(), CONV, "message.assistant", "hi", run_id="run_9f", correlation_id="c1")
|
||||
assert (first["seq"], second["seq"]) == (1, 2)
|
||||
assert first["id"] != second["id"] and first["id"].startswith("evt_")
|
||||
assert first["detail"] == {"message_id": "m1", "chars": 5}
|
||||
assert second["run_id"] == "run_9f" and second["provenance"]["run_id"] == "run_9f"
|
||||
assert first["provenance"]["actor"] == {"type": "user", "id": "usr_0123456789abcdef"}
|
||||
assert second["provenance"]["actor"]["type"] == "assistant"
|
||||
assert first["identity"] == ident().record()
|
||||
for record in (first, second):
|
||||
valid(record)
|
||||
checkpoint = s.get(events.SEQ_FAMILY, f"seq_{CONV}")
|
||||
assert checkpoint["next_seq"] == 3 and checkpoint["last_event_id"] == second["id"]
|
||||
other = events.emit(s, ident(), "conv_0002abcd", "run.started", "another conversation")
|
||||
assert other["seq"] == 1
|
||||
|
||||
|
||||
def test_emit_is_idempotent_on_key(tmp_path):
|
||||
s = tenant(tmp_path)
|
||||
one = events.emit(s, ident(), CONV, "run.started", "start", idempotency_key="run_9f:start:1")
|
||||
again = events.emit(s, ident(), CONV, "run.started", "start", idempotency_key="run_9f:start:1")
|
||||
assert again == one
|
||||
assert len(s.read(events.FAMILY, CONV)) == 1
|
||||
_, replayed = events.emit_with_status(s, ident(), CONV, "run.started", "start", idempotency_key="run_9f:start:1")
|
||||
assert replayed is True
|
||||
|
||||
|
||||
def test_emit_rejects_unknown_kind_and_sensitivity(tmp_path):
|
||||
s = tenant(tmp_path)
|
||||
with pytest.raises(errors.Invalid):
|
||||
events.emit(s, ident(), CONV, "not.a.kind", "x")
|
||||
with pytest.raises(errors.Invalid):
|
||||
events.emit(s, ident(), CONV, "run.started", "x", sensitivity="secret")
|
||||
with pytest.raises(errors.Invalid):
|
||||
events.emit(s, ident(), "not a conversation id", "run.started", "x")
|
||||
|
||||
|
||||
def test_emit_validates_against_contract_before_writing(tmp_path, monkeypatch):
|
||||
s = tenant(tmp_path)
|
||||
monkeypatch.setattr(events, "new_id", lambda prefix: "bad id")
|
||||
with pytest.raises(errors.Invalid):
|
||||
events.emit(s, ident(), CONV, "run.started", "x")
|
||||
assert s.read(events.FAMILY, CONV) == []
|
||||
|
||||
|
||||
def test_worker_emits_as_system_actor(tmp_path):
|
||||
s = tenant(tmp_path)
|
||||
record = events.emit(s, ident(surface="worker", trust="worker"), CONV, "message.user", "x")
|
||||
assert record["provenance"]["actor"] == {"type": "system", "id": "hux-worker"}
|
||||
|
||||
|
||||
def test_private_mode_writes_nothing(tmp_path):
|
||||
s = tenant(tmp_path)
|
||||
s.put("conversations", {"id": "conv_priv0001", "mode": "private"})
|
||||
assert events.emit(s, ident(), "conv_priv0001", "message.user", "secret chat") is None
|
||||
assert s.read(events.FAMILY, "conv_priv0001") == []
|
||||
router = router_for(tmp_path)
|
||||
status, body, _ = call(router, "POST", "/hux/v1/conversations/conv_priv0001/events", body={"kind": "message.user", "summary": "x"})
|
||||
assert status == 204 and body is None
|
||||
|
||||
|
||||
# --- redaction pipeline (SO-11, SO-12, SO-13, SO-19) ------------------------------
|
||||
|
||||
def test_tool_call_detail_keeps_only_hash_and_names(tmp_path):
|
||||
s = tenant(tmp_path)
|
||||
detail = {
|
||||
"tool": "shell", "capability": "shell", "arguments": {"cmd": "curl -H 'Authorization: Bearer abcdefghijklmnopqrstuv'"},
|
||||
"argument_names": ["cmd", "cwd", "cmd"], "argument_hash": "a" * 64, "argument_bytes": 61,
|
||||
"target_path": "/opt/data/.env", "stdout": "leak",
|
||||
}
|
||||
record = events.emit(s, ident(), CONV, "tool.call", "ran shell", detail)
|
||||
assert record["detail"] == {"tool": "shell", "capability": "shell", "argument_names": ["cmd", "cwd"], "argument_hash": "a" * 64, "argument_bytes": 61}
|
||||
assert "arguments" not in json.dumps(record) and "leak" not in json.dumps(record)
|
||||
kept = events.emit(s, ident(), CONV, "tool.call", "wrote", {"tool": "write", "target_path": "/opt/data/workspace/notes.md"})
|
||||
assert kept["detail"]["target_path"] == "/opt/data/workspace/notes.md"
|
||||
plan = events.emit(s, ident(), CONV, "decision.plan", "plan", {"steps": ["s" * 300] * 25})
|
||||
assert len(plan["detail"]["steps"]) == 20 and len(plan["detail"]["steps"][0]) == 200
|
||||
assert events.emit(s, ident(), CONV, "decision.plan", "plan", {"steps": "nope"})["detail"] == {"steps": []}
|
||||
assert events.emit(s, ident(), CONV, "tool.call", "x", {"tool": "t", "argument_names": "nope"})["detail"]["argument_names"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("secret,klass", [
|
||||
("Authorization: Bearer abcdefghijklmnopqrstuvwxyz0123", "bearer"),
|
||||
("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0.abcdef", "jwt"),
|
||||
("sk-abcdefghijklmnopqrstuvwxyz1234", "openai_key"),
|
||||
("ghp_abcdefghijklmnopqrstuvwxyz1234", "github_token"),
|
||||
("glpat-abcdefghijklmnopqrstuv", "gitlab_token"),
|
||||
("AKIAABCDEFGHIJKLMNOP", "aws_key"),
|
||||
("xoxb-1234567890-abcdef", "slack_token"),
|
||||
("hvs.CAESIJabcdefghijklmnopqrstuvwxyz", "vault_token"),
|
||||
("password=hunter2hunter2", "password"),
|
||||
("0123456789abcdef0123456789abcdef", "hex"),
|
||||
("-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----", "private_key"),
|
||||
])
|
||||
def test_secret_patterns_are_scrubbed(tmp_path, secret, klass):
|
||||
s = tenant(tmp_path)
|
||||
record = events.emit(s, ident(), CONV, "decision.route", f"used {secret} today", {"reason": secret})
|
||||
text = json.dumps(record)
|
||||
assert secret not in text and f"[redacted:{klass}]" in text
|
||||
assert record["redaction"]["level"] == "partial" and klass in record["redaction"]["reason"]
|
||||
|
||||
|
||||
def test_canaries_from_environment_and_file_are_scrubbed(tmp_path, monkeypatch):
|
||||
canary_file = tmp_path / "env"
|
||||
canary_file.write_text("OPENAI_KEY='filecanaryvalue'\nSHORT=x\n")
|
||||
monkeypatch.setenv("HUX_RELAY_KEY", "relaykeycanary")
|
||||
monkeypatch.setenv("HUX_CANARY_FILE", str(canary_file))
|
||||
text, hits = redaction.scrub_text("relay relaykeycanary file filecanaryvalue")
|
||||
assert text == "relay [redacted:canary] file [redacted:canary]" and hits == ["canary", "canary"]
|
||||
assert redaction.canaries({}) == []
|
||||
|
||||
|
||||
def test_hashes_survive_the_hex_rule(tmp_path):
|
||||
s = tenant(tmp_path)
|
||||
record = events.emit(s, ident(), CONV, "artifact.version", "v2", {"artifact_id": "art_0001aaaa", "hash": "sha256:" + "b" * 64}, [{"kind": "artifact_version", "id": "art_0001aaaa@2", "hash": "sha256:" + "b" * 64}])
|
||||
assert record["detail"]["hash"] == "sha256:" + "b" * 64 and record["evidence"][0]["hash"] == "sha256:" + "b" * 64
|
||||
assert record["redaction"] == {"level": "none"}
|
||||
|
||||
|
||||
def test_oversized_detail_is_truncated_and_line_cap_enforced(tmp_path):
|
||||
s = tenant(tmp_path)
|
||||
record = events.emit(s, ident(), CONV, "decision.route", "big", {"reason": "r" * 40000})
|
||||
assert record["detail"]["truncated"] is True and record["detail"]["bytes"] > redaction.DETAIL_CAP_BYTES
|
||||
assert record["redaction"]["level"] == "partial" and "truncated" in record["redaction"]["reason"]
|
||||
huge = [{"kind": "url", "id": "u", "uri": "https://x/" + "z" * 1990} for _ in range(64)]
|
||||
with pytest.raises(errors.TooLarge):
|
||||
events.emit(s, ident(), CONV, "decision.route", "too big", evidence=huge)
|
||||
|
||||
|
||||
def test_evidence_uris_outside_allowlist_are_dropped(tmp_path):
|
||||
s = tenant(tmp_path)
|
||||
evidence = [
|
||||
{"kind": "file", "id": "env", "uri": "file:///opt/data/.env"},
|
||||
{"kind": "url", "id": "doc", "uri": "https://example.test/doc"},
|
||||
{"kind": "artifact_version", "id": "art_0001aaaa@1", "uri": "artifact://art_0001aaaa/1"},
|
||||
"garbage", {"kind": 5, "id": "x"},
|
||||
]
|
||||
record = events.emit(s, ident(), CONV, "tool.result", "read", evidence=evidence)
|
||||
assert [ref.get("uri") for ref in record["evidence"]] == [None, "https://example.test/doc", "artifact://art_0001aaaa/1"]
|
||||
assert len(redaction.filter_evidence([{"kind": "run", "id": "r"}] * 100)) == 64
|
||||
|
||||
|
||||
def test_redaction_level_derivation():
|
||||
assert redaction.derive_level("personal", [], False) == {"level": "none"}
|
||||
assert redaction.derive_level("sensitive", [], False)["level"] == "partial"
|
||||
assert redaction.derive_level("sensitive", ["hex"], True)["level"] == "partial"
|
||||
assert redaction.derive_level("restricted", ["hex"], True) == {"level": "full", "reason": "restricted content"}
|
||||
assert redaction.derive_level("public", [], True)["reason"] == "detail truncated"
|
||||
|
||||
|
||||
def test_serve_time_redaction_by_surface(tmp_path):
|
||||
s = tenant(tmp_path)
|
||||
record = events.emit(s, ident(), CONV, "decision.route", "routed", {"requested": "fast"})
|
||||
assert redaction.redact_record({**record, "_meta": {"x": 1}}, "chat") == record
|
||||
for surface in ("telegram", "voice"):
|
||||
served = redaction.redact_record(record, surface)
|
||||
assert "detail" not in served and served["redaction"]["level"] == "partial"
|
||||
valid(served)
|
||||
partial = events.emit(s, ident(), CONV, "decision.route", "routed", {"requested": "fast"}, sensitivity="sensitive")
|
||||
assert "detail" not in redaction.redact_record(partial, "chat")
|
||||
full = events.emit(s, ident(), CONV, "decision.route", "routed", {"requested": "fast"}, ["x"], sensitivity="restricted", run_id="r")
|
||||
served = redaction.redact_record(full, "chat")
|
||||
assert served["summary"] == "[redacted]" and "detail" not in served and "evidence" not in served and "run_id" not in served
|
||||
valid(served)
|
||||
assert redaction.redact_record({"schema": "x", "content": "c"}, "chat") == {"schema": "x", "content": "c"}
|
||||
memory_like = {"schema": "hux.memory.v1", "content": "secret", "redaction": {"level": "full"}}
|
||||
assert redaction.redact_record(memory_like, "chat")["content"] == ""
|
||||
|
||||
|
||||
def test_full_rewrite_keeps_timeline_contiguous(tmp_path):
|
||||
s = tenant(tmp_path)
|
||||
events.emit(s, ident(), CONV, "message.user", "one", {"message_id": "m1"}, idempotency_key="m1:00000001")
|
||||
events.emit(s, ident(), CONV, "message.user", "two", sensitivity="sensitive")
|
||||
assert events.rewrite_full(s, CONV, "", "forgotten") == 2
|
||||
rows = s.read(events.FAMILY, CONV)
|
||||
assert [r["seq"] for r in rows] == [1, 2] and all(r["redaction"]["level"] == "full" for r in rows)
|
||||
assert rows[0]["summary"] == "[redacted]" and rows[0]["idempotency_key"] == "m1:00000001" and "detail" not in rows[0]
|
||||
for row in rows:
|
||||
valid(row)
|
||||
assert events.rewrite_full(s, CONV, "", "again") == 0
|
||||
|
||||
|
||||
def test_memory_reference_redaction_spans_conversations(tmp_path):
|
||||
s = tenant(tmp_path)
|
||||
events.emit(s, ident(), CONV, "memory.committed", "kept", {"memory_id": "mem_0001aaaa"})
|
||||
events.emit(s, ident(), "conv_0002abcd", "message.user", "plain", evidence=[{"kind": "memory", "id": "mem_0001aaaa"}])
|
||||
events.emit(s, ident(), "conv_0002abcd", "message.user", "unrelated")
|
||||
assert events.redact_memory_references(s, "mem_0001aaaa") == 2
|
||||
assert s.read(events.FAMILY, "conv_0002abcd")[1]["redaction"]["level"] == "none"
|
||||
|
||||
|
||||
# --- routes -------------------------------------------------------------------------
|
||||
|
||||
def test_list_pages_with_after_seq_and_limit(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
s = tenant(tmp_path)
|
||||
for n in range(5):
|
||||
events.emit(s, ident(), CONV, "message.user", f"m{n}")
|
||||
status, body, _ = call(router, "GET", f"/hux/v1/conversations/{CONV}/events?limit=2")
|
||||
assert status == 200 and [e["seq"] for e in body["items"]] == [1, 2] and body["next"] == 2
|
||||
status, body, _ = call(router, "GET", f"/hux/v1/conversations/{CONV}/events?after_seq=2&limit=2")
|
||||
assert [e["seq"] for e in body["items"]] == [3, 4] and body["next"] == 4
|
||||
status, body, _ = call(router, "GET", f"/hux/v1/conversations/{CONV}/events?after_seq=4&limit=2")
|
||||
assert [e["seq"] for e in body["items"]] == [5] and body["next"] is None
|
||||
for item in body["items"]:
|
||||
valid(item)
|
||||
status, body, _ = call(router, "GET", f"/hux/v1/conversations/{CONV}/events?limit=5000")
|
||||
assert len(body["items"]) == 5
|
||||
assert events.PAGE_MAX == 200
|
||||
assert call(router, "GET", f"/hux/v1/conversations/{CONV}/events?after_seq=abc")[0] == 400
|
||||
assert call(router, "GET", f"/hux/v1/conversations/{CONV}/events?limit=0&after_seq=-5")[1]["items"][0]["seq"] == 1
|
||||
|
||||
|
||||
def test_foreign_conversation_is_404_not_403(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
events.emit(tenant(tmp_path), ident(), CONV, "message.user", "mine")
|
||||
status, body, _ = call(router, "GET", f"/hux/v1/conversations/{CONV}/events", headers=OTHER)
|
||||
assert (status, body["code"]) == (404, "not_found")
|
||||
assert call(router, "GET", f"/hux/v1/conversations/{CONV}/events/stream?max_polls=0", headers=OTHER)[0] == 404
|
||||
assert call(router, "POST", f"/hux/v1/conversations/{CONV}/events", headers=OTHER, body={"kind": "run.started", "summary": "x"})[0] == 404
|
||||
assert call(router, "GET", "/hux/v1/conversations/conv_nope0000/events")[0] == 404
|
||||
rows = audit.recent(tenant(tmp_path, ident(subject="usr_fedcba9876543210")))
|
||||
assert {r["outcome"] for r in rows} == {"not_found"}
|
||||
|
||||
|
||||
def test_post_event_server_assigns_and_replays(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
tenant(tmp_path).put("conversations", {"id": CONV, "mode": "fast"})
|
||||
body = {"kind": "run.cancelled", "summary": "stopped by user", "detail": {"run_id": "run_9f", "outcome": "cancelled", "receipt_id": "rcpt_0001aaaa", "raw": 1},
|
||||
"evidence": [{"kind": "approval", "id": "apr_0001aaaa"}, {"kind": "run", "id": "run_9f"}], "run_id": "run_9f", "turn": 3,
|
||||
"identity": {"tenant_slot": "slot-9"}, "provenance": {"surface": "worker"}}
|
||||
headers = {**HEADERS, "Idempotency-Key": "run_9f:cancel:1"}
|
||||
status, first, _ = call(router, "POST", f"/hux/v1/conversations/{CONV}/events", headers, body)
|
||||
assert status == 201 and first["seq"] == 1 and first["identity"]["tenant_slot"] == "slot-3" and first["provenance"]["surface"] == "chat"
|
||||
assert first["provenance"]["actor"]["type"] == "user" and first["detail"] == {"run_id": "run_9f", "outcome": "cancelled", "receipt_id": "rcpt_0001aaaa"}
|
||||
assert [e["kind"] for e in first["evidence"]] == ["approval", "run"]
|
||||
valid(first)
|
||||
status, again, response = call(router, "POST", f"/hux/v1/conversations/{CONV}/events", headers, body)
|
||||
assert status == 200 and again == first and response.headers["HUX-Replayed"] == "true"
|
||||
assert call(router, "POST", f"/hux/v1/conversations/{CONV}/events", HEADERS, {"kind": "run.started", "summary": "x", "seq": 9})[0] == 400
|
||||
assert call(router, "POST", f"/hux/v1/conversations/{CONV}/events", HEADERS, {"kind": "run.started", "summary": "x", "id": "evt_0000"})[0] == 400
|
||||
assert call(router, "POST", f"/hux/v1/conversations/{CONV}/events", HEADERS, {"summary": "x"})[0] == 400
|
||||
assert call(router, "POST", f"/hux/v1/conversations/{CONV}/events", HEADERS, [1])[0] == 400
|
||||
status, body, _ = call(router, "POST", f"/hux/v1/conversations/{CONV}/events", HEADERS, {"kind": "run.started", "summary": "x", "detail": "nope", "evidence": "nope", "turn": "3"})
|
||||
assert status == 201 and body["turn"] == 0 and "detail" not in body
|
||||
reasons = [r["reason"] for r in audit.recent(tenant(tmp_path)) if r["action"] == "events.append" and r["outcome"] == "allow" and r.get("reason")]
|
||||
assert reasons == ["replayed"]
|
||||
|
||||
|
||||
def _sse_records(chunks):
|
||||
out = []
|
||||
for chunk in chunks:
|
||||
for line in chunk.decode().splitlines():
|
||||
if line.startswith("data: "):
|
||||
out.append(json.loads(line[6:]))
|
||||
return out
|
||||
|
||||
|
||||
def test_stream_replays_then_polls_and_resumes_from_last_event_id(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
s = tenant(tmp_path)
|
||||
for n in range(3):
|
||||
events.emit(s, ident(), CONV, "message.user", f"m{n}")
|
||||
status, _, response = call(router, "GET", f"/hux/v1/conversations/{CONV}/events/stream?max_polls=0")
|
||||
chunks = list(response.stream())
|
||||
assert status == 200 and chunks[0] == b"retry: 2000\n\n"
|
||||
assert [r["seq"] for r in _sse_records(chunks)] == [1, 2, 3]
|
||||
assert b"id: 3\nevent: message.user\n" in chunks[3]
|
||||
status, _, response = call(router, "GET", f"/hux/v1/conversations/{CONV}/events/stream?max_polls=1&poll_ms=0", {**HEADERS, "Last-Event-ID": "2"})
|
||||
generator = response.stream()
|
||||
assert next(generator) == b"retry: 2000\n\n"
|
||||
assert _sse_records([next(generator)])[0]["seq"] == 3
|
||||
assert next(generator) == b": keepalive\n\n"
|
||||
events.emit(s, ident(), CONV, "message.assistant", "late")
|
||||
assert _sse_records([next(generator)])[0]["seq"] == 4
|
||||
assert list(generator) == []
|
||||
status, _, response = call(router, "GET", f"/hux/v1/conversations/{CONV}/events/stream?after_seq=4&max_polls=0", {**HEADERS, "X-Hux-Surface": "voice"})
|
||||
assert _sse_records(list(response.stream())) == []
|
||||
|
||||
|
||||
def test_second_stream_closes_the_first(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
events.emit(tenant(tmp_path), ident(), CONV, "message.user", "m")
|
||||
_, _, first = call(router, "GET", f"/hux/v1/conversations/{CONV}/events/stream?max_polls=5&poll_ms=0")
|
||||
first_gen = first.stream()
|
||||
next(first_gen)
|
||||
next(first_gen)
|
||||
_, _, second = call(router, "GET", f"/hux/v1/conversations/{CONV}/events/stream?max_polls=0")
|
||||
list(second.stream())
|
||||
assert list(first_gen) == []
|
||||
|
||||
|
||||
def test_stream_redacts_for_voice_surface(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
events.emit(tenant(tmp_path), ident(), CONV, "decision.route", "r", {"requested": "fast"})
|
||||
_, _, response = call(router, "GET", f"/hux/v1/conversations/{CONV}/events/stream?max_polls=0", {**HEADERS, "X-Hux-Surface": "voice"})
|
||||
records = _sse_records(list(response.stream()))
|
||||
assert "detail" not in records[0] and records[0]["redaction"]["level"] == "partial"
|
||||
|
||||
|
||||
def test_flag_off_hides_event_routes(tmp_path):
|
||||
router = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation"})
|
||||
assert call(router, "GET", f"/hux/v1/conversations/{CONV}/events")[1]["code"] == "flag_off"
|
||||
|
||||
|
||||
def test_events_sources_stay_under_500_lines():
|
||||
for name in ("events.py", "redaction.py"):
|
||||
assert len((FOUNDATION / "hux" / name).read_text().splitlines()) <= 500, name
|
||||
@ -338,3 +338,28 @@ def test_all_errors_serialise_to_contract():
|
||||
def test_foundation_sources_stay_under_500_lines():
|
||||
for path in sorted(FOUNDATION.rglob("*.py")):
|
||||
assert len(path.read_text().splitlines()) <= 500, path
|
||||
|
||||
|
||||
def test_per_route_body_cap(tmp_path):
|
||||
router = _router(tmp_path)
|
||||
router.add("POST", "/hux/v1/big", "HUX-11", "test.big", lambda request: page([len(json.dumps(request.body))]), max_body=4 * 1024 * 1024)
|
||||
payload = json.dumps({"blob": "x" * (2 * 1024 * 1024)}).encode()
|
||||
assert _call(router, "POST", "/hux/v1/big", HEADERS, payload)[0] == 200
|
||||
assert _call(router, "POST", "/hux/v1/echo", HEADERS, payload)[0] == 404
|
||||
router.add("POST", "/hux/v1/small", "HUX-11", "test.small", lambda request: page([]))
|
||||
assert _call(router, "POST", "/hux/v1/small", HEADERS, payload)[1]["code"] == "too_large"
|
||||
|
||||
|
||||
def test_server_main_wires_environment(tmp_path, monkeypatch):
|
||||
from hux import server
|
||||
seen = {}
|
||||
|
||||
class Fake:
|
||||
def serve_forever(self):
|
||||
seen["served"] = True
|
||||
|
||||
monkeypatch.setattr(server, "serve", lambda router, host, port: seen.update(root=router.data_root, host=host, port=port) or Fake())
|
||||
monkeypatch.setenv("HUX_DATA_ROOT", str(tmp_path))
|
||||
monkeypatch.setenv("HUX_PORT", "8791")
|
||||
server.main()
|
||||
assert seen == {"root": tmp_path, "host": "127.0.0.1", "port": 8791, "served": True}
|
||||
|
||||
236
testing/tests/test_hermes_hux_contract_organization.py
Normal file
236
testing/tests/test_hermes_hux_contract_organization.py
Normal file
@ -0,0 +1,236 @@
|
||||
"""HUX-03 projects, conversations, branch lineage and search.
|
||||
|
||||
Security obligations exercised: SO-44 (If-Match on PATCH, 409 on mismatch,
|
||||
unconditional writes audited) plus the storage rules every family shares:
|
||||
ids and provenance are server-set, records the caller does not own are 404,
|
||||
and every served record validates against ``project.schema.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
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))
|
||||
|
||||
import hux # noqa: E402
|
||||
from hux import audit, contracts, identity, organization, 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"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def router(tmp_path):
|
||||
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
|
||||
|
||||
|
||||
def call(router, method, path, body=None, headers=HEADERS):
|
||||
raw = b"" if body is None else json.dumps(body).encode()
|
||||
response = router.dispatch(method, path, headers, raw)
|
||||
return response.status, response.body, response.headers
|
||||
|
||||
|
||||
def valid(record):
|
||||
problems = contracts.validate_record(record, SCHEMAS)
|
||||
assert problems == [], problems
|
||||
return record
|
||||
|
||||
|
||||
def make_project(router, **fields):
|
||||
status, body, _ = call(router, "POST", "/hux/v1/projects", {"name": "Kitchen", **fields})
|
||||
assert status == 201, body
|
||||
return valid(body)
|
||||
|
||||
|
||||
def make_conversation(router, **fields):
|
||||
status, body, _ = call(router, "POST", "/hux/v1/conversations", {"title": "Cabinets", **fields})
|
||||
assert status == 201, body
|
||||
return valid(body)
|
||||
|
||||
|
||||
def test_module_stays_under_the_line_budget():
|
||||
assert len((FOUNDATION / "hux" / "organization.py").read_text().splitlines()) <= 500
|
||||
|
||||
|
||||
# --- projects -------------------------------------------------------------------
|
||||
|
||||
def test_project_create_read_list_and_server_set_fields(router):
|
||||
body = make_project(router, tags=["home"], pinned=True, default_mode="research", id="prj_evil", owner="usr_ffffffffffffffff", revision=9)
|
||||
assert body["id"].startswith("prj_") and body["id"] != "prj_evil" and body["owner"] == HEADERS["X-Hux-Subject"] and body["revision"] == 1
|
||||
assert body["created_at"] == body["updated_at"] and body["archived"] is False
|
||||
status, got, headers = call(router, "GET", f"/hux/v1/projects/{body['id']}")
|
||||
assert (status, got, headers["ETag"]) == (200, body, "1")
|
||||
second = make_project(router, name="Garden")
|
||||
status, listing, _ = call(router, "GET", "/hux/v1/projects")
|
||||
assert [p["id"] for p in listing["items"]] == [body["id"], second["id"]], "pinned first"
|
||||
assert call(router, "GET", "/hux/v1/projects?archived=true")[1]["items"] == []
|
||||
assert call(router, "GET", "/hux/v1/projects?archived=false")[1]["items"] == listing["items"]
|
||||
|
||||
|
||||
def test_project_patch_needs_if_match_and_audits_unconditional_writes(router, tmp_path):
|
||||
project = make_project(router)
|
||||
path = f"/hux/v1/projects/{project['id']}"
|
||||
status, body, headers = call(router, "PATCH", path, {"name": "Kitchen v2", "archived": True, "owner": "usr_ffffffffffffffff"}, {**HEADERS, "If-Match": "1"})
|
||||
assert status == 200 and valid(body)["name"] == "Kitchen v2" and body["archived"] is True and body["revision"] == 2 and headers["ETag"] == "2"
|
||||
assert body["owner"] == project["owner"] and body["updated_at"] >= project["updated_at"]
|
||||
status, error, _ = call(router, "PATCH", path, {"name": "stale"}, {**HEADERS, "If-Match": "1"})
|
||||
assert (status, error["code"]) == (409, "conflict") and valid(error)
|
||||
status, body, _ = call(router, "PATCH", path, {"description": "no If-Match"})
|
||||
assert status == 200 and body["revision"] == 3
|
||||
assert call(router, "PATCH", path, {"name": ""}, {**HEADERS, "If-Match": "3"})[0] == 400
|
||||
assert call(router, "PATCH", path, {"default_mode": "turbo"}, {**HEADERS, "If-Match": "3"})[0] == 400
|
||||
assert call(router, "PATCH", path, [], {**HEADERS, "If-Match": "3"})[0] == 400
|
||||
rows = [(r["action"], r["outcome"], r.get("reason", "")) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))) if r["action"] == "projects.update"]
|
||||
assert rows[0] == ("projects.update", "allow", "") and rows[1][1] == "conflict" and rows[2] == ("projects.update", "allow", "unconditional_write")
|
||||
|
||||
|
||||
def test_project_idempotency_and_cap(router, monkeypatch):
|
||||
key = {**HEADERS, "Idempotency-Key": "chat:project:0001"}
|
||||
status, first, _ = call(router, "POST", "/hux/v1/projects", {"name": "Once"}, key)
|
||||
status, again, _ = call(router, "POST", "/hux/v1/projects", {"name": "Twice"}, key)
|
||||
assert (status, again) == (200, first)
|
||||
status, other, _ = call(router, "POST", "/hux/v1/projects", {"name": "Other key"}, {**HEADERS, "Idempotency-Key": "chat:project:0002"})
|
||||
assert status == 201 and other["id"] != first["id"]
|
||||
assert call(router, "POST", "/hux/v1/projects", {"name": "x" * 121})[0] == 400
|
||||
assert call(router, "POST", "/hux/v1/projects", None)[0] == 400
|
||||
monkeypatch.setattr(organization, "MAX_PROJECTS", 1)
|
||||
status, error, _ = call(router, "POST", "/hux/v1/projects", {"name": "Too many"})
|
||||
assert (status, error["code"]) == (409, "conflict")
|
||||
assert call(router, "POST", "/hux/v1/projects", {"name": "Replays still work"}, key)[0] == 200
|
||||
|
||||
|
||||
def test_projects_are_invisible_to_a_second_subject(router):
|
||||
project = make_project(router)
|
||||
assert call(router, "GET", f"/hux/v1/projects/{project['id']}", headers=OTHER)[0] == 404
|
||||
assert call(router, "PATCH", f"/hux/v1/projects/{project['id']}", {"name": "mine now"}, {**OTHER, "If-Match": "1"})[0] == 404
|
||||
assert call(router, "GET", "/hux/v1/projects", headers=OTHER)[1]["items"] == []
|
||||
assert call(router, "GET", "/hux/v1/projects/prj_missing0001")[0] == 404
|
||||
assert call(router, "GET", "/hux/v1/projects/bad")[0] == 400
|
||||
|
||||
|
||||
# --- conversations -------------------------------------------------------------
|
||||
|
||||
def test_conversation_create_list_filters_and_patch(router):
|
||||
project = make_project(router)
|
||||
first = make_conversation(router, project_id=project["id"], tags=["suppliers"], mode="research")
|
||||
second = make_conversation(router, title="Loose", pinned=True)
|
||||
assert first["project_id"] == project["id"] and first["artifact_ids"] == [] and second.get("project_id") is None
|
||||
assert call(router, "POST", "/hux/v1/conversations", {"title": "Orphan", "project_id": "prj_missing0001"})[0] == 404
|
||||
assert call(router, "POST", "/hux/v1/conversations", {"title": "", "tags": ["Bad Tag"]})[0] == 400
|
||||
items = lambda query: [c["id"] for c in call(router, "GET", f"/hux/v1/conversations{query}")[1]["items"]] # noqa: E731
|
||||
assert items("") == [second["id"], first["id"]]
|
||||
assert items(f"?project_id={project['id']}") == [first["id"]]
|
||||
assert items("?tag=suppliers") == [first["id"]] and items("?tag=nope") == []
|
||||
assert items("?pinned=true") == [second["id"]] and items("?archived=true") == []
|
||||
path = f"/hux/v1/conversations/{first['id']}"
|
||||
status, body, _ = call(router, "PATCH", path, {"title": "Cabinet suppliers", "archived": True, "project_id": None}, {**HEADERS, "If-Match": "1"})
|
||||
assert status == 200 and valid(body)["title"] == "Cabinet suppliers" and "project_id" not in body and body["revision"] == 2
|
||||
assert items("?archived=true") == [first["id"]]
|
||||
assert call(router, "PATCH", path, {"project_id": "prj_missing0001"}, {**HEADERS, "If-Match": "2"})[0] == 404
|
||||
assert call(router, "PATCH", path, {"title": "stale"}, {**HEADERS, "If-Match": "1"})[0] == 409
|
||||
status, body, headers = call(router, "GET", path)
|
||||
assert status == 200 and headers["ETag"] == "2" and valid(body)
|
||||
assert call(router, "GET", path, headers=OTHER)[0] == 404
|
||||
assert call(router, "PATCH", path, {"title": "x"}, {**OTHER, "If-Match": "2"})[0] == 404
|
||||
|
||||
|
||||
def test_helpers_for_other_lanes(router, tmp_path):
|
||||
project = make_project(router)
|
||||
conversation = make_conversation(router, project_id=project["id"])
|
||||
mine = store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))
|
||||
theirs = store.TenantStore(tmp_path, identity.resolve(OTHER, {}))
|
||||
assert organization.project_exists(mine, project["id"]) and not organization.project_exists(theirs, project["id"])
|
||||
assert organization.conversation_exists(mine, conversation["id"]) and not organization.conversation_exists(theirs, conversation["id"])
|
||||
assert not organization.project_exists(mine, "../escape") and not organization.conversation_exists(mine, None)
|
||||
assert organization.project_of(mine, conversation["id"]) == project["id"]
|
||||
assert organization.project_of(mine, None) is None and organization.project_of(mine, "conv_missing0001") is None
|
||||
|
||||
|
||||
# --- branches and lineage ---------------------------------------------------------
|
||||
|
||||
def test_branch_copies_project_tags_and_mode_and_lineage_walks_both_ways(router):
|
||||
project = make_project(router)
|
||||
root = make_conversation(router, project_id=project["id"], tags=["a", "b"], mode="create")
|
||||
status, child, _ = call(router, "POST", f"/hux/v1/conversations/{root['id']}/branch", {"branch_point_message_id": "msg-12"})
|
||||
assert status == 201 and valid(child)["branch"] == {"parent_conversation_id": root["id"], "branch_point_message_id": "msg-12"}
|
||||
assert child["project_id"] == project["id"] and child["tags"] == ["a", "b"] and child["mode"] == "create" and child["title"] == "Cabinets (branch)"
|
||||
status, grandchild, _ = call(router, "POST", f"/hux/v1/conversations/{child['id']}/branch", {"branch_point_message_id": "msg-3", "title": "Deeper"})
|
||||
assert grandchild["title"] == "Deeper"
|
||||
sibling = call(router, "POST", f"/hux/v1/conversations/{root['id']}/branch", {"branch_point_message_id": "msg-1"})[1]
|
||||
status, body, _ = call(router, "GET", f"/hux/v1/conversations/{grandchild['id']}/lineage")
|
||||
assert status == 200 and body["conversation"] == grandchild
|
||||
assert [a["id"] for a in body["ancestors"]] == [root["id"], child["id"]] and body["children"] == []
|
||||
body = call(router, "GET", f"/hux/v1/conversations/{root['id']}/lineage")[1]
|
||||
assert body["ancestors"] == [] and {c["id"] for c in body["children"]} == {child["id"], sibling["id"]}
|
||||
for record in body["children"]:
|
||||
valid(record)
|
||||
assert call(router, "POST", f"/hux/v1/conversations/{root['id']}/branch", {})[0] == 400
|
||||
assert call(router, "POST", f"/hux/v1/conversations/{root['id']}/branch", {"branch_point_message_id": "m"}, OTHER)[0] == 404
|
||||
assert call(router, "GET", f"/hux/v1/conversations/{root['id']}/lineage", headers=OTHER)[0] == 404
|
||||
|
||||
|
||||
def test_lineage_survives_a_missing_or_cyclic_parent(router, tmp_path):
|
||||
orphan = make_conversation(router)
|
||||
mine = store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))
|
||||
mine.put("conversations", {**orphan, "branch": {"parent_conversation_id": "conv_gone00000001", "branch_point_message_id": "m"}})
|
||||
assert call(router, "GET", f"/hux/v1/conversations/{orphan['id']}/lineage")[1]["ancestors"] == []
|
||||
mine.put("conversations", {**mine.get("conversations", orphan["id"]), "branch": {"parent_conversation_id": orphan["id"], "branch_point_message_id": "m"}})
|
||||
body = call(router, "GET", f"/hux/v1/conversations/{orphan['id']}/lineage")[1]
|
||||
assert body["ancestors"] == [] and [c["id"] for c in body["children"]] == [orphan["id"]]
|
||||
|
||||
|
||||
# --- search -----------------------------------------------------------------------
|
||||
|
||||
def test_search_ranks_title_over_tag_over_project_and_says_what_is_indexed(router):
|
||||
kitchen = make_project(router, name="Kitchen renovation")
|
||||
by_title = make_conversation(router, title="Cabinet suppliers compared", project_id=kitchen["id"])
|
||||
by_tag = make_conversation(router, title="Budget", tags=["cabinet"])
|
||||
by_project = make_conversation(router, title="Flooring", project_id=kitchen["id"])
|
||||
make_conversation(router, title="Unrelated")
|
||||
status, body, _ = call(router, "GET", "/hux/v1/search?q=cabinet")
|
||||
assert status == 200 and [c["id"] for c in body["items"]] == [by_title["id"], by_tag["id"]]
|
||||
assert body["indexed"] == ["title", "tags", "project_name", "artifact_titles"] and body["not_indexed"] == ["message_text"]
|
||||
assert body["scores"][by_title["id"]] > body["scores"][by_tag["id"]] and body["next"] is None
|
||||
for record in body["items"]:
|
||||
valid(record)
|
||||
assert [c["id"] for c in call(router, "GET", "/hux/v1/search?q=kitchen")[1]["items"]] == [by_title["id"], by_project["id"]]
|
||||
assert [c["id"] for c in call(router, "GET", "/hux/v1/search?q=cabinet+kitchen")[1]["items"]] == [by_title["id"]], "terms are ANDed"
|
||||
assert call(router, "GET", f"/hux/v1/search?q=kitchen&project_id={kitchen['id']}")[1]["items"] == call(router, "GET", "/hux/v1/search?q=kitchen")[1]["items"]
|
||||
assert call(router, "GET", "/hux/v1/search?q=kitchen&project_id=prj_other0000001")[1]["items"] == []
|
||||
assert call(router, "GET", "/hux/v1/search?q=%21%21")[0] == 400
|
||||
assert call(router, "GET", "/hux/v1/search")[0] == 400
|
||||
assert call(router, "GET", "/hux/v1/search?q=cabinet", headers=OTHER)[1]["items"] == []
|
||||
|
||||
|
||||
def test_search_uses_artifact_titles_when_the_artifacts_lane_offers_them(router, monkeypatch):
|
||||
conversation = make_conversation(router, title="Plain")
|
||||
fake = types.ModuleType("hux.artifacts")
|
||||
fake.artifact_titles = lambda store, conversation_id: ["Supplier comparison sheet"] if conversation_id == conversation["id"] else []
|
||||
monkeypatch.setitem(sys.modules, "hux.artifacts", fake)
|
||||
monkeypatch.setattr(hux, "artifacts", fake, raising=False)
|
||||
assert [c["id"] for c in call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"]] == [conversation["id"]]
|
||||
bare = types.ModuleType("hux.artifacts")
|
||||
monkeypatch.setitem(sys.modules, "hux.artifacts", bare)
|
||||
monkeypatch.setattr(hux, "artifacts", bare, raising=False)
|
||||
assert call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"] == []
|
||||
monkeypatch.setitem(sys.modules, "hux.artifacts", None)
|
||||
monkeypatch.delattr(hux, "artifacts", raising=False)
|
||||
assert call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"] == []
|
||||
assert call(router, "GET", "/hux/v1/search?q=plain")[1]["items"] == [conversation]
|
||||
|
||||
|
||||
def test_flag_off_hides_the_card(tmp_path):
|
||||
off = build_router(tmp_path, {"HUX_FLAGS": ""})
|
||||
status, body, _ = call(off, "GET", "/hux/v1/projects")
|
||||
assert (status, body["code"]) == (404, "flag_off")
|
||||
313
testing/tests/test_hermes_hux_memory_ledger.py
Normal file
313
testing/tests/test_hermes_hux_memory_ledger.py
Normal file
@ -0,0 +1,313 @@
|
||||
"""HUX-02 memory ledger: proposals, consent transitions, edits, If-Match and no-store.
|
||||
|
||||
Security obligations exercised: SO-10 (server-set owner and provenance),
|
||||
SO-15 (server-assigned ids), SO-18 (foreign ids are 404), SO-21 (policy
|
||||
violations suppressed with an event), SO-22 (no_store and forget write a
|
||||
content-free line plus tombstone), SO-24 (forget re-redacts events), SO-25
|
||||
(supersedes of a forgotten id needs a user approval), SO-26 (export scope and
|
||||
audit), SO-28 (private mode refuses writes), SO-35-style human-only decisions,
|
||||
SO-44 (If-Match conflicts, unconditional writes audited).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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, memory, 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"
|
||||
USER = {"proposed_by": "user"}
|
||||
|
||||
|
||||
def ident(**overrides) -> identity.Identity:
|
||||
base = {"tenant_slot": "slot-3", "subject": "usr_0123456789abcdef", "surface": "chat", "trust": "router"}
|
||||
return identity.Identity(**{**base, **overrides})
|
||||
|
||||
|
||||
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, who=None) -> store.TenantStore:
|
||||
return store.TenantStore(tmp_path, who or ident())
|
||||
|
||||
|
||||
def valid(record) -> None:
|
||||
assert contracts.validate_record(record, SCHEMAS) == [], record
|
||||
|
||||
|
||||
def propose(router, headers=HEADERS, **fields):
|
||||
body = {"kind": "preference", "content": "Prefers terse answers with code first.", "reason": "asked twice", "conversation_id": CONV, **fields}
|
||||
return call(router, "POST", "/hux/v1/memory", headers, body)
|
||||
|
||||
|
||||
def event_kinds(tmp_path, conversation=CONV):
|
||||
return [row["kind"] for row in tenant(tmp_path).read(events.FAMILY, conversation)]
|
||||
|
||||
|
||||
# --- proposals ------------------------------------------------------------------
|
||||
|
||||
def test_user_proposal_with_personal_content_is_active_immediately(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
status, body, _ = propose(router, **USER, scope={"level": "project", "scope_id": "prj_0001aaaa"}, source={"kind": "message", "id": "msg-42"}, run_id="run_9f")
|
||||
assert status == 201 and body["status"] == "active" and body["approval_mode"] == "automatic" and body["retrievable"] is True
|
||||
assert body["owner"] == "usr_0123456789abcdef" and body["provenance"]["actor"]["type"] == "user" and body["provenance"]["run_id"] == "run_9f"
|
||||
assert body["ttl"] == {"policy": "decay", "decay_days": 180} and body["topic"] == "general" and body["revision"] == 1
|
||||
assert [row["action"] for row in body["audit"]] == ["proposed", "approved"]
|
||||
valid(body)
|
||||
assert event_kinds(tmp_path) == ["memory.committed"]
|
||||
ledger = tenant(tmp_path).read(memory.FAMILY, memory.LEDGER)
|
||||
assert [row["id"] for row in ledger] == [body["id"]]
|
||||
|
||||
|
||||
def test_assistant_proposal_is_suggest_only(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
status, body, _ = propose(router)
|
||||
assert status == 201 and body["status"] == "proposed" and body["approval_mode"] == "ask" and body["retrievable"] is False
|
||||
assert body["provenance"]["actor"] == {"type": "assistant", "id": "hermes"}
|
||||
valid(body)
|
||||
assert event_kinds(tmp_path) == ["memory.proposed"]
|
||||
status, explicit, _ = propose(router, **USER, approval_mode="ask")
|
||||
assert explicit["status"] == "proposed"
|
||||
|
||||
|
||||
def test_body_supplied_identity_and_ids_are_rejected_or_ignored(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
assert propose(router, id="mem_evil0001")[0] == 400
|
||||
assert propose(router, revision=7)[0] == 400
|
||||
assert propose(router, status="active")[0] == 400
|
||||
assert propose(router, content="")[0] == 400
|
||||
assert propose(router, content="x" * 2001)[0] == 400
|
||||
assert propose(router, sensitivity="ultra")[0] == 400
|
||||
assert call(router, "POST", "/hux/v1/memory", HEADERS, [1])[0] == 400
|
||||
status, body, _ = propose(router, **USER, owner="usr_fedcba9876543210", kind="weird", topic="not-a-topic", ttl="never", scope="bad", source="bad")
|
||||
assert status == 201 and body["owner"] == "usr_0123456789abcdef" and body["kind"] == "fact" and body["scope"] == {"level": "global"}
|
||||
assert body["source"] == {"kind": "message", "id": "unspecified"}
|
||||
|
||||
|
||||
def test_sensitive_content_always_asks_and_decays(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
status, body, _ = propose(router, **USER, content="My therapist suggested a new medication for anxiety.", ttl={"policy": "never"})
|
||||
assert status == 201 and body["status"] == "proposed" and body["approval_mode"] == "ask"
|
||||
assert body["sensitivity"] == "sensitive" and body["topic"] == "health" and body["ttl"] == {"policy": "decay", "decay_days": 30}
|
||||
valid(body)
|
||||
status, body, _ = propose(router, **USER, content="plain", sensitivity="sensitive")
|
||||
assert body["approval_mode"] == "ask" and body["topic"] == "general"
|
||||
|
||||
|
||||
def test_no_store_writes_tombstone_and_content_free_line(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
status, body, _ = propose(router, **USER, content="my password is hunter2hunter2 for the bank")
|
||||
assert status == 202 and body["status"] == "no_store" and body["content"] == "" and body["retrievable"] is False and "topic" not in body
|
||||
assert body["audit"][0]["action"] == "no_store" and "credentials" in body["audit"][0]["note"]
|
||||
valid(body)
|
||||
s = tenant(tmp_path)
|
||||
assert "hunter2" not in json.dumps(s.read(memory.FAMILY, memory.LEDGER))
|
||||
assert body["id"] in memory.tombstoned(s)
|
||||
assert event_kinds(tmp_path) == ["memory.suppressed"]
|
||||
status, declined, _ = propose(router, **USER, approval_mode="no_store", content="plain thing")
|
||||
assert status == 202 and declined["audit"][0]["note"].startswith("declined")
|
||||
status, restricted, _ = propose(router, **USER, sensitivity="restricted", content="plain")
|
||||
assert status == 202 and restricted["audit"][0]["note"].startswith("restricted")
|
||||
status, sensitive_declined, _ = propose(router, **USER, approval_mode="no_store", content="my mortgage rate")
|
||||
assert status == 202 and sensitive_declined["approval_mode"] == "ask"
|
||||
assert memory.retrieve(s, ["password", "mortgage", "plain"]) == []
|
||||
|
||||
|
||||
def test_private_mode_conversation_refuses_memory(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
tenant(tmp_path).put("conversations", {"id": "conv_priv0001", "mode": "private"})
|
||||
status, body, _ = propose(router, **USER, conversation_id="conv_priv0001")
|
||||
assert (status, body["code"]) == (403, "forbidden")
|
||||
assert tenant(tmp_path).count(memory.FAMILY) == 0
|
||||
|
||||
|
||||
def test_policy_violation_is_suppressed_with_event(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
status, body, _ = propose(router, **USER, ttl={"policy": "expires_at"})
|
||||
assert status == 400 and any("expires_at" in d for d in body["details"])
|
||||
assert event_kinds(tmp_path) == ["memory.suppressed"]
|
||||
assert tenant(tmp_path).count(memory.FAMILY) == 0
|
||||
|
||||
|
||||
def test_proposal_idempotency_key_replays(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
propose(router, {**HEADERS, "Idempotency-Key": "conv:mem:0000"}, **USER)
|
||||
headers = {**HEADERS, "Idempotency-Key": "conv:mem:0001"}
|
||||
status, first, _ = propose(router, headers, **USER)
|
||||
status, again, response = propose(router, headers, **USER)
|
||||
assert status == 200 and again == first and response.headers["HUX-Replayed"] == "true"
|
||||
assert tenant(tmp_path).count(memory.FAMILY) == 2
|
||||
|
||||
|
||||
# --- transitions -------------------------------------------------------------------
|
||||
|
||||
def test_approve_reject_and_illegal_transitions(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
_, proposed, _ = propose(router)
|
||||
path = f"/hux/v1/memory/{proposed['id']}"
|
||||
status, approved, response = call(router, "POST", f"{path}/approve", {**HEADERS, "If-Match": "1"})
|
||||
assert status == 200 and approved["status"] == "active" and approved["retrievable"] is True and approved["revision"] == 2
|
||||
assert response.headers["ETag"] == "2" and approved["audit"][-1]["actor"] == {"type": "user", "id": "usr_0123456789abcdef"}
|
||||
valid(approved)
|
||||
status, body, _ = call(router, "POST", f"{path}/approve")
|
||||
assert (status, body["code"]) == (409, "conflict")
|
||||
_, other, _ = propose(router)
|
||||
status, rejected, _ = call(router, "POST", f"/hux/v1/memory/{other['id']}/reject")
|
||||
assert status == 200 and rejected["status"] == "rejected" and rejected["content"] == "" and rejected["retrievable"] is False
|
||||
valid(rejected)
|
||||
assert call(router, "POST", f"/hux/v1/memory/{other['id']}/forget")[0] == 409
|
||||
assert event_kinds(tmp_path) == ["memory.proposed", "memory.committed", "memory.proposed", "memory.suppressed"]
|
||||
|
||||
|
||||
def test_if_match_conflicts_and_unconditional_writes_are_audited(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
_, proposed, _ = propose(router)
|
||||
path = f"/hux/v1/memory/{proposed['id']}"
|
||||
status, body, _ = call(router, "POST", f"{path}/approve", {**HEADERS, "If-Match": "5"})
|
||||
assert (status, body["code"]) == (409, "conflict") and body["details"] == ["1"]
|
||||
assert call(router, "POST", f"{path}/approve", {**HEADERS, "If-Match": "x"})[0] == 400
|
||||
assert call(router, "POST", f"{path}/approve")[0] == 200
|
||||
rows = [(r["action"], r["outcome"], r.get("reason", "")) for r in audit.recent(tenant(tmp_path)) if r["action"] in {"memory.act", "memory.approve"}]
|
||||
assert rows[-3:] == [("memory.act", "conflict", "revision 5 does not match current revision 1"), ("memory.act", "deny", "If-Match must be a revision integer"), ("memory.approve", "allow", "unconditional_write")]
|
||||
|
||||
|
||||
def test_forget_drops_content_tombstones_and_redacts_events(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
_, active, _ = propose(router, **USER)
|
||||
s = tenant(tmp_path)
|
||||
events.emit(s, ident(), CONV, "message.assistant", "used memory", evidence=[{"kind": "memory", "id": active["id"]}])
|
||||
status, forgotten, _ = call(router, "POST", f"/hux/v1/memory/{active['id']}/forget", {**HEADERS, "If-Match": "1"})
|
||||
assert status == 200 and forgotten["status"] == "forgotten" and forgotten["content"] == "" and forgotten["retrievable"] is False
|
||||
valid(forgotten)
|
||||
assert active["id"] in memory.tombstoned(s)
|
||||
rows = s.read(events.FAMILY, CONV)
|
||||
assert [r["kind"] for r in rows] == ["memory.committed", "message.assistant", "memory.forgotten"]
|
||||
assert rows[0]["redaction"]["level"] == "full" and rows[1]["redaction"]["level"] == "full"
|
||||
assert call(router, "GET", f"/hux/v1/memory/{active['id']}")[1]["status"] == "forgotten"
|
||||
assert call(router, "POST", f"/hux/v1/memory/{active['id']}/forget")[0] == 409
|
||||
|
||||
|
||||
def test_edit_supersedes_and_forgets_the_old_entry(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
_, active, _ = propose(router, **USER)
|
||||
status, fresh, _ = call(router, "POST", f"/hux/v1/memory/{active['id']}/edit", {**HEADERS, "If-Match": "1"}, {"content": "Prefers long answers now."})
|
||||
assert status == 200 and fresh["id"] != active["id"] and fresh["supersedes"] == active["id"] and fresh["status"] == "active"
|
||||
assert fresh["content"] == "Prefers long answers now." and [a["action"] for a in fresh["audit"]] == ["edited", "approved"]
|
||||
valid(fresh)
|
||||
old = tenant(tmp_path).get(memory.FAMILY, active["id"])
|
||||
assert old["status"] == "forgotten" and old["content"] == "" and old["audit"][-1]["action"] == "superseded"
|
||||
assert call(router, "POST", f"/hux/v1/memory/{fresh['id']}/edit", HEADERS, {"content": ""})[0] == 400
|
||||
assert call(router, "POST", f"/hux/v1/memory/{fresh['id']}/edit", HEADERS)[0] == 400
|
||||
assert event_kinds(tmp_path)[-1] == "memory.committed"
|
||||
|
||||
|
||||
def test_edit_of_sensitive_entry_goes_back_to_ask(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
_, proposed, _ = propose(router, **USER, content="my mortgage is with the bank")
|
||||
_, active, _ = call(router, "POST", f"/hux/v1/memory/{proposed['id']}/approve")
|
||||
status, fresh, _ = call(router, "POST", f"/hux/v1/memory/{active['id']}/edit", HEADERS, {"content": "refinanced the mortgage"})
|
||||
assert status == 200 and fresh["status"] == "proposed" and fresh["approval_mode"] == "ask" and fresh["retrievable"] is False
|
||||
valid(fresh)
|
||||
|
||||
|
||||
def test_supersedes_of_forgotten_id_requires_ask(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
_, active, _ = propose(router, **USER)
|
||||
call(router, "POST", f"/hux/v1/memory/{active['id']}/forget")
|
||||
status, body, _ = propose(router, **USER, supersedes=active["id"])
|
||||
assert status == 201 and body["status"] == "proposed" and body["approval_mode"] == "ask" and body["supersedes"] == active["id"]
|
||||
status, body, _ = propose(router, **USER, source={"kind": "memory", "id": active["id"]})
|
||||
assert body["approval_mode"] == "ask"
|
||||
|
||||
|
||||
def test_retrieval_removal_and_restore(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
_, active, _ = propose(router, **USER)
|
||||
path = f"/hux/v1/memory/{active['id']}"
|
||||
status, removed, _ = call(router, "POST", f"{path}/remove_retrieval")
|
||||
assert status == 200 and removed["status"] == "active" and removed["retrievable"] is False and removed["audit"][-1]["action"] == "retrieval_removed"
|
||||
valid(removed)
|
||||
assert memory.retrieve(tenant(tmp_path), []) == []
|
||||
status, restored, _ = call(router, "POST", f"{path}/restore_retrieval", {**HEADERS, "If-Match": "2"})
|
||||
assert status == 200 and restored["retrievable"] is True and restored["audit"][-1]["note"] == "retrieval restored"
|
||||
assert [m["id"] for m in memory.retrieve(tenant(tmp_path), [])] == [active["id"]]
|
||||
_, proposed, _ = propose(router)
|
||||
assert call(router, "POST", f"/hux/v1/memory/{proposed['id']}/remove_retrieval")[0] == 409
|
||||
assert event_kinds(tmp_path)[:3] == ["memory.committed", "memory.retrieval_removed", "memory.committed"]
|
||||
|
||||
|
||||
def test_decisions_need_a_human_surface(tmp_path):
|
||||
router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk"})
|
||||
_, proposed, _ = propose(router)
|
||||
worker = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
|
||||
status, body, _ = call(router, "POST", f"/hux/v1/memory/{proposed['id']}/approve", worker)
|
||||
assert (status, body["code"]) == (403, "forbidden")
|
||||
api = {**HEADERS, "X-Hux-Surface": "api"}
|
||||
assert call(router, "POST", f"/hux/v1/memory/{proposed['id']}/approve", api)[0] == 403
|
||||
status, body, _ = propose(router, worker, proposed_by="user")
|
||||
assert body["provenance"]["actor"]["type"] == "assistant"
|
||||
|
||||
|
||||
# --- reads, ownership, export ------------------------------------------------------
|
||||
|
||||
def test_list_get_and_cross_tenant_denial(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
_, active, _ = propose(router, **USER, scope={"level": "project", "scope_id": "prj_0001aaaa"})
|
||||
_, proposed, _ = propose(router)
|
||||
status, body, _ = call(router, "GET", "/hux/v1/memory")
|
||||
assert status == 200 and {m["id"] for m in body["items"]} == {active["id"], proposed["id"]}
|
||||
for item in body["items"]:
|
||||
valid(item)
|
||||
assert [m["id"] for m in call(router, "GET", "/hux/v1/memory?status=proposed")[1]["items"]] == [proposed["id"]]
|
||||
assert [m["id"] for m in call(router, "GET", "/hux/v1/memory?scope=project:prj_0001aaaa")[1]["items"]] == [active["id"]]
|
||||
assert call(router, "GET", "/hux/v1/memory?scope=project:prj_other000")[1]["items"] == []
|
||||
assert call(router, "GET", "/hux/v1/memory?scope=global")[1]["items"][0]["id"] == proposed["id"]
|
||||
status, got, response = call(router, "GET", f"/hux/v1/memory/{active['id']}")
|
||||
assert status == 200 and got == active and response.headers["ETag"] == "1"
|
||||
assert call(router, "GET", f"/hux/v1/memory/{active['id']}", OTHER)[0] == 404
|
||||
assert call(router, "POST", f"/hux/v1/memory/{active['id']}/forget", OTHER)[0] == 404
|
||||
assert call(router, "GET", "/hux/v1/memory", OTHER)[1]["items"] == []
|
||||
assert call(router, "GET", "/hux/v1/memory/not-an-id")[0] == 404
|
||||
assert call(router, "GET", "/hux/v1/memory/mem_missing00")[0] == 404
|
||||
assert call(router, "POST", f"/hux/v1/memory/{active['id']}/explode")[0] == 404
|
||||
assert call(router, "POST", "/hux/v1/memory/mem_missing00/approve")[0] == 404
|
||||
|
||||
|
||||
def test_export_is_active_only_and_audited(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
_, active, _ = propose(router, **USER)
|
||||
_, hidden, _ = propose(router, **USER)
|
||||
call(router, "POST", f"/hux/v1/memory/{hidden['id']}/remove_retrieval")
|
||||
propose(router)
|
||||
_, gone, _ = propose(router, **USER)
|
||||
call(router, "POST", f"/hux/v1/memory/{gone['id']}/forget")
|
||||
status, body, response = call(router, "GET", "/hux/v1/memory/export")
|
||||
assert status == 200 and [m["id"] for m in body["items"]] == [active["id"]]
|
||||
assert body["items"][0]["audit"][-1]["action"] == "exported" and body["items"][0]["revision"] == 2
|
||||
assert response.headers["Content-Disposition"].startswith("attachment")
|
||||
valid(body["items"][0])
|
||||
assert tenant(tmp_path).read(memory.FAMILY, "exports")[0]["ids"] == [active["id"]]
|
||||
assert call(router, "GET", "/hux/v1/memory/export", OTHER)[1]["items"] == []
|
||||
|
||||
|
||||
def test_memory_module_stays_under_500_lines():
|
||||
assert len((FOUNDATION / "hux" / "memory.py").read_text().splitlines()) <= 500
|
||||
151
testing/tests/test_hermes_hux_memory_retrieval.py
Normal file
151
testing/tests/test_hermes_hux_memory_retrieval.py
Normal file
@ -0,0 +1,151 @@
|
||||
"""HUX-02 retrieval hook: what the agent may read back from the memory ledger.
|
||||
|
||||
Security obligations exercised: SO-22 and SO-23 (tombstones and memory_disabled
|
||||
consulted before the index; only active, retrievable entries are served; no_store,
|
||||
forgotten, rejected and expired never surface), SO-27 (lazy expiry at read).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
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 contracts, events, identity, memory, privacy, 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"}
|
||||
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
|
||||
|
||||
|
||||
def tenant(tmp_path) -> store.TenantStore:
|
||||
return store.TenantStore(tmp_path, ident())
|
||||
|
||||
|
||||
def remember(router, content, conversation_id=CONV, **fields):
|
||||
body = {"kind": "fact", "content": content, "reason": "user said so", "proposed_by": "user", "conversation_id": conversation_id, **fields}
|
||||
status, record = call(router, "POST", "/hux/v1/memory", HEADERS, body)
|
||||
assert status in (201, 202), record
|
||||
return record
|
||||
|
||||
|
||||
def ids(rows):
|
||||
return [row["id"] for row in rows]
|
||||
|
||||
|
||||
def test_do_not_remember_never_persists_nor_retrieves(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
kept = remember(router, "Likes espresso in the morning.")
|
||||
declined = remember(router, "Do not remember: I take espresso with sugar.", approval_mode="no_store")
|
||||
s = tenant(tmp_path)
|
||||
assert declined["status"] == "no_store" and declined["id"] in memory.tombstoned(s)
|
||||
assert "sugar" not in json.dumps(list(s.scan(memory.FAMILY))) and "sugar" not in json.dumps(s.read(memory.FAMILY, memory.LEDGER))
|
||||
assert ids(memory.retrieve(s, ["espresso"])) == [kept["id"]]
|
||||
assert ids(memory.retrieve(s, ["sugar"])) == []
|
||||
for record in memory.retrieve(s, []):
|
||||
assert contracts.validate_record(record, SCHEMAS) == []
|
||||
|
||||
|
||||
def test_tombstone_wins_even_when_the_document_says_active(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
kept = remember(router, "Team standup is at nine.")
|
||||
s = tenant(tmp_path)
|
||||
s.append(memory.FAMILY, memory.TOMBSTONES, {"memory_id": kept["id"], "at": "2026-08-23T00:00:00Z", "reason": "test", "purged": False})
|
||||
assert memory.retrieve(s, ["standup"]) == []
|
||||
assert call(router, "GET", "/hux/v1/memory/export")[1]["items"] == []
|
||||
|
||||
|
||||
def test_only_active_and_retrievable_entries_surface(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
active = remember(router, "alpha fact")
|
||||
proposed = call(router, "POST", "/hux/v1/memory", HEADERS, {"kind": "fact", "content": "beta fact", "reason": "r"})[1]
|
||||
rejected = call(router, "POST", "/hux/v1/memory", HEADERS, {"kind": "fact", "content": "gamma fact", "reason": "r"})[1]
|
||||
call(router, "POST", f"/hux/v1/memory/{rejected['id']}/reject")
|
||||
forgotten = remember(router, "delta fact")
|
||||
call(router, "POST", f"/hux/v1/memory/{forgotten['id']}/forget")
|
||||
hidden = remember(router, "epsilon fact")
|
||||
call(router, "POST", f"/hux/v1/memory/{hidden['id']}/remove_retrieval")
|
||||
s = tenant(tmp_path)
|
||||
assert ids(memory.retrieve(s, ["fact"])) == [active["id"]]
|
||||
assert ids(memory.retrieve(s, [])) == [active["id"]]
|
||||
assert proposed["status"] == "proposed"
|
||||
|
||||
|
||||
def test_expired_entries_are_never_served_as_active(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
fresh = remember(router, "still valid", ttl={"policy": "expires_at", "expires_at": "2999-01-01T00:00:00Z"})
|
||||
stale = remember(router, "already gone", ttl={"policy": "expires_at", "expires_at": "2020-01-01T00:00:00Z"})
|
||||
forever = remember(router, "kept forever", ttl={"policy": "never"})
|
||||
s = tenant(tmp_path)
|
||||
assert ids(memory.retrieve(s, [])) == [forever["id"], fresh["id"]] or set(ids(memory.retrieve(s, []))) == {forever["id"], fresh["id"]}
|
||||
expired = s.get(memory.FAMILY, stale["id"])
|
||||
assert expired["status"] == "expired" and expired["content"] == "" and expired["audit"][-1]["actor"] == {"type": "system", "id": "retention"}
|
||||
assert contracts.validate_record(expired, SCHEMAS) == []
|
||||
status, body = call(router, "GET", f"/hux/v1/memory/{stale['id']}")
|
||||
assert body["status"] == "expired"
|
||||
assert memory.effective_expiry(forever) is None
|
||||
decayed = remember(router, "decays", ttl={"policy": "decay", "decay_days": 1})
|
||||
far = datetime.now(timezone.utc) + timedelta(days=2)
|
||||
assert memory.load(s, decayed["id"], far)["status"] == "expired"
|
||||
assert [m["status"] for m in call(router, "GET", "/hux/v1/memory?status=expired")[1]["items"]] == ["expired", "expired"]
|
||||
|
||||
|
||||
def test_scope_and_query_filters(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
everywhere = remember(router, "global note about coffee")
|
||||
project = remember(router, "project note about coffee", scope={"level": "project", "scope_id": "prj_0001aaaa"})
|
||||
other = remember(router, "other project coffee", scope={"level": "project", "scope_id": "prj_0002aaaa"})
|
||||
s = tenant(tmp_path)
|
||||
assert set(ids(memory.retrieve(s, ["coffee"], {"level": "project", "scope_id": "prj_0001aaaa"}))) == {everywhere["id"], project["id"]}
|
||||
assert set(ids(memory.retrieve(s, ["Coffee", ""], None))) == {everywhere["id"], project["id"], other["id"]}
|
||||
assert ids(memory.retrieve(s, ["tea"])) == []
|
||||
|
||||
|
||||
def test_blocked_conversations_contribute_and_receive_nothing(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
scoped = remember(router, "said in the disabled chat", "conv_0002abcd")
|
||||
elsewhere = remember(router, "said elsewhere")
|
||||
s = tenant(tmp_path)
|
||||
privacy.set_flag(s, "conv_0002abcd", "memory_disabled", True)
|
||||
assert ids(memory.retrieve(s, ["said"])) == [elsewhere["id"]]
|
||||
assert memory.retrieve(s, ["said"], {"level": "conversation", "scope_id": "conv_0002abcd"}) == []
|
||||
assert scoped["status"] == "active"
|
||||
later = call(router, "POST", "/hux/v1/memory", HEADERS, {"kind": "fact", "content": "after disable", "reason": "r", "proposed_by": "user", "conversation_id": "conv_0002abcd"})
|
||||
assert later[0] == 202 and later[1]["status"] == "no_store" and later[1]["audit"][0]["note"].startswith("memory_disabled")
|
||||
|
||||
|
||||
def test_forgetting_a_conversation_removes_its_memory_from_retrieval(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
doomed = remember(router, "doomed detail", "conv_0003abcd")
|
||||
pending = call(router, "POST", "/hux/v1/memory", HEADERS, {"kind": "fact", "content": "pending detail", "reason": "r", "conversation_id": "conv_0003abcd"})[1]
|
||||
survivor = remember(router, "survivor detail")
|
||||
status, body = call(router, "POST", "/hux/v1/conversations/conv_0003abcd/forget")
|
||||
assert status == 200 and body["memory_forgotten"] == 2 and body["forgotten"] is True
|
||||
s = tenant(tmp_path)
|
||||
assert all(r["redaction"]["level"] == "full" for r in s.read(events.FAMILY, "conv_0003abcd"))
|
||||
assert ids(memory.retrieve(s, ["detail"])) == [survivor["id"]]
|
||||
assert s.get(memory.FAMILY, doomed["id"])["status"] == "forgotten" and s.get(memory.FAMILY, pending["id"])["status"] == "rejected"
|
||||
assert {doomed["id"], pending["id"]} <= memory.tombstoned(s)
|
||||
assert call(router, "POST", "/hux/v1/memory", HEADERS, {"kind": "fact", "content": "late", "reason": "r", "proposed_by": "user", "conversation_id": "conv_0003abcd"})[1]["status"] == "no_store"
|
||||
309
testing/tests/test_hermes_hux_policy_approvals.py
Normal file
309
testing/tests/test_hermes_hux_policy_approvals.py
Normal file
@ -0,0 +1,309 @@
|
||||
"""HUX-05 approval queue and the pre-side-effect gate.
|
||||
|
||||
Security obligations exercised: SO-35 (only a human surface with router or
|
||||
relay trust decides; ``decision.by`` is the asserted user), SO-36 (``once``
|
||||
is consumed exactly once), SO-37 (the gate must present the argument hash
|
||||
recorded at request time), SO-39 (external side effects always need an
|
||||
approval record), SO-40 (an exhausted budget blocks new approvals), SO-42
|
||||
(pending approvals expire 24 h after ``requested_at``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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))
|
||||
|
||||
import hux # noqa: E402
|
||||
from hux import audit, budgets, contracts, identity, policy, store # noqa: E402
|
||||
from hux import events as hux_events # 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"}
|
||||
WORKER = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
|
||||
RELAY = {**HEADERS, "X-Hux-Surface": "telegram", "X-Hux-Trust": "relay", "X-Hux-Relay-Key": "rk"}
|
||||
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
||||
T0 = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc)
|
||||
CONV = "conv_0001abcd"
|
||||
HASH = "sha256:" + "ab" * 32
|
||||
OTHER_HASH = "sha256:" + "cd" * 32
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def router(tmp_path):
|
||||
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk", "HUX_RELAY_KEY": "rk"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def frozen(monkeypatch):
|
||||
state = {"now": T0}
|
||||
monkeypatch.setattr(policy, "clock", lambda: state["now"])
|
||||
return state
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def events(monkeypatch):
|
||||
"""Stand-in for the events lane; records every emit call."""
|
||||
seen: list[dict] = []
|
||||
monkeypatch.setattr(hux_events, "emit", lambda store, identity, conversation_id, kind, summary, **extra: seen.append({"conversation_id": conversation_id, "kind": kind, "summary": summary, **extra}))
|
||||
return seen
|
||||
|
||||
|
||||
def call(router, method, path, body=None, headers=HEADERS):
|
||||
raw = b"" if body is None else json.dumps(body).encode()
|
||||
response = router.dispatch(method, path, headers, raw)
|
||||
return response.status, response.body
|
||||
|
||||
|
||||
def valid(record):
|
||||
problems = contracts.validate_record(record, SCHEMAS)
|
||||
assert problems == [], problems
|
||||
return record
|
||||
|
||||
|
||||
def request_body(capability="write_files", external=False, run_id="run_9f", **extra):
|
||||
evidence = [{"kind": "tool_call", "id": "call-7", "hash": HASH}]
|
||||
return {"run_id": run_id, "conversation_id": CONV, "capability": capability,
|
||||
"request": {"summary": f"do {capability}", "risk": "low", "external": external, "evidence": evidence, **extra}}
|
||||
|
||||
|
||||
def set_autonomy(router, level):
|
||||
status, body = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": level})
|
||||
assert status == 200, body
|
||||
|
||||
|
||||
# --- creation resolves against the matrix ------------------------------------------
|
||||
|
||||
def test_safe_policy_auto_allows_reads_and_queues_mutations(router, frozen, events):
|
||||
status, body = call(router, "POST", "/hux/v1/approvals", request_body("read_files"), WORKER)
|
||||
assert status == 201 and valid(body)["status"] == "approved"
|
||||
assert body["decision"] == {"choice": "once", "by": {"type": "system", "id": "policy"}, "at": policy.iso(T0)}
|
||||
status, body = call(router, "POST", "/hux/v1/approvals", request_body("write_files"), WORKER)
|
||||
assert status == 201 and valid(body)["status"] == "pending" and "decision" not in body
|
||||
assert body["expires_at"] == policy.iso(T0 + timedelta(hours=24))
|
||||
status, body = call(router, "POST", "/hux/v1/approvals", request_body("network"), WORKER)
|
||||
assert status == 201 and valid(body)["status"] == "denied" and body["decision"]["choice"] == "deny"
|
||||
assert [e["kind"] for e in events] == ["approval.resolved", "approval.requested", "approval.resolved"]
|
||||
assert events[0]["evidence"] == [{"kind": "approval", "id": events[0]["evidence"][0]["id"]}]
|
||||
|
||||
|
||||
def test_external_side_effects_always_need_a_human_regardless_of_autonomy(router, events):
|
||||
set_autonomy(router, "autonomous")
|
||||
status, body = call(router, "POST", "/hux/v1/approvals", request_body("send_message"), WORKER)
|
||||
assert status == 201 and body["status"] == "approved"
|
||||
status, body = call(router, "POST", "/hux/v1/approvals", request_body("send_message", external=True), WORKER)
|
||||
assert status == 201 and body["status"] == "pending"
|
||||
for always_ask in ("deploy", "external_side_effect"):
|
||||
status, body = call(router, "POST", "/hux/v1/approvals", request_body(always_ask), WORKER)
|
||||
assert body["status"] == "pending", always_ask
|
||||
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "autonomous", "grants": [{"capability": "network", "decision": "deny"}]})
|
||||
status, body = call(router, "POST", "/hux/v1/approvals", request_body("network", external=True), WORKER)
|
||||
assert body["status"] == "denied", "deny beats external ask"
|
||||
|
||||
|
||||
def test_idempotency_key_returns_the_original(router):
|
||||
key = {"Idempotency-Key": "run_9f:approval:call-7"}
|
||||
status, first = call(router, "POST", "/hux/v1/approvals", request_body(), {**WORKER, **key})
|
||||
assert status == 201 and first["idempotency_key"] == "run_9f:approval:call-7"
|
||||
status, again = call(router, "POST", "/hux/v1/approvals", request_body("shell"), {**WORKER, **key})
|
||||
assert (status, again) == (200, first)
|
||||
status, other = call(router, "POST", "/hux/v1/approvals", request_body(), {**WORKER, "Idempotency-Key": "run_9f:approval:call-8"})
|
||||
assert status == 201 and other["id"] != first["id"]
|
||||
assert call(router, "GET", f"/hux/v1/approvals/{first['id']}")[1] == first
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,status", [
|
||||
(None, 400), ([], 400), ({"conversation_id": "nope", "capability": "shell"}, 400),
|
||||
({"conversation_id": CONV, "capability": "teleport"}, 400),
|
||||
({"conversation_id": CONV, "capability": "shell", "run_id": "r"}, 400),
|
||||
({"conversation_id": CONV, "capability": "shell", "run_id": "r", "request": {"summary": "", "risk": "low"}}, 400),
|
||||
({"conversation_id": CONV, "capability": "shell", "run_id": "r", "request": {"summary": "x", "risk": "silly"}}, 400),
|
||||
])
|
||||
def test_create_rejects_bad_bodies(router, body, status):
|
||||
got, error = call(router, "POST", "/hux/v1/approvals", body, WORKER)
|
||||
assert got == status and valid(error)["code"] == "invalid"
|
||||
|
||||
|
||||
# --- deciding -----------------------------------------------------------------------
|
||||
|
||||
def pending(router, capability="write_files", **kw):
|
||||
status, body = call(router, "POST", "/hux/v1/approvals", request_body(capability, **kw), WORKER)
|
||||
assert status == 201 and body["status"] == "pending", body
|
||||
return body
|
||||
|
||||
|
||||
def test_only_humans_on_router_or_relay_decide(router, tmp_path):
|
||||
record = pending(router)
|
||||
status, error = call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"}, WORKER)
|
||||
assert (status, error["code"]) == (403, "forbidden")
|
||||
api = {**HEADERS, "X-Hux-Surface": "api"}
|
||||
assert call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"}, api)[0] == 403
|
||||
status, body = call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"}, RELAY)
|
||||
assert status == 200 and valid(body)["status"] == "approved"
|
||||
assert body["decision"]["by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]}
|
||||
rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {})))
|
||||
assert [(r["action"], r["outcome"]) for r in rows if r["action"] == "approvals.decide"] == [("approvals.decide", "deny")] * 2 + [("approvals.decide", "allow")]
|
||||
|
||||
|
||||
def test_terminal_states_never_change_again(router, events):
|
||||
record = pending(router)
|
||||
path = f"/hux/v1/approvals/{record['id']}"
|
||||
assert call(router, "POST", path, {"choice": "maybe"})[0] == 400
|
||||
assert call(router, "POST", path, [])[0] == 400
|
||||
status, body = call(router, "POST", path, {"choice": "deny"})
|
||||
assert status == 200 and body["status"] == "denied" and body["decision"]["choice"] == "deny"
|
||||
status, error = call(router, "POST", path, {"choice": "once"})
|
||||
assert (status, error["code"]) == (409, "conflict")
|
||||
assert call(router, "GET", path)[1] == body
|
||||
assert events[-1]["kind"] == "approval.resolved" and "denied" in events[-1]["summary"]
|
||||
|
||||
|
||||
def test_pending_approvals_expire_after_24h(router, frozen):
|
||||
record = pending(router)
|
||||
frozen["now"] = T0 + timedelta(hours=24)
|
||||
status, body = call(router, "GET", f"/hux/v1/approvals/{record['id']}")
|
||||
assert status == 200 and valid(body)["status"] == "expired"
|
||||
assert call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})[0] == 409
|
||||
assert call(router, "GET", "/hux/v1/approvals?status=expired")[1]["items"] == [body]
|
||||
assert call(router, "GET", "/hux/v1/approvals?status=pending")[1]["items"] == []
|
||||
|
||||
|
||||
def test_session_and_always_create_grants_at_their_scope(router, frozen):
|
||||
record = pending(router, "shell")
|
||||
status, body = call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
|
||||
assert status == 200 and body["decision"]["choice"] == "session"
|
||||
conv_policy = call(router, "GET", f"/hux/v1/policy?scope=conversation&scope_id={CONV}")[1]
|
||||
assert valid(conv_policy)["scope"] == {"level": "conversation", "scope_id": CONV}
|
||||
assert conv_policy["grants"] == [{"capability": "shell", "decision": "allow", "expires_at": policy.iso(T0 + policy.SESSION_TTL), "granted_by": {"type": "user", "id": HEADERS["X-Hux-Subject"]}}]
|
||||
status, body = call(router, "POST", "/hux/v1/approvals", request_body("shell"), WORKER)
|
||||
assert body["status"] == "approved", "later shell requests in this conversation auto-approve"
|
||||
assert call(router, "POST", "/hux/v1/approvals", request_body("write_files"), WORKER)[1]["status"] == "pending"
|
||||
record = pending(router, "write_files")
|
||||
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "always"})
|
||||
global_policy = call(router, "GET", "/hux/v1/policy")[1]
|
||||
assert [g["capability"] for g in global_policy["grants"]] == ["write_files"]
|
||||
assert global_policy["grants"][0]["expires_at"] == policy.iso(T0 + policy.ALWAYS_TTL)
|
||||
frozen["now"] = T0 + timedelta(hours=25)
|
||||
record = pending(router, "shell", run_id="run_other")
|
||||
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
|
||||
conv_policy = call(router, "GET", f"/hux/v1/policy?scope=conversation&scope_id={CONV}")[1]
|
||||
assert len(conv_policy["grants"]) == 1 and conv_policy["revision"] == 2, "a repeat grant replaces, never duplicates"
|
||||
|
||||
|
||||
def test_list_filters_and_orders_by_request_time(router, frozen):
|
||||
first = pending(router, "shell")
|
||||
frozen["now"] = T0 + timedelta(seconds=5)
|
||||
second = pending(router, "write_files")
|
||||
status, body = call(router, "GET", "/hux/v1/approvals")
|
||||
assert status == 200 and [i["id"] for i in body["items"]] == [first["id"], second["id"]] and body["next"] is None
|
||||
assert call(router, "GET", "/hux/v1/approvals?status=pending")[1]["items"] == body["items"]
|
||||
assert call(router, "GET", "/hux/v1/approvals?status=approved")[1]["items"] == []
|
||||
assert call(router, "GET", "/hux/v1/approvals?status=bogus")[0] == 400
|
||||
for item in body["items"]:
|
||||
valid(item)
|
||||
|
||||
|
||||
def test_second_subject_cannot_see_or_decide(router):
|
||||
record = pending(router)
|
||||
assert call(router, "GET", f"/hux/v1/approvals/{record['id']}", headers=OTHER)[0] == 404
|
||||
assert call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"}, OTHER)[0] == 404
|
||||
assert call(router, "GET", "/hux/v1/approvals", headers=OTHER)[1]["items"] == []
|
||||
assert call(router, "GET", "/hux/v1/approvals/apr_doesnotexist")[0] == 404
|
||||
assert call(router, "GET", "/hux/v1/approvals/..")[0] == 400
|
||||
|
||||
|
||||
# --- budgets block new approvals -------------------------------------------------------
|
||||
|
||||
def test_exhausted_budget_blocks_new_approvals(router, events):
|
||||
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe", "budgets": {"tool_calls_per_run": 2}})
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_9f/budget", {"conversation_id": CONV, "tool_calls": 2}, WORKER)
|
||||
assert status == 200 and valid(body)["exhausted"] == ["tool_calls_per_run"]
|
||||
status, error = call(router, "POST", "/hux/v1/approvals", request_body("read_files"), WORKER)
|
||||
assert (status, error["code"]) == (429, "budget_exhausted") and error["details"] == ["tool_calls_per_run"]
|
||||
assert [e["kind"] for e in events] == ["budget.exhausted", "budget.exhausted"]
|
||||
status, body = call(router, "POST", "/hux/v1/approvals", request_body("read_files", run_id="run_fresh"), WORKER)
|
||||
assert status == 201, "another run keeps its own budget"
|
||||
|
||||
|
||||
# --- gate --------------------------------------------------------------------------
|
||||
|
||||
def gate(router, run_id="run_9f", capability="write_files", argument_hash=HASH, external=False, **extra):
|
||||
return call(router, "POST", f"/hux/v1/runs/{run_id}/gate", {"capability": capability, "argument_hash": argument_hash, "external": external, **extra}, WORKER)
|
||||
|
||||
|
||||
def test_gate_blocks_before_approval_and_releases_once_exactly_once(router, events, tmp_path):
|
||||
status, body = gate(router)
|
||||
assert status == 200 and body == {"proceed": False, "reason": "no approval for this run and capability"}
|
||||
record = pending(router)
|
||||
assert gate(router)[1]["proceed"] is False and "pending" in gate(router)[1]["reason"]
|
||||
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})
|
||||
status, wrong = gate(router, argument_hash=OTHER_HASH)
|
||||
assert wrong["proceed"] is False and "different arguments" in wrong["reason"], "TOCTOU (SO-37)"
|
||||
status, body = gate(router)
|
||||
assert body == {"proceed": True, "approval_id": record["id"], "reason": "released"}
|
||||
status, again = gate(router)
|
||||
assert again["proceed"] is False and "already consumed" in again["reason"], "once is once (SO-36)"
|
||||
kinds = [e["kind"] for e in events]
|
||||
assert kinds.count("side_effect.released") == 1 and kinds.count("side_effect.blocked") == 4, "the first block predates any approval, so no conversation is known"
|
||||
released = next(e for e in events if e["kind"] == "side_effect.released")
|
||||
assert released["evidence"] == [{"kind": "approval", "id": record["id"]}] and released["run_id"] == "run_9f"
|
||||
served = call(router, "GET", f"/hux/v1/approvals/{record['id']}")[1]
|
||||
assert valid(served) and "_consumed_at" not in served
|
||||
rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {})))
|
||||
assert [r["outcome"] for r in rows if r["action"] == "gate.check"] == ["deny", "deny", "deny", "deny", "allow", "deny"]
|
||||
|
||||
|
||||
def test_gate_session_is_reusable_within_the_conversation(router):
|
||||
record = pending(router, "shell")
|
||||
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
|
||||
assert gate(router, capability="shell")[1]["proceed"] is True
|
||||
assert gate(router, capability="shell", argument_hash=OTHER_HASH)[1]["proceed"] is True
|
||||
assert gate(router, run_id="run_later", capability="shell", conversation_id=CONV)[1]["proceed"] is True
|
||||
assert gate(router, run_id="run_later", capability="shell")[1]["proceed"] is False
|
||||
assert gate(router, run_id="run_later", capability="shell", conversation_id="conv_elsewhere01")[1]["proceed"] is False
|
||||
assert gate(router, capability="write_files")[1]["proceed"] is False
|
||||
|
||||
|
||||
def test_gate_requires_external_approvals_for_external_effects_and_respects_expiry(router, frozen):
|
||||
record = pending(router, "send_message")
|
||||
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})
|
||||
status, body = gate(router, capability="send_message", external=True)
|
||||
assert body["proceed"] is False and "not requested as external" in body["reason"]
|
||||
record = pending(router, "send_message", external=True)
|
||||
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})
|
||||
frozen["now"] = T0 + timedelta(hours=25)
|
||||
status, body = gate(router, capability="send_message", external=True)
|
||||
assert body["proceed"] is False and "expired" in body["reason"]
|
||||
|
||||
|
||||
def test_gate_rejects_bad_bodies_and_other_tenants(router):
|
||||
assert gate(router, capability="teleport")[0] == 400
|
||||
assert gate(router, argument_hash="md5:zz")[0] == 400
|
||||
assert gate(router, conversation_id="nope")[0] == 400
|
||||
assert call(router, "POST", "/hux/v1/runs/run_9f/gate", [], WORKER)[0] == 400
|
||||
assert call(router, "POST", f"/hux/v1/runs/{'r' * 121}/gate", {"capability": "shell", "argument_hash": HASH}, WORKER)[0] == 400
|
||||
record = pending(router)
|
||||
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_9f/gate", {"capability": "write_files", "argument_hash": HASH}, OTHER)
|
||||
assert status == 200 and body["proceed"] is False
|
||||
|
||||
|
||||
def test_events_module_absence_is_tolerated(router, monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "hux.events", None)
|
||||
monkeypatch.delattr(hux, "events")
|
||||
with pytest.raises(ImportError):
|
||||
importlib.import_module("hux.events")
|
||||
assert call(router, "POST", "/hux/v1/approvals", request_body("read_files"), WORKER)[0] == 201
|
||||
policy.emit(None, None, None, "x", "no conversation means no event")
|
||||
assert budgets.hashes_of({"request": {}}) == set()
|
||||
191
testing/tests/test_hermes_hux_policy_matrix.py
Normal file
191
testing/tests/test_hermes_hux_policy_matrix.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""HUX-05 policy documents: the capability matrix, grants and their expiry.
|
||||
|
||||
Security obligations exercised: SO-38 (``always`` grants carry a server-set
|
||||
expiry of at most 30 days), SO-39 (``effective_decision`` is the sole
|
||||
resolver and deploy/external always ask), SO-44 (If-Match on revisioned
|
||||
writes; unconditional writes are audited).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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, identity, policy, 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"])
|
||||
T0 = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def router(tmp_path):
|
||||
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def frozen(monkeypatch):
|
||||
state = {"now": T0}
|
||||
monkeypatch.setattr(policy, "clock", lambda: state["now"])
|
||||
return state
|
||||
|
||||
|
||||
def call(router, method, path, body=None, headers=HEADERS):
|
||||
raw = b"" if body is None else json.dumps(body).encode()
|
||||
response = router.dispatch(method, path, headers, raw)
|
||||
return response.status, response.body, response.headers
|
||||
|
||||
|
||||
def valid(record):
|
||||
assert contracts.validate_record(record, SCHEMAS) == [], contracts.validate_record(record, SCHEMAS)
|
||||
return record
|
||||
|
||||
|
||||
def test_modules_stay_under_the_line_budget():
|
||||
for name in ("policy.py", "budgets.py"):
|
||||
assert len((FOUNDATION / "hux" / name).read_text().splitlines()) <= 500
|
||||
|
||||
|
||||
def test_first_read_creates_the_safe_global_default(router, tmp_path):
|
||||
status, body, headers = call(router, "GET", "/hux/v1/policy")
|
||||
assert status == 200 and headers["ETag"] == "1"
|
||||
valid(body)
|
||||
assert body["scope"] == {"level": "global"} and body["autonomy"] == "safe" and body["grants"] == []
|
||||
assert body["owner"] == HEADERS["X-Hux-Subject"] and body["provenance"]["actor"] == {"type": "user", "id": body["owner"]}
|
||||
assert call(router, "GET", "/hux/v1/policy?scope=global")[1] == body
|
||||
rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {})))
|
||||
assert [r["action"] for r in rows] == ["policy.read", "policy.read"]
|
||||
|
||||
|
||||
def test_matrix_is_the_single_resolver_and_external_always_asks(router):
|
||||
matrix = rules.default_capability_matrix()
|
||||
for level in ("ask_first", "safe", "autonomous"):
|
||||
put = {"scope": {"level": "global"}, "autonomy": level, "grants": [{"capability": "deploy", "decision": "allow"}]}
|
||||
status, body, _ = call(router, "PUT", "/hux/v1/policy", put)
|
||||
assert status == 200, body
|
||||
for capability in rules.CAPABILITIES:
|
||||
expected = matrix[level][capability]
|
||||
if capability == "deploy":
|
||||
expected = "ask" # an explicit allow on an always-ask capability still asks (SO-39)
|
||||
assert policy.resolve_request(body, capability, external=False) == expected
|
||||
assert policy.resolve_request(body, capability, external=True) in {"ask", "deny"}
|
||||
assert policy.resolve_request(body, "network", external=True) == "ask"
|
||||
|
||||
|
||||
def test_grants_get_server_set_expiry_and_actor(router, frozen):
|
||||
far = policy.iso(T0 + timedelta(days=90))
|
||||
soon = policy.iso(T0 + timedelta(days=2))
|
||||
put = {"scope": {"level": "global"}, "autonomy": "safe", "grants": [
|
||||
{"capability": "network", "decision": "allow", "expires_at": far, "granted_by": {"type": "operator", "id": "evil"}},
|
||||
{"capability": "shell", "decision": "allow", "expires_at": soon},
|
||||
{"capability": "write_files", "decision": "deny"},
|
||||
]}
|
||||
status, body, _ = call(router, "PUT", "/hux/v1/policy", put)
|
||||
assert status == 200
|
||||
valid(body)
|
||||
by_cap = {g["capability"]: g for g in body["grants"]}
|
||||
assert by_cap["network"]["expires_at"] == policy.iso(T0 + policy.ALWAYS_TTL)
|
||||
assert by_cap["shell"]["expires_at"] == soon
|
||||
assert by_cap["write_files"]["expires_at"] == policy.iso(T0 + policy.ALWAYS_TTL)
|
||||
assert all(g["granted_by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]} for g in body["grants"])
|
||||
assert policy.resolve_request(body, "network", False) == "allow"
|
||||
assert policy.resolve_request(body, "shell", False) == "allow"
|
||||
assert policy.resolve_request(body, "write_files", False) == "deny"
|
||||
frozen["now"] = T0 + timedelta(days=3)
|
||||
assert policy.resolve_request(body, "shell", False) == "ask", "expired grant falls back to the matrix"
|
||||
assert policy.resolve_request(body, "network", False) == "allow"
|
||||
frozen["now"] = T0 + timedelta(days=31)
|
||||
assert policy.resolve_request(body, "network", False) == "deny", "safe denies network once the grant lapses"
|
||||
|
||||
|
||||
def test_put_honours_if_match_and_audits_unconditional_writes(router, tmp_path):
|
||||
put = {"scope": {"level": "global"}, "autonomy": "autonomous"}
|
||||
status, body, headers = call(router, "PUT", "/hux/v1/policy", put)
|
||||
assert (status, body["revision"], headers["ETag"]) == (200, 1, "1")
|
||||
status, body, _ = call(router, "PUT", "/hux/v1/policy", put, {**HEADERS, "If-Match": "1"})
|
||||
assert (status, body["revision"]) == (200, 2)
|
||||
status, body, _ = call(router, "PUT", "/hux/v1/policy", put, {**HEADERS, "If-Match": "1"})
|
||||
assert (status, body["code"]) == (409, "conflict")
|
||||
status, body, _ = call(router, "PUT", "/hux/v1/policy", put)
|
||||
assert (status, body["revision"]) == (200, 3)
|
||||
rows = [(r["action"], r["outcome"], r.get("reason", "")) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {})))]
|
||||
assert rows == [("policy.write", "allow", ""), ("policy.write", "allow", ""), ("policy.write", "conflict", rows[2][2]), ("policy.write", "allow", "unconditional_write")]
|
||||
assert "does not match" in rows[2][2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,fragment", [
|
||||
([], "JSON object"),
|
||||
({"scope": "global"}, "scope must be an object"),
|
||||
({"scope": {"level": "planet"}, "autonomy": "safe"}, "scope must be"),
|
||||
({"scope": {"level": "project"}, "autonomy": "safe"}, "scope_id required"),
|
||||
({"scope": {"level": "project", "scope_id": "prj_" + "a" * 70}, "autonomy": "safe"}, "scope_id required"),
|
||||
({"scope": {"level": "project", "scope_id": "../etc"}, "autonomy": "safe"}, "malformed id"),
|
||||
({"scope": {"level": "global"}, "autonomy": "yolo"}, "autonomy must be"),
|
||||
({"scope": {"level": "global"}, "autonomy": "safe", "grants": "all"}, "grants must be a list"),
|
||||
({"scope": {"level": "global"}, "autonomy": "safe", "grants": [{"capability": "teleport", "decision": "allow"}]}, "known capability"),
|
||||
({"scope": {"level": "global"}, "autonomy": "safe", "grants": [{"capability": "shell", "decision": "maybe"}]}, "grant decision"),
|
||||
({"scope": {"level": "global"}, "autonomy": "safe", "budgets": {"tokens_per_run": -1}}, "fails contract"),
|
||||
({"scope": {"level": "global"}, "autonomy": "safe", "budgets": {"unknown": 1}}, "fails contract"),
|
||||
])
|
||||
def test_put_rejects_bad_bodies(router, body, fragment):
|
||||
status, error, _ = call(router, "PUT", "/hux/v1/policy", body)
|
||||
assert status == 400 and fragment in error["message"], error
|
||||
valid(error)
|
||||
|
||||
|
||||
def test_get_rejects_bad_scope_queries(router):
|
||||
assert call(router, "GET", "/hux/v1/policy?scope=nope")[0] == 400
|
||||
assert call(router, "GET", "/hux/v1/policy?scope=conversation")[0] == 400
|
||||
|
||||
|
||||
def test_effective_policy_walks_conversation_project_global(router):
|
||||
_, project, _ = call(router, "POST", "/hux/v1/projects", {"name": "Kitchen"})
|
||||
_, conversation, _ = call(router, "POST", "/hux/v1/conversations", {"title": "Cabinets", "project_id": project["id"]})
|
||||
_, loose, _ = call(router, "POST", "/hux/v1/conversations", {"title": "Unfiled"})
|
||||
conv_query = f"/hux/v1/policy?scope=conversation&scope_id={conversation['id']}"
|
||||
assert call(router, "GET", conv_query)[1]["scope"] == {"level": "global"}
|
||||
status, body, _ = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "project", "scope_id": project["id"]}, "autonomy": "autonomous"})
|
||||
assert status == 200 and valid(body)["id"] == f"pol_project.{project['id']}"
|
||||
assert call(router, "GET", conv_query)[1]["scope"] == {"level": "project", "scope_id": project["id"]}
|
||||
assert call(router, "GET", f"/hux/v1/policy?scope=conversation&scope_id={loose['id']}")[1]["scope"] == {"level": "global"}
|
||||
assert call(router, "GET", "/hux/v1/policy?scope=conversation&scope_id=conv_unknown0001")[1]["scope"] == {"level": "global"}
|
||||
status, body, _ = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "conversation", "scope_id": conversation["id"]}, "autonomy": "ask_first"})
|
||||
assert status == 200
|
||||
got = call(router, "GET", conv_query)[1]
|
||||
assert got["autonomy"] == "ask_first" and got["scope"]["level"] == "conversation"
|
||||
assert call(router, "GET", f"/hux/v1/policy?scope=project&scope_id={project['id']}")[1]["autonomy"] == "autonomous"
|
||||
|
||||
|
||||
def test_second_subject_sees_its_own_default_not_the_first_tenants_policy(router):
|
||||
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "autonomous"})
|
||||
status, body, _ = call(router, "GET", "/hux/v1/policy", headers=OTHER)
|
||||
assert status == 200 and body["autonomy"] == "safe" and body["owner"] == OTHER["X-Hux-Subject"]
|
||||
assert call(router, "GET", "/hux/v1/policy")[1]["autonomy"] == "autonomous"
|
||||
|
||||
|
||||
def test_flag_off_hides_the_whole_card(tmp_path):
|
||||
off = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation"})
|
||||
status, body, _ = call(off, "GET", "/hux/v1/policy")
|
||||
assert (status, body["code"]) == (404, "flag_off")
|
||||
|
||||
|
||||
def test_helpers_round_trip_time_and_actors():
|
||||
assert policy.parse(policy.iso(T0)) == T0
|
||||
assert policy.now().tzinfo is timezone.utc
|
||||
worker = identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "worker", "worker")
|
||||
assert policy.actor_for(worker) == {"type": "system", "id": "worker"}
|
||||
assert policy.run_key("run_9f") == policy.run_key("run_9f") and len(policy.run_key("x")) == 32
|
||||
assert policy.public({"a": 1, "_b": 2, "revision": 3}) == {"a": 1}
|
||||
assert policy.public({"a": 1, "_b": 2, "revision": 3}, revisioned=True) == {"a": 1, "revision": 3}
|
||||
138
testing/tests/test_hermes_hux_policy_receipts.py
Normal file
138
testing/tests/test_hermes_hux_policy_receipts.py
Normal file
@ -0,0 +1,138 @@
|
||||
"""HUX-05 run budgets and cancellation receipts.
|
||||
|
||||
Security obligations exercised: SO-40 (budget state per run against the
|
||||
effective policy; crossing a limit emits ``budget.exhausted``), SO-41 (a
|
||||
receipt says ``cancelled`` only when the process registry is empty and
|
||||
carries every side effect the hook reports; a second stop returns the same
|
||||
receipt).
|
||||
"""
|
||||
|
||||
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, identity, store # noqa: E402
|
||||
from hux import events as hux_events # 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"}
|
||||
WORKER = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
|
||||
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
||||
CONV = "conv_0001abcd"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def router(tmp_path):
|
||||
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def events(monkeypatch):
|
||||
seen: list[dict] = []
|
||||
monkeypatch.setattr(hux_events, "emit", lambda store, identity, conversation_id, kind, summary, **extra: seen.append({"conversation_id": conversation_id, "kind": kind, "summary": summary, **extra}))
|
||||
return seen
|
||||
|
||||
|
||||
def call(router, method, path, body=None, headers=WORKER):
|
||||
raw = b"" if body is None else json.dumps(body).encode()
|
||||
response = router.dispatch(method, path, headers, raw)
|
||||
return response.status, response.body
|
||||
|
||||
|
||||
def valid(record):
|
||||
problems = contracts.validate_record(record, SCHEMAS)
|
||||
assert problems == [], problems
|
||||
return record
|
||||
|
||||
|
||||
# --- budgets ------------------------------------------------------------------------
|
||||
|
||||
def test_budget_starts_empty_against_the_effective_policy(router):
|
||||
status, body = call(router, "GET", "/hux/v1/runs/run_9f/budget")
|
||||
assert status == 200 and valid(body)["run_id"] == "run_9f"
|
||||
assert body["spent"] == {k: 0 for k in ("tokens", "tool_calls", "wall_clock_seconds", "delegations", "spend_units", "subagents")}
|
||||
assert body["limits"]["tokens_per_run"] == 200000 and body["exhausted"] == []
|
||||
assert "revision" not in body and not any(k.startswith("_") for k in body)
|
||||
|
||||
|
||||
def test_increments_accumulate_and_exhaust_each_limit(router, events):
|
||||
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe",
|
||||
"budgets": {"tokens_per_run": 100, "tool_calls_per_run": 3, "subagents_per_run": 0, "scope": {"paths": ["/work"]}}}, HEADERS)
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_9f/budget", {"conversation_id": CONV, "tokens": 60, "tool_calls": 1})
|
||||
assert status == 200 and valid(body)["spent"]["tokens"] == 60
|
||||
assert body["exhausted"] == ["subagents_per_run"], "a zero limit is exhausted before any spend"
|
||||
assert body["limits"] == {"tokens_per_run": 100, "tool_calls_per_run": 3, "subagents_per_run": 0}, "scope is policy detail, not a limit"
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_9f/budget", {"tokens": 40, "tool_calls": 2, "delegations": 5})
|
||||
assert body["spent"] == {"tokens": 100, "tool_calls": 3, "wall_clock_seconds": 0, "delegations": 5, "spend_units": 0, "subagents": 0}
|
||||
assert body["exhausted"] == ["tokens_per_run", "tool_calls_per_run", "subagents_per_run"]
|
||||
assert [e["kind"] for e in events] == ["budget.exhausted", "budget.exhausted"]
|
||||
assert events[1]["summary"] == "Budget exhausted: tokens_per_run, tool_calls_per_run" and events[1]["run_id"] == "run_9f"
|
||||
assert events[1]["conversation_id"] == CONV, "the conversation learned on the first report sticks"
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_9f/budget", {"tokens": 1})
|
||||
assert status == 200 and len(events) == 2, "already exhausted limits do not re-emit"
|
||||
assert call(router, "GET", "/hux/v1/runs/run_9f/budget")[1] == body
|
||||
|
||||
|
||||
def test_budget_follows_the_conversation_policy(router):
|
||||
_, conversation = call(router, "POST", "/hux/v1/conversations", {"title": "Budgeted"}, HEADERS)
|
||||
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "conversation", "scope_id": conversation["id"]}, "autonomy": "safe", "budgets": {"spend_units": 1}}, HEADERS)
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_c/budget", {"conversation_id": conversation["id"], "spend_units": 1})
|
||||
assert body["limits"] == {"spend_units": 1} and body["exhausted"] == ["spend_units"]
|
||||
assert call(router, "GET", "/hux/v1/runs/run_c/budget")[1]["limits"] == {"spend_units": 1}
|
||||
assert call(router, "GET", "/hux/v1/runs/run_c/budget", headers=OTHER)[1]["spent"]["spend_units"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [[], {"tokens": -1}, {"tokens": True}, {"tokens": "5"}, {"conversation_id": "x"}])
|
||||
def test_budget_rejects_bad_bodies(router, body):
|
||||
status, error = call(router, "POST", "/hux/v1/runs/run_9f/budget", body)
|
||||
assert status == 400 and valid(error)["code"] == "invalid"
|
||||
|
||||
|
||||
# --- stop receipts ----------------------------------------------------------------
|
||||
|
||||
def test_stop_writes_a_receipt_and_a_second_stop_returns_it(router, events, tmp_path):
|
||||
side_effects = [{"description": "Partial file left in workspace", "reverted": True, "evidence": {"kind": "file", "id": "notes.md"}}]
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "side_effects": side_effects, "conversation_id": CONV}, HEADERS)
|
||||
assert status == 201 and valid(body)["outcome"] == "cancelled"
|
||||
assert body["requested_by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]}
|
||||
assert body["requested_at"] == body["acknowledged_at"] == body["completed_at"]
|
||||
assert body["side_effects"] == side_effects and body["conversation_id"] == CONV and "revision" not in body
|
||||
status, again = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False})
|
||||
assert (status, again) == (200, body)
|
||||
assert [e["kind"] for e in events] == ["run.cancelled"]
|
||||
assert events[0]["evidence"] == [{"kind": "run", "id": body["id"]}] and events[0]["run_id"] == "run_9f"
|
||||
rows = [(r["action"], r.get("reason")) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))) if r["action"] == "runs.stop"]
|
||||
assert rows == [("runs.stop", "cancelled"), ("runs.stop", "replayed")]
|
||||
|
||||
|
||||
def test_stop_outcomes_follow_the_process_registry(router):
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_a/stop", {"process_registry_empty": False, "side_effects": []})
|
||||
assert status == 201 and valid(body)["outcome"] == "failed_to_cancel" and "completed_at" not in body
|
||||
assert body["requested_by"] == {"type": "system", "id": "worker"}
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_b/stop", {"already_complete": True})
|
||||
assert valid(body)["outcome"] == "already_complete" and "completed_at" in body
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_c/stop", {})
|
||||
assert valid(body)["outcome"] == "failed_to_cancel", "no registry report means the stop is not proven"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [[], {"side_effects": "none"}, {"side_effects": [{"description": ""}]}, {"conversation_id": "bad"}])
|
||||
def test_stop_rejects_bad_bodies(router, body):
|
||||
status, error = call(router, "POST", "/hux/v1/runs/run_9f/stop", body)
|
||||
assert status == 400 and valid(error)["code"] == "invalid"
|
||||
|
||||
|
||||
def test_receipts_are_per_tenant(router):
|
||||
call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True})
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False}, OTHER)
|
||||
assert status == 201 and body["outcome"] == "failed_to_cancel", "same run id, other subject, its own receipt"
|
||||
142
testing/tests/test_hermes_hux_privacy_retention.py
Normal file
142
testing/tests/test_hermes_hux_privacy_retention.py
Normal file
@ -0,0 +1,142 @@
|
||||
"""HUX-10 retention job: expiry, topic decay, purge of forgotten content, audit record.
|
||||
|
||||
Security obligations exercised: SO-24 (forgotten content is purged from older
|
||||
ledger lines), SO-27 (expiry enforced eagerly and reported), SO-47 (purge touches
|
||||
only tombstoned ids and never the audit ledgers), SO-52 (retention keeps running
|
||||
whatever the flags say).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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, errors, events, identity, memory, privacy, 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
|
||||
|
||||
|
||||
def tenant(tmp_path) -> store.TenantStore:
|
||||
return store.TenantStore(tmp_path, ident())
|
||||
|
||||
|
||||
def remember(router, content, **fields):
|
||||
body = {"kind": "fact", "content": content, "reason": "r", "proposed_by": "user", "conversation_id": CONV, **fields}
|
||||
status, record = call(router, "POST", "/hux/v1/memory", HEADERS, body)
|
||||
assert status in (201, 202), record
|
||||
return record
|
||||
|
||||
|
||||
def counts(record):
|
||||
return {row["action"]: row["count"] for row in record["results"]}
|
||||
|
||||
|
||||
def test_retention_expires_decays_purges_and_reports(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
s = tenant(tmp_path)
|
||||
stale = remember(router, "expires soon", ttl={"policy": "expires_at", "expires_at": "2020-01-01T00:00:00Z"})
|
||||
keep = remember(router, "kept", ttl={"policy": "never"})
|
||||
gone = remember(router, "to be forgotten")
|
||||
call(router, "POST", f"/hux/v1/memory/{gone['id']}/forget")
|
||||
assert "to be forgotten" in json.dumps(s.read(memory.FAMILY, memory.LEDGER))
|
||||
events.emit(s, ident(), CONV, "message.user", "about my diagnosis", sensitivity="sensitive")
|
||||
events.emit(s, ident(), CONV, "message.assistant", "plain reply")
|
||||
privacy.mark_topic(s, CONV, "health", "2026-01-01T00:00:00Z")
|
||||
audit_rows_before = audit.recent(s)
|
||||
record = privacy.run_retention(s, datetime(2026, 8, 24, 3, 0, tzinfo=timezone.utc))
|
||||
assert record["schema"] == "hux.retention_audit.v1" and contracts.validate_record(record, SCHEMAS) == []
|
||||
assert counts(record) == {"expire_memory": 1, "decay_topic_context": 1, "purge_forgotten_content": 1, "report": 1}
|
||||
assert s.get(memory.FAMILY, stale["id"])["status"] == "expired" and s.get(memory.FAMILY, keep["id"])["status"] == "active"
|
||||
ledger = json.dumps(s.read(memory.FAMILY, memory.LEDGER))
|
||||
assert "to be forgotten" not in ledger and "kept" in ledger
|
||||
assert all(row["purged"] for row in s.read(memory.FAMILY, memory.TOMBSTONES))
|
||||
rows = s.read(events.FAMILY, CONV)
|
||||
sensitive = [r for r in rows if r["sensitivity"] == "sensitive"]
|
||||
assert sensitive and all(r["redaction"]["level"] == "full" and r["summary"].startswith("This looks like a health topic") for r in sensitive)
|
||||
assert [r["seq"] for r in rows] == list(range(1, len(rows) + 1))
|
||||
assert any(r["kind"] == "message.assistant" and r["redaction"]["level"] == "none" for r in rows)
|
||||
assert audit.recent(s)[: len(audit_rows_before)] == audit_rows_before
|
||||
assert privacy.conversation_state(s, CONV)["decayed"] is True
|
||||
again = privacy.run_retention(s, datetime(2026, 8, 25, 3, 0, tzinfo=timezone.utc))
|
||||
assert counts(again) == {"expire_memory": 0, "decay_topic_context": 0, "purge_forgotten_content": 0, "report": 1}
|
||||
|
||||
|
||||
def test_retention_skips_topics_not_yet_due(tmp_path):
|
||||
s = tenant(tmp_path)
|
||||
events.emit(s, ident(), CONV, "message.user", "money talk", sensitivity="sensitive")
|
||||
privacy.mark_topic(s, CONV, "finance")
|
||||
privacy.set_flag(s, "conv_0002abcd", "memory_disabled", True)
|
||||
record = privacy.run_retention(s)
|
||||
assert counts(record)["decay_topic_context"] == 0
|
||||
assert s.read(events.FAMILY, CONV)[0]["redaction"]["level"] == "partial"
|
||||
assert not privacy.audit_stale(s)
|
||||
assert privacy.audit_stale(s, datetime.now(timezone.utc) + timedelta(days=3))
|
||||
|
||||
|
||||
def test_audit_route_lists_newest_first_and_is_tenant_scoped(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
s = tenant(tmp_path)
|
||||
assert privacy.latest_audit(s) is None
|
||||
first = privacy.run_retention(s, datetime(2026, 8, 22, 3, 0, tzinfo=timezone.utc))
|
||||
second = privacy.run_retention(s, datetime(2026, 8, 23, 3, 0, tzinfo=timezone.utc))
|
||||
status, body = call(router, "GET", "/hux/v1/privacy/audit")
|
||||
assert status == 200 and [r["id"] for r in body["items"]] == [second["id"], first["id"]]
|
||||
for item in body["items"]:
|
||||
assert contracts.validate_record(item, SCHEMAS) == [] and "revision" not in item
|
||||
assert privacy.latest_audit(s)["id"] == second["id"]
|
||||
assert call(router, "GET", "/hux/v1/privacy/audit", OTHER)[1]["items"] == []
|
||||
assert [r["action"] for r in audit.recent(s)][-1] == "privacy.audit"
|
||||
|
||||
|
||||
def test_retention_runs_without_flags(tmp_path):
|
||||
router = build_router(tmp_path, {"HUX_FLAGS": ""})
|
||||
s = tenant(tmp_path)
|
||||
assert call(router, "GET", "/hux/v1/privacy/audit")[0] == 404
|
||||
assert counts(privacy.run_retention(s))["report"] == 1
|
||||
|
||||
|
||||
def test_retention_record_is_validated_before_it_is_written(tmp_path, monkeypatch):
|
||||
s = tenant(tmp_path)
|
||||
monkeypatch.setattr(privacy, "new_id", lambda prefix: "bad id")
|
||||
with pytest.raises(errors.Invalid):
|
||||
privacy.run_retention(s)
|
||||
assert privacy.latest_audit(s) is None
|
||||
|
||||
|
||||
def test_purge_leaves_untombstoned_content_alone(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
s = tenant(tmp_path)
|
||||
keep = remember(router, "keep me")
|
||||
assert memory.purge_forgotten(s) == 0
|
||||
s.append(memory.FAMILY, memory.TOMBSTONES, {"memory_id": "mem_ghost0000", "at": "2026-08-23T00:00:00Z", "reason": "test", "purged": False})
|
||||
assert memory.purge_forgotten(s) == 1
|
||||
assert s.get(memory.FAMILY, keep["id"])["content"] == "keep me"
|
||||
assert "keep me" in json.dumps(s.read(memory.FAMILY, memory.LEDGER))
|
||||
172
testing/tests/test_hermes_hux_privacy_topics.py
Normal file
172
testing/tests/test_hermes_hux_privacy_topics.py
Normal file
@ -0,0 +1,172 @@
|
||||
"""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)
|
||||
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)
|
||||
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}
|
||||
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
|
||||
238
testing/tests/test_hermes_hux_research_citations.py
Normal file
238
testing/tests/test_hermes_hux_research_citations.py
Normal file
@ -0,0 +1,238 @@
|
||||
"""HUX-08 research: sources, passages and citations.
|
||||
|
||||
Security obligations exercised: the service stores but never dereferences a
|
||||
source ``uri`` and only allowlisted schemes are accepted (SO-19); passage and
|
||||
citation references resolve under the caller's subtree and foreign ids are
|
||||
404 (SO-33); hashes and dedupe keys are server-computed (SO-30 by analogy);
|
||||
every served record satisfies citation.schema.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
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, identity, research, store # noqa: E402
|
||||
from hux.server import build_router # noqa: E402
|
||||
|
||||
SCHEMAS = contracts.load_all()
|
||||
OWNER = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
|
||||
OTHER = {**OWNER, "X-Hux-Subject": "usr_fedcba9876543210"}
|
||||
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
||||
|
||||
|
||||
def tenant(router, subject="usr_0123456789abcdef") -> store.TenantStore:
|
||||
return store.TenantStore(router.data_root, identity.Identity("slot-3", subject, "chat", "router"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def router(tmp_path):
|
||||
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
|
||||
|
||||
|
||||
def call(router, method, path, body=None, headers=None, raw=None):
|
||||
payload = raw if raw is not None else (json.dumps(body).encode() if body is not None else b"")
|
||||
response = router.dispatch(method, path, {**OWNER, **(headers or {})}, payload)
|
||||
return response.status, response.body, response.headers
|
||||
|
||||
|
||||
def valid(record) -> bool:
|
||||
return contracts.validate_record(record, SCHEMAS) == []
|
||||
|
||||
|
||||
def source(router, uri="https://Example.com/cabinets/#lead", **extra):
|
||||
status, record, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": uri, "title": "Cabinet pricing", "classification": "primary", **extra})
|
||||
assert status == 201, record
|
||||
return record
|
||||
|
||||
|
||||
def passage(router, source_id, text="Standard base cabinets ship in 3-4 weeks."):
|
||||
status, record, _ = call(router, "POST", "/hux/v1/passages", {"source_id": source_id, "text": text})
|
||||
assert status == 201, record
|
||||
return record
|
||||
|
||||
|
||||
def cite(router, passage_ids, message="msg-44", claim="Base cabinets take three to four weeks.", **extra):
|
||||
status, record, _ = call(router, "POST", f"/hux/v1/messages/{message}/citations", {"claim": claim, "passage_ids": passage_ids, "support": "supports", **extra})
|
||||
assert status == 201, record
|
||||
return record
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def events(monkeypatch):
|
||||
calls = []
|
||||
module = types.ModuleType("hux.events")
|
||||
module.emit = lambda store, identity, conversation_id, kind, summary, detail=None, evidence=None, sensitivity="personal", run_id=None, turn=None, correlation_id=None: calls.append((conversation_id, kind, detail, evidence))
|
||||
monkeypatch.setitem(sys.modules, "hux.events", module)
|
||||
return calls
|
||||
|
||||
|
||||
# --- sources -----------------------------------------------------------------------
|
||||
|
||||
def test_source_is_valid_and_deduped_on_normalised_uri(router):
|
||||
first = source(router, publisher="Example Co", published_at="2026-01-01T00:00:00Z", content_hash="sha256:" + "a" * 64, conversation_id="conv_0001abcd")
|
||||
assert valid(first) and first["provenance"]["actor"] == {"type": "user", "id": "usr_0123456789abcdef"}
|
||||
assert first["provenance"]["conversation_id"] == "conv_0001abcd" and first["publisher"] == "Example Co"
|
||||
expected = "sha256:" + hashlib.sha256(b"uri\nhttps://example.com/cabinets").hexdigest()
|
||||
assert first["dedupe_key"] == expected
|
||||
status, again, headers = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "HTTPS://example.com/cabinets", "title": "Different title"})
|
||||
assert (status, again["id"], headers["HUX-Replayed"]) == (200, first["id"], "true")
|
||||
assert tenant(router).count("sources") == 1
|
||||
status, record, _ = call(router, "GET", f"/hux/v1/sources/{first['id']}")
|
||||
assert status == 200 and record == first
|
||||
assert call(router, "GET", "/hux/v1/sources/src_missing00001")[0] == 404
|
||||
assert call(router, "GET", "/hux/v1/sources/..")[0] == 404
|
||||
|
||||
|
||||
def test_source_without_uri_dedupes_on_kind_and_title(router):
|
||||
status, memory, _ = call(router, "POST", "/hux/v1/sources", {"kind": "memory", "title": " Remembered fact "})
|
||||
status, again, _ = call(router, "POST", "/hux/v1/sources", {"kind": "memory", "title": "remembered fact"})
|
||||
assert status == 200 and again["id"] == memory["id"] and "uri" not in memory and memory["classification"] == "unknown"
|
||||
assert research.normalise_uri("artifact://art_0001aaaa@2/") == "artifact://art_0001aaaa@2/"
|
||||
assert research.normalise_uri("file:///opt/data/x/") == "file:///opt/data/x"
|
||||
assert research.normalise_uri("https://h.example/") == "https://h.example/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
{"uri": "javascript:alert(1)"}, {"uri": "ftp://x"}, {"uri": "/relative"}, {"kind": "rumour"}, {"kind": None},
|
||||
{"classification": "gospel"}, {"title": ""}, {"conversation_id": "x"}, {"content_hash": "md5:abc"}, {"published_at": "yesterday"},
|
||||
])
|
||||
def test_source_rejects_bad_bodies(router, bad):
|
||||
status, error, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "https://ok.example/", "title": "t", **bad})
|
||||
assert status == 400 and valid(error), bad
|
||||
assert call(router, "POST", "/hux/v1/sources", raw=b'"str"')[0] == 400
|
||||
|
||||
|
||||
def test_source_idempotency_and_cap(router, monkeypatch):
|
||||
status, one, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "https://a.example/1", "title": "t"}, {"Idempotency-Key": "src-key-0001"})
|
||||
status, two, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "https://a.example/2", "title": "t"}, {"Idempotency-Key": "src-key-0001"})
|
||||
assert status == 200 and two["id"] == one["id"]
|
||||
monkeypatch.setattr(research, "MAX_SOURCES", 1)
|
||||
status, error, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "https://a.example/3", "title": "t"})
|
||||
assert (status, error["code"]) == (413, "too_large")
|
||||
|
||||
|
||||
def test_service_has_no_outbound_client():
|
||||
import hux.artifacts
|
||||
import hux.research
|
||||
for name in ("urllib.request", "http.client", "socket"):
|
||||
assert name not in sys.modules or not any(name in getattr(m, "__dict__", {}) for m in (hux.research, hux.artifacts))
|
||||
text = (FOUNDATION / "hux" / "research.py").read_text()
|
||||
assert "urlopen" not in text and "http.client" not in text
|
||||
|
||||
|
||||
# --- passages ----------------------------------------------------------------------
|
||||
|
||||
def test_passage_hash_is_server_computed_and_deduped(router, monkeypatch):
|
||||
src = source(router)
|
||||
first = passage(router, src["id"])
|
||||
assert valid(first) and first["hash"] == "sha256:" + hashlib.sha256(b"Standard base cabinets ship in 3-4 weeks.").hexdigest()
|
||||
status, again, headers = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "Standard base cabinets ship in 3-4 weeks.", "hash": "sha256:" + "0" * 64})
|
||||
assert (status, again["id"], headers["HUX-Replayed"]) == (200, first["id"], "true") and again["hash"] == first["hash"]
|
||||
status, located, _ = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "Other", "locator": {"page": 2, "selector": "#x"}}, {"Idempotency-Key": "psg-key-00001"})
|
||||
assert status == 201 and valid(located) and located["locator"] == {"page": 2, "selector": "#x"}
|
||||
status, replay, _ = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "Ignored"}, {"Idempotency-Key": "psg-key-00001"})
|
||||
assert status == 200 and replay["id"] == located["id"]
|
||||
status, error, _ = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "Bad", "locator": {"page": 0}})
|
||||
assert status == 400 and error["details"]
|
||||
assert call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "x" * 4001})[0] == 400
|
||||
assert call(router, "POST", "/hux/v1/passages", {"source_id": "src_missing00001", "text": "x"})[0] == 404
|
||||
assert call(router, "POST", "/hux/v1/passages", {"text": "x"})[0] == 404
|
||||
assert call(router, "POST", "/hux/v1/passages", raw=b"[]")[0] == 400
|
||||
monkeypatch.setattr(research, "MAX_PASSAGES", 2)
|
||||
assert call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "third"})[0] == 413
|
||||
|
||||
|
||||
def test_passage_cannot_name_another_subjects_source(router):
|
||||
src = source(router)
|
||||
status, error, _ = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "steal"}, OTHER)
|
||||
assert (status, error["code"]) == (404, "not_found")
|
||||
assert call(router, "GET", f"/hux/v1/sources/{src['id']}", headers=OTHER)[0] == 404
|
||||
rows = audit.recent(tenant(router, "usr_fedcba9876543210"))
|
||||
assert [r["outcome"] for r in rows] == ["not_found", "not_found"]
|
||||
|
||||
|
||||
# --- citations ---------------------------------------------------------------------
|
||||
|
||||
def test_citation_attach_dedupes_and_lists_with_passages_and_sources(router, events):
|
||||
src = source(router)
|
||||
one = passage(router, src["id"])
|
||||
two = passage(router, src["id"], "Delivery excludes installation.")
|
||||
cit = cite(router, [two["id"], one["id"]], conversation_id="conv_0001abcd", note="checked")
|
||||
assert valid(cit) and cit["passage_ids"] == [two["id"], one["id"]] and cit["note"] == "checked"
|
||||
assert cit["dedupe_key"] == "sha256:" + hashlib.sha256(("\n".join(["citation", "msg-44", "Base cabinets take three to four weeks.", *sorted([one["id"], two["id"]])])).encode()).hexdigest()
|
||||
assert events == [("conv_0001abcd", "citation.attached", {"message_id": "msg-44", "citation_id": cit["id"], "support": "supports"}, [{"kind": "passage", "id": two["id"], "hash": two["hash"]}, {"kind": "passage", "id": one["id"], "hash": one["hash"]}])]
|
||||
status, again, headers = call(router, "POST", "/hux/v1/messages/msg-44/citations", {"claim": "Base cabinets take three to four weeks.", "passage_ids": [one["id"], two["id"], one["id"]], "support": "contradicts"})
|
||||
assert (status, again["id"], headers["HUX-Replayed"]) == (200, cit["id"], "true") and again["support"] == "supports"
|
||||
second = cite(router, [one["id"]], claim="Second claim")
|
||||
status, body, _ = call(router, "GET", "/hux/v1/messages/msg-44/citations")
|
||||
assert status == 200 and [i["citation"]["id"] for i in body["items"]] == [cit["id"], second["id"]]
|
||||
strip = body["items"][0]
|
||||
assert [p["id"] for p in strip["passages"]] == [two["id"], one["id"]] and strip["sources"] == [src]
|
||||
assert all(valid(r) for item in body["items"] for r in (item["citation"], *item["passages"], *item["sources"]))
|
||||
assert call(router, "GET", "/hux/v1/messages/msg-none/citations")[1]["items"] == []
|
||||
assert len(events) == 1 # the second citation carried no conversation_id, so nothing to emit against
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
{"claim": ""}, {"support": "maybe"}, {"passage_ids": "psg_x"}, {"passage_ids": []}, {"note": "n" * 501}, {"conversation_id": "nope"},
|
||||
])
|
||||
def test_citation_rejects_bad_bodies(router, bad):
|
||||
src = source(router)
|
||||
psg = passage(router, src["id"])
|
||||
status, error, _ = call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "c", "passage_ids": [psg["id"]], "support": "supports", **bad})
|
||||
assert status == 400 and valid(error), bad
|
||||
|
||||
|
||||
def test_citation_reference_rules(router):
|
||||
src = source(router)
|
||||
psg = passage(router, src["id"])
|
||||
assert call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "c", "passage_ids": ["psg_missing00001"]})[0] == 404
|
||||
assert call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "c", "passage_ids": [psg["id"]]}, OTHER)[0] == 404
|
||||
status, error, _ = call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "c", "passage_ids": [f"psg_{i:012d}" for i in range(33)]})
|
||||
assert status == 413
|
||||
assert call(router, "POST", f"/hux/v1/messages/{'m' * 121}/citations", {"claim": "c", "passage_ids": [psg["id"]]})[0] == 400
|
||||
assert call(router, "POST", "/hux/v1/messages/msg-1/citations", raw=b"null")[0] == 400
|
||||
status, cit, _ = call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "c", "passage_ids": [psg["id"]]}, {"Idempotency-Key": "cit-key-00001"})
|
||||
status, replay, _ = call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "other", "passage_ids": [psg["id"]]}, {"Idempotency-Key": "cit-key-00001"})
|
||||
assert status == 200 and replay["id"] == cit["id"] and replay["support"] == "unverified"
|
||||
assert call(router, "GET", "/hux/v1/messages/msg-1/citations", headers=OTHER)[1]["items"] == []
|
||||
|
||||
|
||||
def test_validate_citations_reports_integrity_problems(router, monkeypatch):
|
||||
src = source(router)
|
||||
psg = passage(router, src["id"])
|
||||
good = cite(router, [psg["id"]])
|
||||
contradiction = cite(router, [psg["id"]], claim="Never", support="contradicts")
|
||||
noted = cite(router, [psg["id"]], claim="Never again", support="contradicts", note="supplier changed policy")
|
||||
tenant_store = tenant(router)
|
||||
assert research.validate_citations(tenant_store, [good["id"], noted["id"]]) == []
|
||||
assert research.validate_citations(tenant_store, [contradiction["id"]]) == [f"{contradiction['id']}: contradicts without a note"]
|
||||
tenant_store.delete("sources", src["id"])
|
||||
assert research.validate_citations(tenant_store, [good["id"]]) == [f"{good['id']}: passage {psg['id']} names missing source {src['id']}"]
|
||||
tenant_store.delete("passages", psg["id"])
|
||||
problems = research.validate_citations(tenant_store, [good["id"], "cit_missing00001", "bad id"])
|
||||
assert problems == [f"{good['id']}: passage {psg['id']} does not resolve", "cit_missing00001: citation does not resolve", "bad id: citation does not resolve"]
|
||||
monkeypatch.setitem(sys.modules, "hux.events", None)
|
||||
assert cite(router, [passage(router, source(router, "https://b.example/")["id"])["id"]], claim="silent", conversation_id="conv_0001abcd")["support"] == "supports"
|
||||
|
||||
|
||||
def test_research_modules_stay_under_500_lines():
|
||||
assert len((FOUNDATION / "hux" / "research.py").read_text().splitlines()) <= 500
|
||||
|
||||
|
||||
def test_worker_trust_records_a_system_actor(tmp_path):
|
||||
router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk"})
|
||||
headers = {"X-Hux-Trust": "worker", "X-Hux-Surface": "worker", "X-Hux-Relay-Key": "wk"}
|
||||
status, record, _ = call(router, "POST", "/hux/v1/sources", {"kind": "tool_output", "title": "grep"}, headers)
|
||||
assert status == 201 and record["provenance"] == {"surface": "worker", "actor": {"type": "system", "id": "worker"}, "recorded_at": record["retrieved_at"]}
|
||||
162
testing/tests/test_hermes_hux_research_notebook.py
Normal file
162
testing/tests/test_hermes_hux_research_notebook.py
Normal file
@ -0,0 +1,162 @@
|
||||
"""HUX-08 research notebooks: state machine, references and concurrency.
|
||||
|
||||
Security obligations exercised: every id added to a notebook resolves under
|
||||
the caller's subtree, so a second subject cannot attach or read another
|
||||
tenant's records (SO-33); PATCH requires If-Match and a stale revision is a
|
||||
409 (SO-44); status only moves open -> answered | abandoned; every served
|
||||
record satisfies citation.schema.json#/$defs/notebook.
|
||||
"""
|
||||
|
||||
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, identity, store # noqa: E402
|
||||
from hux.server import build_router # noqa: E402
|
||||
|
||||
SCHEMAS = contracts.load_all()
|
||||
OWNER = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
|
||||
OTHER = {**OWNER, "X-Hux-Subject": "usr_fedcba9876543210"}
|
||||
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def router(tmp_path):
|
||||
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
|
||||
|
||||
|
||||
def call(router, method, path, body=None, headers=None, raw=None):
|
||||
payload = raw if raw is not None else (json.dumps(body).encode() if body is not None else b"")
|
||||
response = router.dispatch(method, path, {**OWNER, **(headers or {})}, payload)
|
||||
return response.status, response.body, response.headers
|
||||
|
||||
|
||||
def valid(record) -> bool:
|
||||
return contracts.validate_record(record, SCHEMAS) == []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def evidence(router):
|
||||
"""One source, passage and citation owned by the default subject."""
|
||||
_, src, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "https://example.com/a", "title": "A"})
|
||||
_, psg, _ = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "A says B."})
|
||||
_, cit, _ = call(router, "POST", "/hux/v1/messages/msg-9/citations", {"claim": "B", "passage_ids": [psg["id"]], "support": "supports"})
|
||||
return src, psg, cit
|
||||
|
||||
|
||||
def notebook(router, **extra):
|
||||
status, record, headers = call(router, "POST", "/hux/v1/notebooks", {"conversation_id": "conv_0001abcd", "question": "Which supplier?", **extra})
|
||||
assert status == 201, record
|
||||
assert headers["ETag"] == "1"
|
||||
return record
|
||||
|
||||
|
||||
def test_create_and_get_are_contract_valid(router):
|
||||
record = notebook(router, assumptions=["Budget is fixed"], unresolved_questions=["Installation included?"])
|
||||
assert valid(record) and record["status"] == "open" and record["revision"] == 1
|
||||
assert record["assumptions"] == ["Budget is fixed"] and record["notes"] == []
|
||||
status, fetched, headers = call(router, "GET", f"/hux/v1/notebooks/{record['id']}")
|
||||
assert status == 200 and fetched == record and headers["ETag"] == "1"
|
||||
status, replay, headers = call(router, "POST", "/hux/v1/notebooks", {"conversation_id": "conv_0001abcd", "question": "again"}, {"Idempotency-Key": "nb-key-000001"})
|
||||
status, same, headers = call(router, "POST", "/hux/v1/notebooks", {"conversation_id": "conv_0001abcd", "question": "again"}, {"Idempotency-Key": "nb-key-000001"})
|
||||
assert (status, same["id"], headers["HUX-Replayed"]) == (200, replay["id"], "true")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
{"conversation_id": "nope"}, {"conversation_id": None}, {"question": ""}, {"question": "q" * 1001},
|
||||
{"assumptions": "one"}, {"assumptions": [""]}, {"unresolved_questions": [1]}, {"assumptions": ["a"] * 65},
|
||||
])
|
||||
def test_create_rejects_bad_bodies(router, bad):
|
||||
status, error, _ = call(router, "POST", "/hux/v1/notebooks", {"conversation_id": "conv_0001abcd", "question": "q", **bad})
|
||||
assert status == 400 and valid(error), bad
|
||||
assert call(router, "POST", "/hux/v1/notebooks", raw=b"[]")[0] == 400
|
||||
|
||||
|
||||
def test_patch_adds_references_notes_and_lists(router, evidence):
|
||||
src, psg, cit = evidence
|
||||
record = notebook(router)
|
||||
path = f"/hux/v1/notebooks/{record['id']}"
|
||||
patch = {
|
||||
"add_source_ids": [src["id"], src["id"]], "add_passage_ids": [psg["id"]], "add_citation_ids": [cit["id"]],
|
||||
"add_notes": [{"text": "Lead times exclude installation.", "source_id": src["id"]}, {"text": "Plain note"}],
|
||||
"assumptions": ["Budget cap 12k"], "unresolved_questions": [],
|
||||
}
|
||||
status, updated, headers = call(router, "PATCH", path, patch, {"If-Match": "1"})
|
||||
assert status == 200 and valid(updated) and headers["ETag"] == "2"
|
||||
assert updated["source_ids"] == [src["id"]] and updated["passage_ids"] == [psg["id"]] and updated["citation_ids"] == [cit["id"]]
|
||||
assert [n["text"] for n in updated["notes"]] == ["Lead times exclude installation.", "Plain note"]
|
||||
assert updated["notes"][0]["source_id"] == src["id"] and "source_id" not in updated["notes"][1]
|
||||
assert updated["assumptions"] == ["Budget cap 12k"] and updated["unresolved_questions"] == []
|
||||
status, again, _ = call(router, "PATCH", path, {"add_source_ids": [src["id"]]}, {"If-Match": "2"})
|
||||
assert again["source_ids"] == [src["id"]] and again["revision"] == 3
|
||||
status, error, _ = call(router, "PATCH", path, {"add_notes": [{"text": "x", "source_id": "src_missing00001"}]}, {"If-Match": "3"})
|
||||
assert status == 404
|
||||
for bad in ({"add_notes": "note"}, {"add_notes": [{"text": ""}]}, {"add_source_ids": "src_x"}, {"assumptions": [""]}):
|
||||
status, error, _ = call(router, "PATCH", path, bad, {"If-Match": "3"})
|
||||
assert status == 400 and valid(error), bad
|
||||
assert call(router, "PATCH", path, raw=b"[]", headers={"If-Match": "3"})[0] == 400
|
||||
status, error, _ = call(router, "PATCH", path, {"add_source_ids": [f"src_{i:012d}" for i in range(501)]}, {"If-Match": "3"})
|
||||
assert status == 413
|
||||
|
||||
|
||||
def test_patch_requires_matching_if_match(router):
|
||||
record = notebook(router)
|
||||
path = f"/hux/v1/notebooks/{record['id']}"
|
||||
status, error, _ = call(router, "PATCH", path, {"assumptions": ["x"]})
|
||||
assert (status, error["code"]) == (400, "invalid")
|
||||
status, error, _ = call(router, "PATCH", path, {"assumptions": ["x"]}, {"If-Match": "5"})
|
||||
assert (status, error["code"], error["details"]) == (409, "conflict", ["1"])
|
||||
assert call(router, "PATCH", path, {"assumptions": ["x"]}, {"If-Match": "abc"})[0] == 400
|
||||
assert call(router, "GET", path)[1]["assumptions"] == []
|
||||
rows = audit.recent(store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")))
|
||||
assert [r["outcome"] for r in rows if r["action"] == "research.notebook_patch"] == ["deny", "conflict", "deny"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("target", "then", "expected"), [
|
||||
("answered", "abandoned", 409), ("abandoned", "answered", 409), ("answered", "open", 409), ("abandoned", "abandoned", 200),
|
||||
])
|
||||
def test_status_moves_only_from_open(router, target, then, expected):
|
||||
record = notebook(router)
|
||||
path = f"/hux/v1/notebooks/{record['id']}"
|
||||
status, moved, _ = call(router, "PATCH", path, {"status": target}, {"If-Match": "1"})
|
||||
assert status == 200 and moved["status"] == target and valid(moved)
|
||||
status, body, _ = call(router, "PATCH", path, {"status": then}, {"If-Match": "2"})
|
||||
assert status == expected
|
||||
if expected == 409:
|
||||
assert body["code"] == "conflict" and call(router, "GET", path)[1]["status"] == target
|
||||
assert call(router, "PATCH", path, {"status": "done"}, {"If-Match": str(3 if expected == 200 else 2)})[0] == 409
|
||||
assert call(router, "PATCH", path, {"status": target}, {"If-Match": str(3 if expected == 200 else 2)})[0] == 200
|
||||
|
||||
|
||||
def test_second_subject_cannot_read_patch_or_attach(router, evidence):
|
||||
src, psg, cit = evidence
|
||||
record = notebook(router)
|
||||
path = f"/hux/v1/notebooks/{record['id']}"
|
||||
assert call(router, "GET", path, headers=OTHER)[0] == 404
|
||||
status, error, _ = call(router, "PATCH", path, {"status": "abandoned"}, {**OTHER, "If-Match": "1"})
|
||||
assert (status, error["code"]) == (404, "not_found") and valid(error)
|
||||
theirs = call(router, "POST", "/hux/v1/notebooks", {"conversation_id": "conv_0001abcd", "question": "spy"}, OTHER)[1]
|
||||
for key, value in (("add_source_ids", src["id"]), ("add_passage_ids", psg["id"]), ("add_citation_ids", cit["id"])):
|
||||
status, error, _ = call(router, "PATCH", f"/hux/v1/notebooks/{theirs['id']}", {key: [value]}, {**OTHER, "If-Match": "1"})
|
||||
assert status == 404, key
|
||||
status, mine, _ = call(router, "GET", path)
|
||||
assert mine["status"] == "open" and mine["revision"] == 1
|
||||
other_rows = audit.recent(store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_fedcba9876543210", "chat", "router")))
|
||||
assert other_rows[-1]["outcome"] == "not_found" and other_rows[-1]["action"] == "research.notebook_patch"
|
||||
|
||||
|
||||
def test_notebook_paths_are_tenant_scoped(router):
|
||||
record = notebook(router)
|
||||
tenant = store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router"))
|
||||
assert (tenant.root / "notebooks" / f"{record['id']}.json").exists()
|
||||
assert call(router, "GET", "/hux/v1/notebooks/nb_missing000001")[0] == 404
|
||||
assert call(router, "GET", "/hux/v1/notebooks/NB")[0] == 404
|
||||
Loading…
x
Reference in New Issue
Block a user