Compare commits
8 Commits
e1ef110c8a
...
124206b748
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
124206b748 | ||
|
|
1cb6f07c78 | ||
|
|
b3de70bacb | ||
|
|
ada74ce0d4 | ||
|
|
fc8ec28db3 | ||
|
|
700d6fc326 | ||
|
|
7cf9a714a4 | ||
|
|
47a9fd8354 |
0
dockerfiles/hermes-hux-foundation/hux/__init__.py
Normal file
0
dockerfiles/hermes-hux-foundation/hux/__init__.py
Normal file
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)
|
||||
40
dockerfiles/hermes-hux-foundation/hux/audit.py
Normal file
40
dockerfiles/hermes-hux-foundation/hux/audit.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""Auditable outcome for every read and mutation.
|
||||
|
||||
One JSONL ledger per tenant per day. The record shape is
|
||||
``common.schema.json#/$defs/audit_outcome``; it never carries request bodies,
|
||||
only the action name, the resource id and the decision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, now_iso
|
||||
|
||||
OUTCOMES = ("allow", "deny", "not_found", "conflict", "flag_off")
|
||||
|
||||
|
||||
def record(store: TenantStore, identity: Identity, action: str, resource: str, outcome: str, reason: str = "") -> dict:
|
||||
"""Append an audit outcome and return it."""
|
||||
if outcome not in OUTCOMES:
|
||||
raise ValueError(f"unknown outcome {outcome!r}")
|
||||
entry = {
|
||||
"at": now_iso(),
|
||||
"identity": identity.record(),
|
||||
"action": action,
|
||||
"resource": resource[:200],
|
||||
"outcome": outcome,
|
||||
}
|
||||
if reason:
|
||||
entry["reason"] = reason[:200]
|
||||
store.append("audit", now_iso()[:10], entry)
|
||||
return entry
|
||||
|
||||
|
||||
def recent(store: TenantStore, limit: int = 200) -> list[dict]:
|
||||
"""Newest audit rows across day ledgers, newest last."""
|
||||
rows: list[dict] = []
|
||||
for name in reversed(store.ledgers("audit")):
|
||||
rows = store.read("audit", name) + rows
|
||||
if len(rows) >= limit:
|
||||
break
|
||||
return rows[-limit:]
|
||||
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)
|
||||
|
||||
@ -10,13 +10,18 @@ loudly rather than silently passing.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
CONTRACT_DIR = Path(__file__).resolve().parents[1] / "contracts" / "hux"
|
||||
CONTRACT_DIR = Path(
|
||||
os.environ.get("HUX_CONTRACT_DIR")
|
||||
or Path(__file__).resolve().parents[3] / "services" / "hermes" / "contracts" / "hux"
|
||||
)
|
||||
SCHEMA_FILES = (
|
||||
"common.schema.json",
|
||||
"identity.schema.json",
|
||||
"event.schema.json",
|
||||
"memory.schema.json",
|
||||
"project.schema.json",
|
||||
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
|
||||
82
dockerfiles/hermes-hux-foundation/hux/errors.py
Normal file
82
dockerfiles/hermes-hux-foundation/hux/errors.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""Error types that map one-to-one onto ``hux.error.v1`` records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class HuxError(Exception):
|
||||
"""Base class; ``status`` and ``code`` follow identity.schema.json#/$defs/error."""
|
||||
|
||||
status = 500
|
||||
code = "invalid"
|
||||
|
||||
def __init__(self, message: str, details: list[str] | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.details = list(details or [])
|
||||
|
||||
def record(self) -> dict:
|
||||
"""Serialise as a ``hux.error.v1`` record."""
|
||||
body = {"schema": "hux.error.v1", "status": self.status, "code": self.code, "message": self.message[:280]}
|
||||
if self.details:
|
||||
body["details"] = [d[:280] for d in self.details[:32]]
|
||||
return body
|
||||
|
||||
|
||||
class Unauthorized(HuxError):
|
||||
"""Identity headers missing, malformed or not trusted."""
|
||||
|
||||
status, code = 401, "unauthorized"
|
||||
|
||||
|
||||
class Forbidden(HuxError):
|
||||
"""Identity is valid but does not own the resource."""
|
||||
|
||||
status, code = 403, "forbidden"
|
||||
|
||||
|
||||
class NotFound(HuxError):
|
||||
"""Resource does not exist for this tenant."""
|
||||
|
||||
status, code = 404, "not_found"
|
||||
|
||||
|
||||
class FlagOff(HuxError):
|
||||
"""Card (or one of its dependencies) is disabled; indistinguishable from not found on purpose."""
|
||||
|
||||
status, code = 404, "flag_off"
|
||||
|
||||
|
||||
class Conflict(HuxError):
|
||||
"""If-Match revision mismatch or duplicate create."""
|
||||
|
||||
status, code = 409, "conflict"
|
||||
|
||||
|
||||
class Invalid(HuxError):
|
||||
"""Body failed contract validation."""
|
||||
|
||||
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."""
|
||||
|
||||
status, code = 413, "too_large"
|
||||
|
||||
|
||||
class ApprovalRequired(HuxError):
|
||||
"""An action needs an approval record before it may proceed."""
|
||||
|
||||
status, code = 403, "approval_required"
|
||||
|
||||
|
||||
class BudgetExhausted(HuxError):
|
||||
"""A run budget has been spent."""
|
||||
|
||||
status, code = 429, "budget_exhausted"
|
||||
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)
|
||||
71
dockerfiles/hermes-hux-foundation/hux/flags.py
Normal file
71
dockerfiles/hermes-hux-foundation/hux/flags.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""Per-card feature flags and the capabilities record clients negotiate with.
|
||||
|
||||
Flags come from the ``HUX_FLAGS`` comma list. A card counts as enabled only
|
||||
when it and every card it depends on are enabled, so a half-configured
|
||||
deployment fails closed. Route ownership per card is declared here so the
|
||||
capabilities record can tell a client exactly what it may call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
|
||||
from hux.errors import FlagOff
|
||||
from hux.identity import Identity
|
||||
from hux.rules import flag_enabled, flag_registry
|
||||
|
||||
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"],
|
||||
"HUX-02": ["/hux/v1/memory", "/hux/v1/memory/{id}", "/hux/v1/memory/{id}/{action}", "/hux/v1/memory/export"],
|
||||
"HUX-03": ["/hux/v1/projects", "/hux/v1/projects/{id}", "/hux/v1/conversations", "/hux/v1/conversations/{id}", "/hux/v1/conversations/{id}/branch", "/hux/v1/conversations/{id}/lineage", "/hux/v1/search"],
|
||||
"HUX-04": ["/hux/v1/artifacts", "/hux/v1/artifacts/{id}", "/hux/v1/artifacts/{id}/versions", "/hux/v1/artifacts/{id}/versions/{n}", "/hux/v1/artifacts/{id}/versions/{n}/diff", "/hux/v1/artifacts/{id}/promote"],
|
||||
"HUX-05": ["/hux/v1/policy", "/hux/v1/approvals", "/hux/v1/approvals/{id}", "/hux/v1/runs/{id}/stop", "/hux/v1/runs/{id}/budget", "/hux/v1/runs/{id}/gate"],
|
||||
"HUX-06": ["/hux/v1/modes", "/hux/v1/conversations/{id}/mode"],
|
||||
"HUX-07": [],
|
||||
"HUX-08": ["/hux/v1/sources", "/hux/v1/sources/{id}", "/hux/v1/passages", "/hux/v1/messages/{id}/citations", "/hux/v1/notebooks", "/hux/v1/notebooks/{id}"],
|
||||
"HUX-09": ["/hux/v1/suggestions", "/hux/v1/suggestions/{id}/{action}"],
|
||||
"HUX-10": ["/hux/v1/privacy/policy", "/hux/v1/privacy/notices", "/hux/v1/conversations/{id}/forget", "/hux/v1/privacy/audit"],
|
||||
"HUX-12": ["/hux/v1/releases"],
|
||||
}
|
||||
|
||||
|
||||
class Flags:
|
||||
"""Snapshot of which cards are on for this process."""
|
||||
|
||||
def __init__(self, environ: Mapping[str, str] | None = None) -> None:
|
||||
self._environ = dict(os.environ if environ is None else environ)
|
||||
self._registry = flag_registry()
|
||||
|
||||
def enabled(self, card: str) -> bool:
|
||||
"""True when the card and its whole dependency chain are on."""
|
||||
entry = self._registry.get(card)
|
||||
return bool(entry) and flag_enabled(entry["flag"], self._environ)
|
||||
|
||||
def require(self, card: str) -> None:
|
||||
"""Raise FlagOff unless the card is enabled."""
|
||||
if not self.enabled(card):
|
||||
raise FlagOff(f"{card} is not enabled")
|
||||
|
||||
def capabilities(self, identity: Identity, build: Mapping[str, str] | None = None) -> dict:
|
||||
"""Serialise ``hux.capabilities.v1`` for one caller."""
|
||||
cards = [
|
||||
{"card": card, "flag": entry["flag"], "enabled": self.enabled(card), "routes": CARD_ROUTES.get(card, [])}
|
||||
for card, entry in sorted(self._registry.items())
|
||||
]
|
||||
server = {k: v for k, v in (build or {}).items() if k in {"commit", "image_digest"} and v}
|
||||
return {
|
||||
"schema": "hux.capabilities.v1",
|
||||
"contract_version": CONTRACT_VERSION,
|
||||
"identity": identity.record(),
|
||||
"cards": cards,
|
||||
"server": server,
|
||||
}
|
||||
|
||||
|
||||
def build_from_environ(environ: Mapping[str, str] | None = None) -> dict[str, str]:
|
||||
"""Commit and image digest the pod was started with, when the operator set them."""
|
||||
environ = os.environ if environ is None else environ
|
||||
return {"commit": environ.get("HUX_BUILD_COMMIT", ""), "image_digest": environ.get("HUX_IMAGE_DIGEST", "")}
|
||||
24
dockerfiles/hermes-hux-foundation/hux/foundation.py
Normal file
24
dockerfiles/hermes-hux-foundation/hux/foundation.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""HUX-11 routes: capability negotiation and the data manifest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hux.flags import CONTRACT_VERSION
|
||||
from hux.http import Request, Response, Router
|
||||
|
||||
|
||||
def capabilities(request: Request) -> Response:
|
||||
"""``GET /hux/v1/capabilities``: what this tenant may call."""
|
||||
request.audit("foundation.capabilities", "capabilities")
|
||||
return Response(200, request.flags.capabilities(request.identity, request.flags_build))
|
||||
|
||||
|
||||
def manifest(request: Request) -> Response:
|
||||
"""``GET /hux/v1/manifest``: the on-disk layout version for rollback readers."""
|
||||
request.audit("foundation.manifest", "manifest")
|
||||
return Response(200, request.store.manifest(CONTRACT_VERSION))
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-11 routes."""
|
||||
router.add("GET", "/hux/v1/capabilities", "HUX-11", "foundation.capabilities", capabilities)
|
||||
router.add("GET", "/hux/v1/manifest", "HUX-11", "foundation.manifest", manifest)
|
||||
214
dockerfiles/hermes-hux-foundation/hux/http.py
Normal file
214
dockerfiles/hermes-hux-foundation/hux/http.py
Normal file
@ -0,0 +1,214 @@
|
||||
"""Minimal HTTP layer for the per-tenant HUX service.
|
||||
|
||||
Stdlib only. A ``Router`` maps method + path template to a handler; family
|
||||
modules register their routes with it. Every request resolves identity from
|
||||
trusted headers, checks the owning card's flag, and writes an audit outcome.
|
||||
Errors always leave as ``hux.error.v1``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from collections.abc import Mapping
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from hux import audit
|
||||
from hux.errors import HuxError, Invalid, NotFound, TooLarge
|
||||
from hux.flags import Flags, build_from_environ
|
||||
from hux.identity import Identity, resolve
|
||||
from hux.store import TenantStore
|
||||
|
||||
MAX_BODY_BYTES = 1024 * 1024
|
||||
Handler = Callable[["Request"], "Response"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Request:
|
||||
"""Everything a handler needs; no raw socket access."""
|
||||
|
||||
method: str
|
||||
path: str
|
||||
params: dict[str, str]
|
||||
query: dict[str, str]
|
||||
headers: Mapping[str, str]
|
||||
body: Any
|
||||
identity: Identity
|
||||
store: TenantStore
|
||||
flags: Flags
|
||||
flags_build: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def if_match(self) -> int | None:
|
||||
"""Parsed If-Match revision, or None when absent."""
|
||||
raw = self.header("If-Match")
|
||||
if raw == "":
|
||||
return None
|
||||
if not raw.isdigit():
|
||||
raise Invalid("If-Match must be a revision integer")
|
||||
return int(raw)
|
||||
|
||||
def idempotency_key(self) -> str:
|
||||
"""Client idempotency key, validated against the contract pattern."""
|
||||
raw = self.header("Idempotency-Key")
|
||||
if raw and not re.match(r"^[A-Za-z0-9._:-]{8,120}$", raw):
|
||||
raise Invalid("malformed Idempotency-Key")
|
||||
return raw
|
||||
|
||||
def header(self, name: str) -> str:
|
||||
"""Case-insensitive header lookup."""
|
||||
for key, value in self.headers.items():
|
||||
if key.lower() == name.lower():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
def audit(self, action: str, resource: str, outcome: str = "allow", reason: str = "") -> None:
|
||||
"""Write an audit outcome for this request."""
|
||||
audit.record(self.store, self.identity, action, resource, outcome, reason)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Response:
|
||||
"""JSON (or SSE) response."""
|
||||
|
||||
status: int = 200
|
||||
body: Any = None
|
||||
headers: dict[str, str] = field(default_factory=dict)
|
||||
stream: Callable[[], Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Route:
|
||||
"""One registered handler."""
|
||||
|
||||
method: str
|
||||
template: str
|
||||
card: str
|
||||
action: str
|
||||
handler: Handler
|
||||
max_body: int = MAX_BODY_BYTES
|
||||
pattern: re.Pattern = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
regex = re.sub(r"\{(\w+)\}", r"(?P<\1>[A-Za-z0-9._:-]+)", self.template)
|
||||
self.pattern = re.compile(f"^{regex}$")
|
||||
|
||||
|
||||
class Router:
|
||||
"""Route table plus the request pipeline."""
|
||||
|
||||
def __init__(self, data_root: Path, environ: Mapping[str, str] | None = None) -> None:
|
||||
self.data_root = Path(data_root)
|
||||
self.environ = environ
|
||||
self.flags = Flags(environ)
|
||||
self.build = build_from_environ(environ)
|
||||
self.routes: list[Route] = []
|
||||
|
||||
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)."""
|
||||
known = False
|
||||
for route in self.routes:
|
||||
found = route.pattern.match(path)
|
||||
if found:
|
||||
known = True
|
||||
if route.method == method:
|
||||
return route, found.groupdict(), True
|
||||
return None, {}, known
|
||||
|
||||
def dispatch(self, method: str, raw_path: str, headers: Mapping[str, str], body: bytes) -> Response:
|
||||
"""Run the full pipeline and never raise."""
|
||||
parts = urlsplit(raw_path)
|
||||
query = {k: v[-1] for k, v in parse_qs(parts.query).items()}
|
||||
try:
|
||||
identity = resolve(headers, self.environ)
|
||||
store = TenantStore(self.data_root, identity)
|
||||
except HuxError as error:
|
||||
return Response(error.status, error.record())
|
||||
route, params, known = self.match(method, parts.path)
|
||||
if route is None:
|
||||
error = Invalid("method not allowed") if known else NotFound("no such route")
|
||||
audit.record(store, identity, "http.route", parts.path, "not_found", error.message)
|
||||
return Response(405 if known else 404, error.record())
|
||||
try:
|
||||
self.flags.require(route.card)
|
||||
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:
|
||||
outcome = {"flag_off": "flag_off", "conflict": "conflict", "not_found": "not_found"}.get(error.code, "deny")
|
||||
audit.record(store, identity, route.action, parts.path, outcome, error.message)
|
||||
return Response(error.status, error.record())
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _decode(body: bytes, max_body: int = MAX_BODY_BYTES) -> Any:
|
||||
if not body:
|
||||
return None
|
||||
if len(body) > max_body:
|
||||
raise TooLarge(f"body exceeds {max_body} bytes")
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError as error:
|
||||
raise Invalid(f"body is not JSON: {error.msg}") from error
|
||||
|
||||
|
||||
def page(items: list[Any], next_cursor: Any = None) -> Response:
|
||||
"""Standard list envelope."""
|
||||
return Response(200, {"items": items, "next": next_cursor})
|
||||
|
||||
|
||||
def make_handler(router: Router) -> type[BaseHTTPRequestHandler]:
|
||||
"""Bind a Router to a BaseHTTPRequestHandler subclass."""
|
||||
|
||||
class HuxHandler(BaseHTTPRequestHandler):
|
||||
server_version = "hux-foundation/1.0"
|
||||
|
||||
def log_message(self, fmt: str, *args: Any) -> None: # noqa: D102 - quiet by design; audit ledger is the log
|
||||
return
|
||||
|
||||
def _run(self) -> None:
|
||||
if self.path == "/healthz":
|
||||
self._send(Response(200, {"status": "ok", "contract_version": "1.0.0"}))
|
||||
return
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length else b""
|
||||
self._send(router.dispatch(self.command, self.path, dict(self.headers.items()), body))
|
||||
|
||||
def _send(self, response: Response) -> None:
|
||||
if response.stream is not None:
|
||||
self.send_response(response.status)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
for key, value in response.headers.items():
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
for chunk in response.stream():
|
||||
self.wfile.write(chunk)
|
||||
self.wfile.flush()
|
||||
return
|
||||
data = json.dumps(response.body, sort_keys=True).encode()
|
||||
self.send_response(response.status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
for key, value in response.headers.items():
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _run
|
||||
|
||||
return HuxHandler
|
||||
|
||||
|
||||
def serve(router: Router, host: str = "127.0.0.1", port: int = 8790) -> ThreadingHTTPServer:
|
||||
"""Create (but do not start) the server; callers call serve_forever()."""
|
||||
server = ThreadingHTTPServer((host, port), make_handler(router))
|
||||
server.daemon_threads = True
|
||||
return server
|
||||
80
dockerfiles/hermes-hux-foundation/hux/identity.py
Normal file
80
dockerfiles/hermes-hux-foundation/hux/identity.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""Caller identity resolved from the trusted hop's headers.
|
||||
|
||||
The chat router, the Telegram relay and the Worker are the only callers. Each
|
||||
asserts identity in headers; the request body is never trusted for identity.
|
||||
Relay and worker callers must also present their shared key, compared in
|
||||
constant time against the value the pod was started with.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Mapping
|
||||
|
||||
from hux.errors import Unauthorized
|
||||
|
||||
HEADER_SLOT = "X-Hermes-Tenant-Identity"
|
||||
HEADER_SUBJECT = "X-Hux-Subject"
|
||||
HEADER_SURFACE = "X-Hux-Surface"
|
||||
HEADER_TRUST = "X-Hux-Trust"
|
||||
HEADER_KEY = "X-Hux-Relay-Key"
|
||||
|
||||
SLOT_RE = re.compile(r"^slot-[0-9]{1,3}$")
|
||||
SUBJECT_RE = re.compile(r"^usr_[0-9a-f]{16,64}$")
|
||||
SURFACES = ("chat", "worker", "telegram", "voice", "api")
|
||||
TRUSTS = ("router", "relay", "worker")
|
||||
KEY_ENV = {"relay": "HUX_RELAY_KEY", "worker": "HUX_WORKER_KEY"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Identity:
|
||||
"""Who is calling, on which surface, asserted by which trusted hop."""
|
||||
|
||||
tenant_slot: str
|
||||
subject: str
|
||||
surface: str
|
||||
trust: str
|
||||
|
||||
def record(self) -> dict[str, str]:
|
||||
"""Serialise as ``common.identity``."""
|
||||
return {"tenant_slot": self.tenant_slot, "subject": self.subject, "surface": self.surface, "trust": self.trust}
|
||||
|
||||
|
||||
def _header(headers: Mapping[str, str], name: str) -> str:
|
||||
for key, value in headers.items():
|
||||
if key.lower() == name.lower():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def resolve(headers: Mapping[str, str], environ: Mapping[str, str] | None = None) -> Identity:
|
||||
"""Build an Identity from request headers or raise Unauthorized.
|
||||
|
||||
``trust`` defaults to ``router``; relay and worker trust require the
|
||||
matching shared key to be configured and presented.
|
||||
"""
|
||||
environ = os.environ if environ is None else environ
|
||||
slot = _header(headers, HEADER_SLOT)
|
||||
subject = _header(headers, HEADER_SUBJECT)
|
||||
surface = _header(headers, HEADER_SURFACE) or "chat"
|
||||
trust = _header(headers, HEADER_TRUST) or "router"
|
||||
if not SLOT_RE.match(slot):
|
||||
raise Unauthorized("missing or malformed tenant slot")
|
||||
own_slot = environ.get("HUX_TENANT_SLOT", "")
|
||||
if own_slot and slot != own_slot:
|
||||
raise Unauthorized("tenant slot does not belong to this pod")
|
||||
if not SUBJECT_RE.match(subject):
|
||||
raise Unauthorized("missing or malformed subject")
|
||||
if surface not in SURFACES or trust not in TRUSTS:
|
||||
raise Unauthorized("unknown surface or trust")
|
||||
if trust in KEY_ENV:
|
||||
expected = environ.get(KEY_ENV[trust], "")
|
||||
presented = _header(headers, HEADER_KEY)
|
||||
if not expected or not presented or not hmac.compare_digest(expected, presented):
|
||||
raise Unauthorized(f"{trust} key missing or wrong")
|
||||
if trust == "router" and surface == "worker":
|
||||
raise Unauthorized("worker surface needs worker trust")
|
||||
return Identity(slot, subject, surface, trust)
|
||||
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)
|
||||
@ -13,7 +13,7 @@ import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from hux_contracts import load_flags
|
||||
from hux.contracts import load_flags
|
||||
|
||||
EFFORT_ORDER = ("low", "medium", "high", "xhigh")
|
||||
|
||||
@ -44,11 +44,12 @@ RELEASE_EVIDENCE: dict[str, tuple[str, ...]] = {
|
||||
|
||||
CAPABILITIES = (
|
||||
"read_files", "write_files", "shell", "network", "web_search", "send_message",
|
||||
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy",
|
||||
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy", "external_side_effect",
|
||||
)
|
||||
_READ_ONLY = frozenset({"read_files", "web_search", "spend_tokens"})
|
||||
_MUTATING = frozenset({"write_files", "shell", "send_message", "memory_write", "artifact_write", "delegate"})
|
||||
_EXTERNAL = frozenset({"network", "deploy"})
|
||||
_ALWAYS_ASK = frozenset({"deploy", "external_side_effect"})
|
||||
|
||||
MODE_CATALOG: dict[str, dict[str, Any]] = {
|
||||
"fast": {
|
||||
@ -123,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:
|
||||
@ -140,12 +142,12 @@ def memory_policy_violations(entry: dict[str, Any]) -> list[str]:
|
||||
|
||||
|
||||
def default_capability_matrix() -> dict[str, dict[str, str]]:
|
||||
"""Autonomy level -> capability -> allow|ask|deny. Deploy always asks."""
|
||||
"""Autonomy level -> capability -> allow|ask|deny. Deploy and external side effects always ask."""
|
||||
matrix: dict[str, dict[str, str]] = {}
|
||||
for level in ("ask_first", "safe", "autonomous"):
|
||||
row: dict[str, str] = {}
|
||||
for capability in CAPABILITIES:
|
||||
if capability == "deploy":
|
||||
if capability in _ALWAYS_ASK:
|
||||
row[capability] = "ask"
|
||||
elif capability in _READ_ONLY:
|
||||
row[capability] = "allow"
|
||||
@ -171,7 +173,7 @@ def effective_decision(policy: dict[str, Any], capability: str, now: datetime |
|
||||
if grant["decision"] == "deny":
|
||||
return "deny"
|
||||
decision = grant["decision"]
|
||||
if capability == "deploy" and decision == "allow":
|
||||
if capability in _ALWAYS_ASK and decision == "allow":
|
||||
return "ask"
|
||||
return decision
|
||||
|
||||
48
dockerfiles/hermes-hux-foundation/hux/server.py
Normal file
48
dockerfiles/hermes-hux-foundation/hux/server.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Process entry point: ``python -m hux.server``.
|
||||
|
||||
Environment: ``HUX_DATA_ROOT`` (tenant PVC path, default /opt/data),
|
||||
``HUX_BIND`` (default 127.0.0.1), ``HUX_PORT`` (default 8790), ``HUX_FLAGS``,
|
||||
``HUX_RELAY_KEY``, ``HUX_WORKER_KEY``, ``HUX_BUILD_COMMIT``, ``HUX_IMAGE_DIGEST``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from hux.http import Router, serve
|
||||
|
||||
FAMILIES = ("foundation", "events", "memory", "privacy", "organization", "artifacts", "research", "policy")
|
||||
|
||||
|
||||
def build_router(data_root: Path, environ: dict[str, str] | None = None) -> Router:
|
||||
"""Create a router with every family module that is present registered."""
|
||||
router = Router(data_root, environ)
|
||||
for name in FAMILIES:
|
||||
try:
|
||||
module = importlib.import_module(f"hux.{name}")
|
||||
except ModuleNotFoundError:
|
||||
continue
|
||||
module.register(router)
|
||||
return router
|
||||
|
||||
|
||||
def bind_address(env: dict[str, str]) -> str:
|
||||
"""Loopback only: the router is in the same pod, nothing else may reach the service."""
|
||||
bind = env.get("HUX_BIND", "127.0.0.1")
|
||||
if bind not in {"127.0.0.1", "::1", "localhost"}:
|
||||
raise SystemExit(f"refusing to bind {bind}: hux-foundation is loopback-only")
|
||||
return bind
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the server until killed."""
|
||||
env = dict(os.environ)
|
||||
router = build_router(Path(env.get("HUX_DATA_ROOT", "/opt/data")), env)
|
||||
server = serve(router, bind_address(env), int(env.get("HUX_PORT", "8790")))
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
223
dockerfiles/hermes-hux-foundation/hux/store.py
Normal file
223
dockerfiles/hermes-hux-foundation/hux/store.py
Normal file
@ -0,0 +1,223 @@
|
||||
"""Per-tenant durable storage on the tenant PVC.
|
||||
|
||||
Every path is derived from the caller's tenant slot and hashed subject, so
|
||||
there is no shared cross-tenant store and no way to address another tenant's
|
||||
data. Records are JSON documents with a ``revision`` for optimistic
|
||||
concurrency; ledgers are append-only JSONL. Writes are atomic (temp file +
|
||||
fsync + rename) and guarded by one lock per family per tenant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from collections.abc import Iterator
|
||||
|
||||
from hux.errors import Conflict, Invalid, NotFound, TooLarge
|
||||
from hux.identity import Identity
|
||||
|
||||
ID_RE = re.compile(r"^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$")
|
||||
LAYOUT_VERSION = 1
|
||||
MAX_RECORD_BYTES = 256 * 1024
|
||||
MAX_FAMILY_RECORDS = 20000
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
"""RFC 3339 UTC timestamp with second precision and a Z suffix."""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def new_id(prefix: str) -> str:
|
||||
"""Opaque, prefix-typed identifier matching common.schema.json#/$defs/id."""
|
||||
return f"{prefix}_{os.urandom(8).hex()}{int(time.time() * 1000) % 100000:05d}"
|
||||
|
||||
|
||||
def check_id(value: Any) -> str:
|
||||
"""Return ``value`` when it is a well-formed record id, else raise Invalid."""
|
||||
if not isinstance(value, str) or not ID_RE.match(value):
|
||||
raise Invalid("malformed id")
|
||||
return value
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: bytes) -> None:
|
||||
tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
||||
with open(tmp, "wb") as handle:
|
||||
handle.write(data)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
class TenantStore:
|
||||
"""Files for exactly one (tenant slot, subject) pair."""
|
||||
|
||||
_locks: dict[str, threading.RLock] = {}
|
||||
_locks_guard = threading.Lock()
|
||||
|
||||
def __init__(self, root: Path, identity: Identity) -> None:
|
||||
self.identity = identity
|
||||
self.root = Path(root) / "hux" / f"v{LAYOUT_VERSION}" / identity.tenant_slot / identity.subject
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# -- locking -----------------------------------------------------------
|
||||
def lock(self, family: str) -> threading.RLock:
|
||||
"""One re-entrant lock per family per tenant directory."""
|
||||
key = f"{self.root}:{family}"
|
||||
with self._locks_guard:
|
||||
return self._locks.setdefault(key, threading.RLock())
|
||||
|
||||
# -- documents ---------------------------------------------------------
|
||||
def _doc_path(self, family: str, record_id: str) -> Path:
|
||||
directory = self.root / family
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory / f"{check_id(record_id)}.json"
|
||||
|
||||
def get(self, family: str, record_id: str) -> dict[str, Any]:
|
||||
"""Read one document or raise NotFound."""
|
||||
path = self._doc_path(family, record_id)
|
||||
if not path.exists():
|
||||
raise NotFound(f"{family} {record_id} not found")
|
||||
return json.loads(path.read_bytes())
|
||||
|
||||
def exists(self, family: str, record_id: str) -> bool:
|
||||
"""True when the document is present."""
|
||||
return self._doc_path(family, record_id).exists()
|
||||
|
||||
def put(self, family: str, record: dict[str, Any], expected_revision: int | None = None) -> dict[str, Any]:
|
||||
"""Create or replace a document, bumping ``revision``.
|
||||
|
||||
``expected_revision`` implements If-Match: it must equal the stored
|
||||
revision (or be None for a create of a new id) or Conflict is raised.
|
||||
"""
|
||||
record_id = check_id(record.get("id"))
|
||||
with self.lock(family):
|
||||
path = self._doc_path(family, record_id)
|
||||
current = json.loads(path.read_bytes()) if path.exists() else None
|
||||
if current is None:
|
||||
if expected_revision not in (None, 0):
|
||||
raise Conflict("record does not exist yet")
|
||||
if self.count(family) >= MAX_FAMILY_RECORDS:
|
||||
raise TooLarge(f"{family} is full")
|
||||
revision = 1
|
||||
else:
|
||||
if expected_revision is not None and expected_revision != current.get("revision"):
|
||||
raise Conflict(f"revision {expected_revision} does not match current revision {current.get('revision')}")
|
||||
revision = int(current.get("revision", 0)) + 1
|
||||
stored = {**record, "revision": revision}
|
||||
data = json.dumps(stored, sort_keys=True, separators=(",", ":")).encode()
|
||||
if len(data) > MAX_RECORD_BYTES:
|
||||
raise TooLarge("record exceeds size bound")
|
||||
_atomic_write(path, data)
|
||||
return stored
|
||||
|
||||
def delete(self, family: str, record_id: str) -> None:
|
||||
"""Remove a document; missing is not an error."""
|
||||
path = self._doc_path(family, record_id)
|
||||
with self.lock(family):
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
def count(self, family: str) -> int:
|
||||
"""Number of documents in a family."""
|
||||
directory = self.root / family
|
||||
return sum(1 for p in directory.glob("*.json")) if directory.exists() else 0
|
||||
|
||||
def scan(self, family: str) -> Iterator[dict[str, Any]]:
|
||||
"""Yield every document in a family, oldest file first."""
|
||||
directory = self.root / family
|
||||
if not directory.exists():
|
||||
return
|
||||
for path in sorted(directory.glob("*.json"), key=lambda p: (p.stat().st_mtime_ns, p.name)):
|
||||
yield json.loads(path.read_bytes())
|
||||
|
||||
# -- ledgers -----------------------------------------------------------
|
||||
def _ledger_path(self, family: str, name: str) -> Path:
|
||||
directory = self.root / family
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
if not re.match(r"^[A-Za-z0-9._-]{1,120}$", name):
|
||||
raise Invalid("malformed ledger name")
|
||||
return directory / f"{name}.jsonl"
|
||||
|
||||
def append(self, family: str, name: str, record: dict[str, Any]) -> None:
|
||||
"""Append one JSON line with fsync."""
|
||||
line = json.dumps(record, sort_keys=True, separators=(",", ":")).encode() + b"\n"
|
||||
if len(line) > MAX_RECORD_BYTES:
|
||||
raise TooLarge("ledger record exceeds size bound")
|
||||
with self.lock(family):
|
||||
path = self._ledger_path(family, name)
|
||||
with open(path, "ab") as handle:
|
||||
handle.write(line)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
def read(self, family: str, name: str) -> list[dict[str, Any]]:
|
||||
"""Read a whole ledger; a torn trailing line from a crash is dropped."""
|
||||
path = self._ledger_path(family, name)
|
||||
if not path.exists():
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
for line in path.read_bytes().split(b"\n"):
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
break
|
||||
return rows
|
||||
|
||||
def ledgers(self, family: str) -> list[str]:
|
||||
"""Names of the ledgers in a family."""
|
||||
directory = self.root / family
|
||||
return sorted(p.stem for p in directory.glob("*.jsonl")) if directory.exists() else []
|
||||
|
||||
def rewrite(self, family: str, name: str, rows: list[dict[str, Any]]) -> None:
|
||||
"""Replace a ledger atomically (used by retention purges)."""
|
||||
data = b"".join(json.dumps(r, sort_keys=True, separators=(",", ":")).encode() + b"\n" for r in rows)
|
||||
with self.lock(family):
|
||||
_atomic_write(self._ledger_path(family, name), data)
|
||||
|
||||
# -- blobs -------------------------------------------------------------
|
||||
def put_blob(self, digest: str, data: bytes) -> Path:
|
||||
"""Store content-addressed bytes; the caller has already verified the digest."""
|
||||
if not re.match(r"^[0-9a-f]{64}$", digest):
|
||||
raise Invalid("malformed digest")
|
||||
directory = self.root / "blobs" / digest[:2]
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / digest
|
||||
if not path.exists():
|
||||
_atomic_write(path, data)
|
||||
return path
|
||||
|
||||
def get_blob(self, digest: str) -> bytes:
|
||||
"""Read content-addressed bytes or raise NotFound."""
|
||||
if not re.match(r"^[0-9a-f]{64}$", digest):
|
||||
raise Invalid("malformed digest")
|
||||
path = self.root / "blobs" / digest[:2] / digest
|
||||
if not path.exists():
|
||||
raise NotFound("blob not found")
|
||||
return path.read_bytes()
|
||||
|
||||
# -- manifest ----------------------------------------------------------
|
||||
def manifest(self, contract_version: str) -> dict[str, Any]:
|
||||
"""Read or create the ``hux.manifest.v1`` for this tenant directory."""
|
||||
path = self.root / "MANIFEST.json"
|
||||
with self.lock("manifest"):
|
||||
if path.exists():
|
||||
return json.loads(path.read_bytes())
|
||||
stamp = now_iso()
|
||||
record = {
|
||||
"schema": "hux.manifest.v1",
|
||||
"contract_version": contract_version,
|
||||
"data_layout_version": LAYOUT_VERSION,
|
||||
"min_reader_contract_version": "1.0.0",
|
||||
"created_at": stamp,
|
||||
"updated_at": stamp,
|
||||
}
|
||||
_atomic_write(path, json.dumps(record, sort_keys=True).encode())
|
||||
return record
|
||||
@ -10,9 +10,12 @@ Schemas live in `services/hermes/contracts/hux/` as JSON Schema 2020-12 with
|
||||
one worked example per record under `examples/`. The rules a schema cannot
|
||||
express (state machines, capability matrix, mode catalog, privacy defaults,
|
||||
suggestion gating, flag dependencies) live in
|
||||
`services/hermes/scripts/hux_policy.py`; `hux_contracts.py` validates records
|
||||
without external packages. `testing/tests/test_hermes_hux_contracts.py` keeps
|
||||
schemas, examples, rules and the live Switchyard catalog in agreement.
|
||||
`dockerfiles/hermes-hux-foundation/hux/rules.py`; `hux/contracts.py`
|
||||
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.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
|
||||
|
||||
@ -51,15 +54,16 @@ tenant. Responses are the records below, wrapped as `{"items": [...],
|
||||
|
||||
| Area | Routes | Record |
|
||||
|---|---|---|
|
||||
| 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` |
|
||||
| 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/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 | `hux.policy.v1`, `hux.approval.v1`, `hux.cancel_receipt.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,
|
||||
|
||||
80
docs/hux/ADR-0001-hux-v1-contract-freeze.md
Normal file
80
docs/hux/ADR-0001-hux-v1-contract-freeze.md
Normal file
@ -0,0 +1,80 @@
|
||||
# ADR-0001: freeze the hux.v1 contract at 1.0.0
|
||||
|
||||
Status: accepted, 2026-08-24. Owners: Claude (backend), Codex (integration).
|
||||
|
||||
## Decision
|
||||
|
||||
`services/hermes/contracts/hux/` is frozen as contract version `1.0.0`
|
||||
(`VERSION`, `flags.json.contract_version`). It is the only shape any HUX
|
||||
surface (Chat, Worker, Telegram, voice) codes against. The Python reference
|
||||
implementation lives in `dockerfiles/hermes-hux-foundation/hux/`:
|
||||
`contracts.py` validates records, `rules.py` holds the governance rules the
|
||||
schemas cannot express.
|
||||
|
||||
Records carry their own version in `schema` (`hux.event.v1`). Wire fixtures
|
||||
under `services/hermes/contracts/hux/examples/` are validated by
|
||||
`testing/tests/test_hermes_hux_contract_schemas.py` and are the exchange
|
||||
format with Codex: a change to a fixture is a change to the contract.
|
||||
|
||||
## What 1.0.0 covers
|
||||
|
||||
| Requirement | Where |
|
||||
|---|---|
|
||||
| Tenant and surface identity | `common.identity` (tenant_slot, hashed subject, surface, trust: router/relay/worker) — carried on every stored record that a caller can create |
|
||||
| Capability negotiation, default-off flags | `identity.capabilities` (`GET /hux/v1/capabilities`), `flags.json`; a card is enabled only when its dependency chain is enabled (`rules.flag_enabled`) |
|
||||
| Redacted activity events | `event`: `seq` (monotonic per conversation), `turn`, `correlation_id`, `idempotency_key`, replay by `after_seq`, kinds for decisions, delegation, failure/completion, cancellation, budget and side-effect gates; `detail` is allowlisted per kind and scrubbed |
|
||||
| Memory | `memory`: provenance, `source`, `reason`, `sensitivity`, `topic`, `ttl`, `approval_mode` incl. `no_store`, `status` incl. `no_store`, edit via `supersedes`, delete via `forgotten`, retrieval removal via `retrievable=false`, revisioned |
|
||||
| Projects, lineage, artifacts, versions, diffs, authorization | `project`, `conversation.branch`, `artifact.versions[]` immutable with `content_ref` hash, `diff_from`, `lineage`, `promotion`, `access`, `revision` |
|
||||
| Autonomy | `permission.policy` (levels, grants, budgets: tokens/tool calls/wall clock/spend/subagents/delegations/scope), `approval` with `request.external`, `cancellation_receipt`, `budget_state` |
|
||||
| Research | `citation.source/passage/citation/notebook` with classification, support verdict, notes, assumptions, unresolved questions, `dedupe_key` |
|
||||
| Additive migrations, rollback readers | `identity.manifest` (`data_layout_version`, `min_reader_contract_version`); rules in "Compatibility" below |
|
||||
|
||||
## Wire conventions
|
||||
|
||||
- Identity is asserted by the trusted hop, never by the client body: the
|
||||
chat router sets `X-Hermes-Tenant-Identity: slot-N`, `X-Hux-Subject:
|
||||
usr_<hash>`, `X-Hux-Surface: chat`; the Telegram relay adds the relay key
|
||||
and `trust: relay`; the Worker uses `trust: worker`. Requests without a
|
||||
complete, well-formed identity are `401 unauthorized`. The service must
|
||||
only be reachable from those hops (NetworkPolicy is Codex's to enforce).
|
||||
- Mutations of revisioned records send `If-Match: <revision>`; a mismatch is
|
||||
`409 conflict` with `hux.error.v1`. Creates accept `Idempotency-Key`; a
|
||||
repeat returns the original record with `200` instead of a duplicate.
|
||||
- Lists return `{"items": [...], "next": <cursor|null>}`. Event pages use
|
||||
`after_seq`; the SSE stream sets `id:` to `seq` so `Last-Event-ID`
|
||||
resumes exactly.
|
||||
- Errors are always `hux.error.v1`. A disabled card answers `404 flag_off`
|
||||
so an old client and a flag-off server look identical.
|
||||
- Every read and mutation writes a `common.audit_outcome`.
|
||||
|
||||
## Compatibility rules
|
||||
|
||||
1. Within `1.x`: only add optional fields, enum values or new record kinds.
|
||||
Never rename, remove, change a type, or make an optional field required.
|
||||
2. Readers ignore unknown fields and unknown enum values they do not need;
|
||||
a reader that needs a new enum value must check `contract_version`.
|
||||
3. On-disk data is append-only per family; `data_layout_version` bumps only
|
||||
for additive layout changes, and `min_reader_contract_version` says the
|
||||
oldest service that can still read it. Rolling the service back to any
|
||||
version ≥ `min_reader_contract_version` leaves data readable.
|
||||
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.
|
||||
- Claude's implementation cards (HUX-01/02/03/04/05/08/10/11) may add
|
||||
optional fields but must ship the fixture change in the same commit.
|
||||
- If Codex needs a shared-interface change, it lands as a `1.(x+1).0`
|
||||
revision of the schemas and fixtures here, never as an informal edit.
|
||||
353
docs/hux/DATA-MODEL.md
Normal file
353
docs/hux/DATA-MODEL.md
Normal file
@ -0,0 +1,353 @@
|
||||
# HUX foundation: on-PVC data model
|
||||
|
||||
`hux-foundation` is a Python stdlib service that runs inside every tenant pod
|
||||
and owns the records defined in `services/hermes/contracts/hux/`. It has no
|
||||
database and no storage shared between tenants. Everything it keeps lives on
|
||||
the tenant's own `home` PVC (mounted at `/opt/data`, beside `webui/`,
|
||||
`workspace/`, `home/` and the Telegram media roots `cache/images` and
|
||||
`workspace`). The Go router in `services/hermes/router/` fronts it: the router
|
||||
resolves Keycloak identity to a tenant slot and forwards the
|
||||
`common.schema.json#/$defs/identity` tuple (`tenant_slot`, `subject`,
|
||||
`surface`, `trust`) as headers, and enforces `hux.foundation` before any
|
||||
request reaches this service. The service trusts nothing else about the
|
||||
caller.
|
||||
|
||||
The conventions below are lifted from what already works in this repo:
|
||||
`hermes_model_routing._atomic_write` (temp file + `os.replace`),
|
||||
`cli_lane_records.atomic_json` (journal first, act second), and
|
||||
`execution_pool_store` (idempotent add by digest, terminal rows only are ever
|
||||
garbage-collected). SQLite is deliberately not used: the WebUI already owns
|
||||
`state.db` on this volume and a second writer with its own WAL is one more
|
||||
thing to recover; JSON and JSONL are greppable during an incident.
|
||||
|
||||
## 1. Directory layout
|
||||
|
||||
Tenant root is `HUX_DATA_ROOT`, default `/opt/data/hux`. The layout version is
|
||||
a directory (`v1`), so a v2 layout can be built beside v1 and swapped by
|
||||
`MANIFEST.json`, never by rewriting v1 in place.
|
||||
|
||||
```
|
||||
/opt/data/hux/
|
||||
MANIFEST.json hux.manifest.v1 (identity.schema.json): contract and layout versions
|
||||
v1/
|
||||
.lock advisory lock file (flock) proving single writer per tenant
|
||||
users/<usr_hash>/ one subtree per hashed subject; nothing lives above it
|
||||
profile.json {schema:"hux.user_profile.v1", memory_enabled, default_mode, revision}
|
||||
events/
|
||||
<conv_id>/
|
||||
events.jsonl append-only hux.event.v1, one per line, ordered by seq
|
||||
seq.json {next_seq, last_event_id, bytes, checkpointed_at}
|
||||
idempotency.jsonl {idempotency_key, event_id, seq, at}; last 10k keys kept
|
||||
memory/
|
||||
ledger.jsonl append-only hux.memory.v1 snapshots; a status change appends a full record
|
||||
tombstones.jsonl {memory_id, at, reason, purged:bool}; written on forget and on decay
|
||||
index.json retrieval index (section 3), rebuilt from the ledger on demand
|
||||
exports/<ts>.json GET /memory/export snapshots, audited, pruned after 7 days
|
||||
projects/
|
||||
<prj_id>.json hux.project.v1 + revision
|
||||
index.json {items:[{id,name,tags,pinned,archived,updated_at}], revision}
|
||||
conversations/
|
||||
<conv_id>.json hux.conversation.v1 + revision
|
||||
index.json {items:[{id,project_id,title,tags,pinned,archived,mode,branch,last_message_at}]}
|
||||
search/
|
||||
<conv_id>.json per-conversation term postings for message_text and artifact_titles
|
||||
artifacts/
|
||||
<art_id>.json hux.artifact.v1 + revision (versions[] is the version list)
|
||||
index.json {items:[{id,type,title,conversation_id,project_id,current_version}]}
|
||||
blobs/sha256/<aa>/<hash> content-addressed, immutable, 0400, fan-out on first two hex chars
|
||||
blobs/refs.json {hash: [ "<art_id>@<version>", ...]} for safe purge
|
||||
research/
|
||||
sources/<src_id>.json hux.source.v1
|
||||
passages/<psg_id>.json hux.passage.v1 (text + locator; hash is the dedupe key)
|
||||
citations/<msg_id>.jsonl hux.citation.v1 per message, append-only
|
||||
notebooks/<nb_id>.json hux.research_notebook.v1 + revision
|
||||
index.json {by_conversation:{conv_id:[nb_id]}, by_message:{msg_id:count}, passage_hashes:{hash:psg_id}}
|
||||
policy/
|
||||
global.json hux.policy.v1 for scope level global
|
||||
project/<prj_id>.json hux.policy.v1 per project
|
||||
conversation/<conv_id>.json hux.policy.v1 per conversation
|
||||
approvals/<apr_id>.json hux.approval.v1; terminal records never change again
|
||||
approvals/pending.json {items:[apr_id], revision}; the queue the UI polls
|
||||
receipts/<rcpt_id>.json hux.cancel_receipt.v1
|
||||
receipts/by_run.json {run_id: rcpt_id}
|
||||
suggestions/
|
||||
state.json {schema:"hux.suggestion_states.v1", items:{sug_id: hux.suggestion_state.v1}, revision}
|
||||
privacy/
|
||||
notices.jsonl hux.privacy_notice.v1 as shown, append-only
|
||||
conversation_topics.json {conv_id:{topic, first_seen, decay_at, memory_disabled:bool}}
|
||||
forgotten.jsonl {conv_id, requested_at, purged_at, counts}
|
||||
audit/
|
||||
outcomes/<YYYY-MM-DD>.jsonl one hux.audit_outcome.v1 per read or mutation (section 6)
|
||||
retention/<YYYY-MM-DD>.json hux.retention_audit.v1 per run
|
||||
catalog/
|
||||
suggestions.json hux.suggestion.v1 catalog shipped with the image (read-only copy)
|
||||
privacy_policy.json hux.privacy_policy.v1 as served, with the policy version
|
||||
```
|
||||
|
||||
The pod is single-tenant, so the `users/` level is not multi-tenancy; it is
|
||||
the guarantee that every path contains the hashed subject the router asserted
|
||||
and that a second subject on the same pod (operator break-glass, future
|
||||
household sharing) can never see another's tree without a distinct path.
|
||||
|
||||
Record ids are minted by the service: `<prefix>_<ts36><6 random base32>`, e.g.
|
||||
`evt_m0k3xq9a2bcd7f`, so they sort by creation time and satisfy the
|
||||
`common.schema.json` id pattern. `hux.audit_outcome.v1` and
|
||||
`hux.user_profile.v1` are foundation-internal records; they follow the same
|
||||
provenance and versioning rules but are not contracts the UI codes against.
|
||||
|
||||
## 2. Write semantics
|
||||
|
||||
**Atomic document write.** Serialise with `json.dumps(sort_keys=True,
|
||||
ensure_ascii=False)` plus a trailing newline, write to
|
||||
`.<name>.<pid>.<counter>.tmp` in the same directory, `flush()`, `os.fsync(fd)`,
|
||||
`os.replace(tmp, final)`, then `os.fsync(dir_fd)` so the rename itself is
|
||||
durable. Temp files that survive a crash are deleted on open. Blobs are
|
||||
written the same way under their hash; an existing blob is never rewritten
|
||||
(compare size and hash; on mismatch refuse and audit).
|
||||
|
||||
**Append.** JSONL families open with `O_APPEND`, write the whole line in one
|
||||
`os.write`, and `fsync` the file before the HTTP response is sent. The
|
||||
controlling checkpoint (`seq.json`, `index.json`) is written atomically after
|
||||
the append; a checkpoint may lag the log, never lead it.
|
||||
|
||||
**Crash recovery.** On first access to a family the store validates the log:
|
||||
each line must parse and, for events, `seq` must equal the previous `seq + 1`.
|
||||
A trailing partial line (no newline, or JSON error on the last line only) is
|
||||
truncated to the last good newline; a bad line anywhere else is a hard error
|
||||
(the family is marked read-only, an audit outcome is written, `/healthz`
|
||||
reports `degraded`). The checkpoint is then reconciled from the log:
|
||||
`next_seq` = last good `seq + 1`, indexes rebuilt if `checkpointed_at` is
|
||||
older than the log mtime.
|
||||
|
||||
**Idempotency.** Every mutating request may carry `Idempotency-Key`
|
||||
(`common.schema.json#/$defs/idempotency_key`, `^[A-Za-z0-9._:-]{8,120}$`). For
|
||||
events the key is stored in `idempotency.jsonl`; a replay returns the original
|
||||
event (same `id`, same `seq`) with `HTTP 200` and `HUX-Replayed: true`. For
|
||||
documents the key is stored in the record's `_meta.idempotency_keys` (last 16)
|
||||
and a replay returns the current record without bumping `revision`. Event
|
||||
`id` is unique per tenant; an append whose `id` already exists in the last
|
||||
checkpoint window is treated as a replay, not a duplicate.
|
||||
|
||||
**Seq allocation.** The HTTP server is a single process,
|
||||
`ThreadingHTTPServer`. Locks are per path, held in a process-wide
|
||||
`dict[str, threading.RLock]` guarded by one `threading.Lock`. The unit of
|
||||
locking is the family directory for JSONL (`events/<conv>`, `memory/`) and the
|
||||
document path for JSON. Seq is allocated inside the conversation lock:
|
||||
read `next_seq` from memory (loaded from `seq.json` once per open), assign,
|
||||
append, fsync, write `seq.json`, release. Since one process owns the PVC,
|
||||
`v1/.lock` is taken with `fcntl.flock(LOCK_EX|LOCK_NB)` at start-up so an
|
||||
accidental second replica fails fast instead of interleaving appends.
|
||||
|
||||
**Optimistic concurrency.** Every JSON document carries an integer
|
||||
`revision` (starts at 1) beside the contract fields; it is served as the
|
||||
`ETag`. `PATCH`/`PUT` require `If-Match: <revision>`; a mismatch returns
|
||||
`409` with a `hux.error.v1` body (`code: conflict`, the current revision in
|
||||
`details`) as `common.schema.json#/$defs/revision` specifies. A missing `If-Match` is accepted only when the
|
||||
request carries an `Idempotency-Key`, and then the write is last-writer-wins
|
||||
with the outcome audited as `why: "unconditional_write"`. Indexes carry their
|
||||
own `revision` and are rewritten under the family lock after the document.
|
||||
|
||||
**Caps.** Rejected with `hux.error.v1` `too_large` (413, size) or `conflict`
|
||||
(409, count):
|
||||
- event line 64 KiB, `detail` 32 KiB; 50 000 events per conversation, then
|
||||
the conversation is `archived` and further appends need a branch
|
||||
- memory content 2 000 chars (schema), 5 000 live entries, ledger 64 MiB
|
||||
before compaction (section 4)
|
||||
- conversation and project documents 256 KiB; 2 000 conversations, 200
|
||||
projects, `artifact_ids` 500 (schema)
|
||||
- artifact blob 25 MiB (`tenantMediaLimit` is 50 MiB; half leaves room for
|
||||
the Telegram path), 200 versions per artifact, 2 000 artifacts, blob store
|
||||
2 GiB per tenant
|
||||
- passage text 4 000 chars, 10 000 sources, 50 000 passages
|
||||
- audit outcomes are never capped by count; they rotate daily and age out
|
||||
|
||||
## 3. Indexes
|
||||
|
||||
Nothing is indexed that a scan cannot rebuild; every `index.json` is a cache
|
||||
of its family and carries `built_from` (log bytes or document count) so a
|
||||
stale index is detected and rebuilt rather than trusted.
|
||||
|
||||
**Conversation search** (`GET /hux/v1/search?q=`) covers exactly
|
||||
`project.schema.json#/$defs/search_index`: `title`, `tags`, `project_name`
|
||||
come from `conversations/index.json` joined with `projects/index.json` in
|
||||
memory (a few thousand rows, scanned per query); `message_text` and
|
||||
`artifact_titles` come from `conversations/search/<conv_id>.json`, a per
|
||||
conversation bag of lowercased, punctuation-stripped terms with positions,
|
||||
updated when a `message.*` event or `artifact.*` event is appended. Query
|
||||
terms are ANDed; ranking is title hit > tag hit > term frequency > recency.
|
||||
Events with `sensitivity: restricted` or `redaction.level: full` never enter
|
||||
the search bag, so a search result can never leak what the timeline hides.
|
||||
|
||||
**Memory retrieval** (`memory/index.json`) is a term index over `content` of
|
||||
`active` entries only, keyed by `scope.level`/`scope_id` and `topic`, plus
|
||||
`expires` sorted by the effective expiry (`expires_at` or `created_at +
|
||||
decay_days`) for the retention job. Entries are added on `approved`
|
||||
(automatic or user), removed on `rejected`, `expired`, `forgotten`. Retrieval
|
||||
reads the index, then loads the newest ledger snapshot for each id and drops
|
||||
any whose status is no longer `active`, so a lagging index fails safe.
|
||||
|
||||
**Tombstones remove from retrieval before content is gone.** `forget` is two
|
||||
writes under the memory lock: append a ledger snapshot with
|
||||
`status: forgotten`, `content: ""`, audit `forgotten`; then append
|
||||
`tombstones.jsonl` `{memory_id, at, reason, purged:false}`. The index write
|
||||
follows. Both the retrieval path and the export path consult the tombstone
|
||||
set (loaded once, appended in memory) before returning anything, which is
|
||||
what makes "do not remember" hold even if the index rebuild is interrupted.
|
||||
The earlier ledger lines that still contain content are what the purge job
|
||||
in section 4 rewrites. The same tombstone file records `disable_memory_here`
|
||||
per conversation via `privacy/conversation_topics.json`, which retrieval also
|
||||
checks: a conversation with `memory_disabled` contributes no entries and
|
||||
receives none.
|
||||
|
||||
**Research** indexes by hash: `passage_hashes` dedupes passages across
|
||||
sources, `by_message` lets `GET /messages/{id}/citations` open one JSONL
|
||||
without listing a directory, `by_conversation` backs the notebook drawer.
|
||||
Approvals index only the pending queue; terminal approvals are found by id.
|
||||
|
||||
## 4. Retention
|
||||
|
||||
One thread runs the retention job every `retention_audit.interval_days`
|
||||
(1 day) at a jittered hour, and on demand via `POST /hux/v1/admin/retention`
|
||||
(worker surface only). Each run writes `audit/retention/<date>.json` as
|
||||
`hux.retention_audit.v1` with the counts the schema names, so a day without
|
||||
the record is itself a finding.
|
||||
|
||||
- `expire_memory`: entries whose effective expiry has passed get a new ledger
|
||||
snapshot `status: expired` and leave the index. Decay means the entry
|
||||
expires `decay_days` after `created_at` unless a later ledger snapshot
|
||||
carries a newer `updated_at` from an `approved` audit action, which resets
|
||||
the clock once.
|
||||
- `decay_topic_context`: `privacy/conversation_topics.json` rows past
|
||||
`decay_at` (`PRIVACY_TOPICS[topic].decay_days` after `first_seen`) cause
|
||||
the conversation's events with that `sensitivity` to be rewritten with
|
||||
`redaction.level: full` and `detail` removed; `summary` is replaced by the
|
||||
topic notice text. Seq, ids and provenance are kept, so the timeline stays
|
||||
contiguous.
|
||||
- `purge_forgotten_content`: for every tombstone with `purged:false`, rewrite
|
||||
`ledger.jsonl` (temp + replace, under the memory lock) replacing `content`
|
||||
with `""` on every snapshot of that id, keeping `audit[]`; then set
|
||||
`purged:true`. Forgotten conversations (`POST /conversations/{id}/forget`)
|
||||
are handled the same way: `events.jsonl` is rewritten with `detail`
|
||||
dropped and `redaction.level: full`, `search/<conv>.json` is deleted,
|
||||
artifacts owned only by that conversation lose their blobs (via
|
||||
`blobs/refs.json`), and `privacy/forgotten.jsonl` records the counts. The
|
||||
conversation document stays with `archived: true` so branches still
|
||||
resolve their parent.
|
||||
- `report`: bounds check. Per-family bounds for a home cluster (10 Gi PVC
|
||||
shared with the WebUI): events 1 GiB total and 180 days for archived
|
||||
conversations, memory ledger compacted when over 64 MiB (rewrite keeping
|
||||
only the newest snapshot per id plus every snapshot of ids with a
|
||||
tombstone), blobs 2 GiB with unreferenced blobs deleted 7 days after their
|
||||
last ref disappears, research 512 MiB and sources unreferenced by any
|
||||
notebook or citation for 90 days deleted, audit outcomes 90 days,
|
||||
retention audits 400 days, memory exports 7 days, idempotency keys 10 000
|
||||
per conversation. Terminal approvals and receipts are kept 180 days.
|
||||
Nothing in `audit/` is ever removed by a forget or purge; only age.
|
||||
|
||||
Private mode conversations (`retention: ephemeral`) are not written to
|
||||
`events/` at all; the service returns `204` to appends and the router's
|
||||
session memory is the only copy.
|
||||
|
||||
## 5. Migrations
|
||||
|
||||
`MANIFEST.json`:
|
||||
|
||||
```json
|
||||
{"schema":"hux.manifest.v1","contract_version":"1.0.0","data_layout_version":1,
|
||||
"min_reader_contract_version":"1.0.0","created_at":"...","updated_at":"..."}
|
||||
```
|
||||
|
||||
`contract_version` is `services/hermes/contracts/hux/VERSION`; the service
|
||||
that last opened the tree writes its own version there and bumps
|
||||
`updated_at`. `data_layout_version` names the `v<N>/` directory in use.
|
||||
|
||||
Rules:
|
||||
- Additive only within a layout. A release may add optional fields to a
|
||||
record, add a new file name, add a new directory, add an enum value that a
|
||||
reader can ignore. It may not rename or remove a field, change a field's
|
||||
type, change the meaning of an existing enum value, change the id pattern,
|
||||
change `seq` semantics, or move a family to a different path.
|
||||
- Every record keeps its contract `schema` value (`hux.event.v1`). Internal
|
||||
bookkeeping is under `_meta` (`revision`, `idempotency_keys`, `built_from`),
|
||||
which is stripped before a record is served and which readers must ignore.
|
||||
- Readers ignore unknown fields and never fail a family because one record
|
||||
has an unknown optional key. A reader from the previous release therefore
|
||||
reads records from the next one; a rollback of the service leaves every
|
||||
file readable because the older code sees the same required fields.
|
||||
- A record whose `schema` is a version the running code does not know is
|
||||
skipped on list and returned `409 hux.schema_unknown` on direct fetch, and
|
||||
logged once per family per process.
|
||||
- The service refuses to open a tree whose `data_layout_version` is
|
||||
greater than the one it was built for, or whose
|
||||
`min_reader_contract_version` is above its own `VERSION`, and it never
|
||||
bumps either on its own; a v2 layout is a separate migration tool that
|
||||
builds `v2/` beside `v1/` and flips the manifest last. A release that only
|
||||
adds optional fields leaves `min_reader_contract_version` alone, which is
|
||||
exactly what lets the previous release read after a rollback.
|
||||
- Index files are never migrated; they are deleted and rebuilt.
|
||||
|
||||
## 6. Authorization
|
||||
|
||||
The router asserts the identity tuple: `tenant_slot` (`^slot-[0-9]{1,3}$`),
|
||||
`subject` (`usr_<hash>`), `surface` and `trust` (`router|relay|worker`); the
|
||||
service checks the slot equals its own `HERMES_TENANT_SLOT` and refuses
|
||||
otherwise. Every filesystem path is then built by `store.path_for(user,
|
||||
family, *ids)`, where `user` must match `^usr_[0-9a-f]{16,64}$` and each id
|
||||
must match the `common.schema.json` id pattern
|
||||
`^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$` (dates in `audit/` match
|
||||
`^\d{4}-\d{2}-\d{2}$`, versions `^\d+$`, hashes `^[0-9a-f]{64}$`). The
|
||||
pattern admits `.` but not `/`, so `..` alone is impossible in an id; the
|
||||
resolved path is still checked with `os.path.commonpath` against the user's
|
||||
subtree, the same belt-and-braces the router applies in
|
||||
`normalizeTenantMediaPath`. No caller-supplied string is ever joined into a
|
||||
path without going through `path_for`.
|
||||
|
||||
Ownership: records with an `owner` field must equal the asserted user on
|
||||
read and write; records without one (events, approvals, receipts, research)
|
||||
are owned through their `conversation_id`, whose document is loaded and
|
||||
checked first. A lookup that fails ownership returns `404`, not `403`, so
|
||||
ids cannot be probed.
|
||||
|
||||
Every read and mutation appends one line to `audit/outcomes/<date>.jsonl`
|
||||
shaped as `common.schema.json#/$defs/audit_outcome`, also for denials. The
|
||||
line is the contract object plus an `_meta` envelope the store owns:
|
||||
|
||||
```json
|
||||
{"at":"...","identity":{"tenant_slot":"slot-3","subject":"usr_...","surface":"chat","trust":"router"},
|
||||
"action":"memory.forget","resource":"memory/mem_...","outcome":"allow","reason":"owner_match",
|
||||
"_meta":{"schema":"hux.audit_outcome.v1","id":"aud_...","request_id":"...","idempotency_key":"...",
|
||||
"revision_before":4,"revision_after":5,"build":{"commit":"...","image_digest":"sha256:..."}}}
|
||||
```
|
||||
|
||||
`action` is `<family>.<op>`; `resource` is the family-relative record path;
|
||||
`outcome` is `allow|deny|not_found|conflict|flag_off`; `reason` is a short
|
||||
fixed vocabulary (`owner_match`, `owner_mismatch`, `invalid_id`,
|
||||
`revision_conflict`, `cap_exceeded`, `policy_violation`,
|
||||
`unconditional_write`, `replayed`, `family_readonly`). Audit lines never
|
||||
contain record content.
|
||||
|
||||
## 7. Module map
|
||||
|
||||
All modules live in the `hux` package under `dockerfiles/hermes-hux-foundation/`
|
||||
(where `contracts.py` and `rules.py`, the former `hux_contracts.py` and
|
||||
`hux_policy.py`, already sit), stdlib only, each at most 500 lines, and each
|
||||
tested in `testing/tests/test_hermes_hux_foundation_*.py`.
|
||||
|
||||
| Module | Responsibility |
|
||||
|---|---|
|
||||
| `identity.py` | Parse and validate router headers (slot, `usr_` hash, request id); id and hash regexes from `common.schema.json`; id minting; ownership check helpers. |
|
||||
| `flags.py` | Wrap `rules.flag_enabled` with per-request evaluation of `HUX_FLAGS`; map each route to its card flag; 404 when the chain is off. |
|
||||
| `store.py` | `path_for`, `MANIFEST.json`, `.lock`, per-path lock registry, atomic document write, fsync'd append, tmp-file cleanup, crash validation and truncation, revision/`If-Match`, idempotency-key storage, size caps. No record semantics. |
|
||||
| `events.py` | Per-conversation JSONL log: seq allocation, `seq.json`, `after_seq` reads, SSE cursor, search-bag updates, redaction on read, ephemeral-mode short circuit. |
|
||||
| `memory.py` | Ledger append, state machine via `rules.MEMORY_TRANSITIONS`, `memory_policy_violations`, tombstones, retrieval index, export snapshots, compaction. |
|
||||
| `privacy.py` | Topic detection hooks, `conversation_topics.json`, notices log, `forget` for a conversation, retention job (`expire_memory`, `decay_topic_context`, `purge_forgotten_content`, `report`) and `hux.retention_audit.v1`. |
|
||||
| `artifacts.py` | Artifact documents, version list, content-addressed blobs and `refs.json`, diff between versions, promotion, lineage, blob GC. |
|
||||
| `research.py` | Sources, passages (hash dedupe), per-message citation logs, notebooks, research index. |
|
||||
| `policy.py` | Policy documents per scope, `effective_decision`, approvals queue and terminal transitions, cancellation receipts and `by_run.json`. |
|
||||
| `organization.py` | Projects and conversations, indexes, branch lineage, search over the `search_index` fields, suggestion state and `suggestion_allowed` gating. |
|
||||
| `audit.py` | `hux.audit_outcome.v1` writer with daily rotation, the `why` vocabulary, structured logging of degraded families, age-based pruning. |
|
||||
| `http.py` | `ThreadingHTTPServer` on the tenant loopback port, route table for `/hux/v1`, JSON and SSE responses, `{"items":[],"next":cursor}` list wrapping, error mapping (`404/409/412/413`), `/healthz`. |
|
||||
|
||||
Dependency direction is one way: `http` -> family modules -> `store`, with
|
||||
`identity`, `flags` and `audit` used by everyone and importing only `store`
|
||||
and `rules`/`contracts`. Family modules never touch the filesystem directly.
|
||||
64
docs/hux/HANDOFF.md
Normal file
64
docs/hux/HANDOFF.md
Normal file
@ -0,0 +1,64 @@
|
||||
# hermes-next: Claude backend handoff ledger
|
||||
|
||||
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.
|
||||
Contract: `hux.v1` 1.0.0 (`services/hermes/contracts/hux/VERSION`,
|
||||
`docs/hux/ADR-0001-hux-v1-contract-freeze.md`). Threat model:
|
||||
`docs/hux/THREAT-MODEL.md` (SO-01..SO-54). Data model: `docs/hux/DATA-MODEL.md`.
|
||||
|
||||
Run everything with the CI interpreter:
|
||||
`python -m pytest testing/tests/test_hermes_hux_*.py -q` and
|
||||
`python -m coverage run --branch --source=dockerfiles/hermes-hux-foundation -m pytest testing/tests/test_hermes_hux_*.py`.
|
||||
|
||||
## Codex integration requirements common to every card
|
||||
|
||||
- Package `dockerfiles/hermes-hux-foundation/hux/` runs as `python -m hux.server`
|
||||
inside each `hermes-chat-tenant` pod (and the Worker) on `127.0.0.1:8790`.
|
||||
Image, manifest, NetworkPolicy and Flux wiring are Codex's (SO-01, SO-02,
|
||||
SO-45: separate uid, `/opt/data/hux` mode 0700).
|
||||
- Env: `HUX_DATA_ROOT` (tenant PVC, default `/opt/data`), `HUX_TENANT_SLOT`
|
||||
(pin, SO-03), `HUX_FLAGS` (comma list, default empty = everything off),
|
||||
`HUX_RELAY_KEY` (Telegram relay), `HUX_WORKER_KEY` (Worker),
|
||||
`HUX_BUILD_COMMIT`, `HUX_IMAGE_DIGEST`, `HUX_CONTRACT_DIR` (schemas path in
|
||||
the image; default resolves relative to the repo layout).
|
||||
- Router → service headers (SO-04): `X-Hermes-Tenant-Identity: slot-N`,
|
||||
`X-Hux-Subject: usr_<hash>`, `X-Hux-Surface: chat|telegram|voice|api|worker`,
|
||||
optional `X-Hux-Trust: relay|worker` + `X-Hux-Relay-Key`. The router must
|
||||
strip any inbound `X-Hux-*` before setting its own.
|
||||
- First client call: `GET /hux/v1/capabilities` → which cards/routes are on.
|
||||
Disabled or unknown → `404` (`flag_off` vs `not_found` only in the body).
|
||||
- Mutations: `If-Match: <revision>` on revisioned records (409 on mismatch);
|
||||
`Idempotency-Key` on creates. Errors are `hux.error.v1`.
|
||||
- Fixtures Codex codes against: `services/hermes/contracts/hux/examples/*.json`
|
||||
(validated in CI by `test_hermes_hux_contract_schemas.py`).
|
||||
|
||||
## HUX-11 shared foundation — DONE (backend)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Commits | `47a9fd83` (contract freeze 1.0.0), `7cf9a714` (service core) |
|
||||
| Files | `services/hermes/contracts/hux/{common,identity}.schema.json` + examples, `VERSION`; `dockerfiles/hermes-hux-foundation/hux/{__init__,errors,identity,flags,store,audit,http,foundation,server,contracts,rules}.py`; `docs/hux/ADR-0001-*.md`, `THREAT-MODEL.md`, `DATA-MODEL.md` |
|
||||
| Tests | `testing/tests/test_hermes_hux_contract_schemas.py` (61), `test_hermes_hux_contract_foundation.py` (30): identity header rejection matrix, relay/worker keys, slot pinning, fail-closed flag chains, capabilities record validity, tenant-scoped paths + traversal rejection, revisions/conflicts, torn-ledger recovery, blobs, manifest, concurrent writers, audit rows, flag-off == not-found, 401 never touches storage, real HTTP server JSON + SSE, loopback-only bind, all errors validate, ≤500 LOC guard |
|
||||
| Coverage | 99% line / 98% branch over the package |
|
||||
| Contract | 1.0.0 |
|
||||
| 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).
|
||||
247
docs/hux/THREAT-MODEL.md
Normal file
247
docs/hux/THREAT-MODEL.md
Normal file
@ -0,0 +1,247 @@
|
||||
# hux-foundation threat model
|
||||
|
||||
Scope: the per-tenant `hux-foundation` service (Python, stdlib
|
||||
`ThreadingHTTPServer`) that runs as a sidecar in each `hermes-chat-tenant-N`
|
||||
pod, keeps HUX records on that pod's `home` PVC under `/opt/data/hux/v1/`
|
||||
(layout in `DATA-MODEL.md`), and is reached only through the chat router,
|
||||
the Worker, and the Telegram relay. It implements the frozen 1.0.0 contracts
|
||||
in `services/hermes/contracts/hux/` (ADR-0001) with the rules in
|
||||
`dockerfiles/hermes-hux-foundation/hux/rules.py`. Where a NetworkPolicy or
|
||||
pod setting is needed it is written as a requirement for Codex, not a
|
||||
manifest. Every mitigation cites a contract field, a code obligation, or an
|
||||
SO id from the checklist at the end.
|
||||
|
||||
Assets, most important first:
|
||||
|
||||
1. Tenant isolation: one Keycloak subject, one pod, one PVC
|
||||
(`chat-statefulset.yaml`, `ai.bstein.dev/isolation`). Everything rests on it.
|
||||
2. Secrets that transit the pod: the relay key at
|
||||
`/runtime-access/chat-relay-key`, provider keys in `/opt/data/.env`, and
|
||||
the OAuth headers the router already strips (`main.go` Director).
|
||||
3. User content: memory, artifacts, sources, events. Sensitive and restricted
|
||||
topics have hard rules in `PRIVACY_TOPICS` and `memory_policy_violations`.
|
||||
4. Honesty of the record: `seq`, approval decisions, cancel receipts, budget
|
||||
state, release evidence. If these can be forged the UI lies to the user.
|
||||
|
||||
Attackers, in the order we expect to meet them:
|
||||
|
||||
- A: another tenant's Hermes process or a tool it ran. Shell, Python,
|
||||
Playwright, and a same-namespace address.
|
||||
- B: this tenant's own agent gone wrong (prompt injection via web or files).
|
||||
Tenant privileges, plus tool arguments and logs full of things the user
|
||||
never meant to keep.
|
||||
- C: the tenant's browser session, possibly a stolen `hermes_chat_session`
|
||||
cookie, driving `/hux/v1` through the router.
|
||||
- D: a linked Telegram peer, or a leaked relay key.
|
||||
- E: operator mistakes: a flag on without its chain, an old binary reading
|
||||
new records, a purge with a bad glob.
|
||||
|
||||
## 1. Tenant and surface identity
|
||||
|
||||
The router is the only identity authority. `slotFor` maps subject to slot,
|
||||
the Director strips `Authorization`, `Cookie`, `X-Auth-Request-*` and
|
||||
`X-Forwarded-*`, and sets `X-Hermes-Tenant-Identity: slot-N`. Nothing in the
|
||||
pod ever sees a raw subject. ADR-0001 adds `X-Hux-Subject: usr_<hash>`
|
||||
(`identityHash("keycloak", subject)`) and `X-Hux-Surface`. The service
|
||||
turns those into `common.identity` (`tenant_slot`, `subject`, `surface`,
|
||||
`trust`) and stamps it on every record a caller can create.
|
||||
|
||||
| Threat | STRIDE | Who | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Pod-network client sends `X-Hermes-Tenant-Identity: slot-2` straight to slot 2's hux port | S | A | Bind `127.0.0.1` only; the WebUI sidecar is the sole in-pod caller and forwards the router's headers verbatim. No cross-pod path exists to spoof. (SO-01) |
|
||||
| Someone reaches 8787/8642/8788 from a pod that is not the router | S | A | `hermes-chat-tenant-isolation` already admits those ports only from `app: hermes-chat-router`. Requirement: no hux port is ever added to that ingress list; the sandbox pods keep their single-source ingress. (SO-02) |
|
||||
| Browser sends `X-Hux-Subject`, router forwards it | S | C | Router deletes inbound `X-Hux-*` and `X-Hermes-Tenant-Identity` before setting its own, exactly as it deletes `Authorization`. Service refuses a slot header that differs from its own ordinal (`HERMES_TENANT_SLOT`). (SO-03) |
|
||||
| Two header vocabularies (ADR: `X-Hux-Subject`; DATA-MODEL §6: `X-Hermes-User`) and one hop honours the wrong one | S, E | E | One header set, defined once in `identity.py`, with the other names rejected as `401 unauthorized`. (SO-04) |
|
||||
| Telegram relay carries the shared relay key and no subject | S, E | D | `Authorization: Bearer <relay_key>` compared with `hmac.compare_digest` (as `telegram_media_server.py` does); `X-Hux-Surface: telegram`, `trust: relay`. The owner is the slot's single assignment. A relay request that names `X-Hux-Subject` is `401`. (SO-05, SO-06) |
|
||||
| Relay key ends up in a record or log | I | B | Key lives only under `/runtime-access` (read-only mount, not the PVC). It, and every value in `/opt/data/.env`, is a literal canary in the scrub of section 2. (SO-07) |
|
||||
| Worker reads a tenant's user data through `trust: worker` | E, I | E | `trust: worker` may call `GET /hux/v1/releases`, `GET /hux/v1/capabilities` and `POST /hux/v1/admin/retention` only; any other route is `403 forbidden`. No worker bypass port. (SO-08) |
|
||||
| Slot handed to a new subject; old subtree leaks | I | E | Paths are `users/<usr_hash>/...`; reads join on `identity.subject`. A different hash sees an empty tree, never another's. (SO-09) |
|
||||
| Body claims `provenance.surface: worker` or `actor.type: user` | R, T | B, C | Server overwrites `identity`, `provenance.surface`, `provenance.actor` (when `type: user`) and `recorded_at` from the trusted hop and its own clock. (SO-10) |
|
||||
|
||||
NetworkPolicy requirements for Codex: router-only ingress to every listening
|
||||
port in the tenant pod; no egress from hux-foundation (it fetches nothing);
|
||||
sandbox pods gain no route to the tenant's loopback services.
|
||||
|
||||
## 2. Activity events
|
||||
|
||||
`detail` is schema-open ("kind-specific payload"). Tool arguments carry file
|
||||
paths, shell lines with tokens, URLs with query strings, pasted secrets.
|
||||
The pipeline, in order, before any byte is written: drop keys outside the
|
||||
per-kind allowlist; scrub every remaining string; cap sizes; raise
|
||||
`redaction.level` if anything fired; then and only then allocate `seq`.
|
||||
|
||||
| Threat | STRIDE | Who | Mitigation |
|
||||
|---|---|---|---|
|
||||
| `tool.call` detail persists raw arguments | I | B | Allowlist below; unknown keys dropped before write, never filtered on read only. (SO-11) |
|
||||
| Secret inside an allowed string (`summary`, `detail.target_path`) | I | B | Pattern scrub: `sk-[A-Za-z0-9]{20,}`, `ghp_|gho_|glpat-`, `AKIA[0-9A-Z]{16}`, `xox[abp]-`, `Bearer [A-Za-z0-9._-]{16,}`, `-----BEGIN [A-Z ]*PRIVATE KEY`, JWT `eyJ[A-Za-z0-9_-]{20,}\.`, hex/base64 runs ≥ 40 chars, plus the literal canaries from SO-07. Match becomes `[redacted:<class>]`, `redaction.level` ≥ `partial`, `redaction.reason` set. (SO-12) |
|
||||
| Oversized detail fills the PVC or an SSE buffer | D | B | `detail` > 32 KiB (DATA-MODEL cap) is replaced by `{"truncated": true, "bytes": N}`; line cap 64 KiB; `evidence` ≤ 64 and `summary` ≤ 280 per schema. (SO-13) |
|
||||
| Reader ignores `redaction.level` | I | C, D | Enforced at serve time too: `none` as stored; `partial` strips `detail`; `full` serves `kind`, `seq`, `ts`, `sensitivity`, `redaction` only. `surface: telegram` and `voice` never receive `none`. (SO-14) |
|
||||
| Client supplies `seq` or `id` | T | B, C | Server-assigned under the conversation lock; a body carrying either is `400 invalid`. (SO-15) |
|
||||
| Replay through `Idempotency-Key` returns someone else's event | I | C | Idempotency keys are scoped per conversation file; a replay is served only after the ownership check. (SO-16) |
|
||||
| Reconnect storm, `after_seq=0` on a long log | D | C | Pages ≤ 200; `after_seq` before the retention window resumes from the oldest kept event; one SSE stream per (subject, conversation), a second open closes the first; idle timeout 15 min. (SO-17) |
|
||||
| `GET /conversations/{guess}/events` | I | C | Ids are time-prefixed plus 30 random bits, so ownership is the real gate: `conversation_id` document loaded first, mismatch is `404 not_found`, not `403`. (SO-18) |
|
||||
| `evidence[].uri` of `file:///opt/data/.env` expanded by a UI | I | B | Service never dereferences `evidence_ref.uri`; stored URIs must be `https://`, `hux://` or `artifact://`, others dropped. (SO-19) |
|
||||
| Restricted event leaks via search | I | C | `sensitivity: restricted` or `redaction.level: full` events never enter `search/<conv>.json`. (SO-20) |
|
||||
|
||||
Detail allowlist (anything else is dropped):
|
||||
|
||||
- `message.*`: `message_id`, `chars`, `has_attachments`
|
||||
- `decision.route`: `requested`, `resolved_target`, `provider`, `effort`, `reason`
|
||||
- `decision.plan`: `steps` (≤ 20 strings ≤ 200 chars)
|
||||
- `tool.call`: `tool`, `capability`, `argument_names`, `argument_hash`,
|
||||
`target_path` (kept only under `/opt/data/workspace`, else dropped)
|
||||
- `tool.result`: `tool`, `ok`, `duration_ms`, `bytes`, `exit_code`
|
||||
- `approval.*`, `side_effect.*`: `approval_id`, `capability`, `choice`, `external`
|
||||
- `memory.*`: `memory_id`, `kind`, `sensitivity`, `topic`
|
||||
- `artifact.*`: `artifact_id`, `version`, `type`, `bytes`, `hash`
|
||||
- `run.*`, `delegation.*`, `budget.exhausted`: `run_id`, `outcome`,
|
||||
`receipt_id`, `spent`, `limits`, `child_run_id`
|
||||
- `privacy.notice`: the `hux.privacy_notice.v1` fields only
|
||||
- `citation.attached`, `mode.changed`, `suggestion.*`, `release.transition`:
|
||||
ids and enum fields of their own schemas only
|
||||
|
||||
## 3. Memory
|
||||
|
||||
Defaults: `approval_mode: ask` for anything above `personal`; `no_store` is
|
||||
a first-class status (`memory.status`, `memory.approval_mode`) so "do not
|
||||
remember" is recorded as a decision rather than as absence. A proposal
|
||||
unanswered for 7 days expires.
|
||||
|
||||
| Threat | STRIDE | Who | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Restricted content written because the topic detector missed it | I | B | `memory_policy_violations` on every write and transition; any problem is `422`/`invalid` with a `memory.suppressed` event. The service never lowers `sensitivity`; its own scrub may raise it. (SO-21) |
|
||||
| "Do not remember" blocks the write but the entry surfaces later | I | B, C | `no_store` writes a content-free ledger line and a tombstone; `forget` writes `status: forgotten`, `content: ""`, `retrievable: false`, tombstone. Retrieval, `/search`, `/export` and the agent read hook check the tombstone set before the index and return only `status: active` with `retrievable: true`. (SO-22, SO-23) |
|
||||
| Forgotten content lingers in earlier events or ledger lines | I | B | `memory.*` events carry ids only. `forget` re-redacts events referencing the id to `full` and queues `purge_forgotten_content` to rewrite older ledger snapshots. (SO-24) |
|
||||
| Correction resurrects forgotten text | T | B | An entry whose `supersedes` or `source` points at a forgotten id needs `approval_mode: ask` answered by a `user` actor. (SO-25) |
|
||||
| Export leaks another owner or forgotten entries | I | C | Export is owner-scoped, excludes `forgotten`, `rejected`, `expired`, `no_store` and `retrievable: false`, appends `exported` to `audit[]`, is served as an attachment, and the snapshot file is pruned after 7 days. (SO-26) |
|
||||
| TTL never enforced because the audit job died | I | E | Expiry checked lazily at read (an expired entry is never served as active) and eagerly by the retention job; a missing `hux.retention_audit.v1` older than 48 h makes `/privacy/policy` report `audit_stale: true`. (SO-27) |
|
||||
| Private mode writes memory | I | B | `MODE_CATALOG["private"].memory.write == False`: `POST /memory` is refused while the conversation's mode is `private`, and its events are not written at all (`204`). (SO-28) |
|
||||
| `disable_memory_here` ignored on the read side | I | B | `privacy/conversation_topics.json` `memory_disabled` is checked by retrieval: that conversation contributes and receives nothing. (SO-23) |
|
||||
|
||||
## 4. Artifacts and research
|
||||
|
||||
Sources are records about where evidence came from. The service stores and
|
||||
serves them; it never fetches a URI.
|
||||
|
||||
| Threat | STRIDE | Who | Mitigation |
|
||||
|---|---|---|---|
|
||||
| SSRF via `source.uri` or `evidence_ref.uri` | S, I | B | No outbound HTTP client in the service; a test asserts `urllib.request`, `http.client` and `socket.create_connection` are absent from the module graph. (SO-29) |
|
||||
| Wrong hash on upload; UI dedupes on it | T | B | `sha256` recomputed server-side; mismatch with `content_ref.hash` is `422`; blobs stored at `blobs/sha256/<aa>/<hash>`, mode 0400, never rewritten. (SO-30) |
|
||||
| Oversized artifact | D | B, C | 25 MiB per version, 200 versions, 2 000 artifacts, 2 GiB blob store per tenant (DATA-MODEL caps); over limit is `413 too_large` or `409 conflict`. (SO-31) |
|
||||
| HTML/SVG served executable into the chat origin | I, E | B | Blobs served as `application/octet-stream`, `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`. `mime` is metadata for the UI's sandboxed preview, never a response header. (SO-32) |
|
||||
| Lineage or promotion names a record the caller does not own | T, I | B, C | `lineage.artifact_id`, `promotion.project_id`, `passage.source_id`, `citation.passage_ids`, notebook id lists all resolved under the caller's subtree at write time; unknown or foreign is `422`. There is no cross-tenant store, and the check stays explicit so a future shared cache cannot widen it. (SO-33) |
|
||||
| `access.mode: shared_readonly` used as a cross-tenant channel | I | C | Share tokens are minted by the owner, read-only, expire at `share_expires_at` ≤ 7 days, and resolve only inside the same pod; the router does not route them to another slot. (SO-34) |
|
||||
| Passage `text` used to smuggle secrets into the citation strip | I | B | Passage text passes the SO-12 scrub; 4 000-char cap is the schema's. (SO-12) |
|
||||
|
||||
## 5. Autonomy
|
||||
|
||||
The service is the policy store and approval queue; the gateway executes
|
||||
tools. The gate is only as strong as the gateway's call to
|
||||
`effective_decision` and its presentation of the approval id at execution.
|
||||
|
||||
| Threat | STRIDE | Who | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Agent decides its own approval | S, E | B | `POST /approvals/{id}` needs `identity.surface` in `{chat, telegram, voice}` and `trust` in `{router, relay}`; the stored `decision.by` is the asserted user, not the body's actor. (SO-35) |
|
||||
| `once` reused for two side effects | T | B | Approvals are terminal after one transition (`APPROVAL_TRANSITIONS`); consume marks `_meta.consumed_at` and a second consume of `once` is `409 conflict`. (SO-36) |
|
||||
| TOCTOU: approved "write notes.md", executed "write ~/.ssh/authorized_keys" | T | B | `request.evidence` carries a `tool_call` ref whose `hash` is the sha256 of the canonical argument JSON; consume must present the same hash or it is `denied` with a `side_effect.blocked` event. (SO-37) |
|
||||
| `always` grant never expires | E | C | `always` writes a grant with server-set `expires_at` ≤ 30 days and `granted_by`; the client cannot extend it. (SO-38) |
|
||||
| `deploy`, `external_side_effect` or `network` slip through under `safe` | E | B, C | `effective_decision` is the only resolver; `_ALWAYS_ASK` ends `ask`, unexpired `deny` wins. `request.external: true` always requires an approval record. (SO-39) |
|
||||
| Budget exhausted by delegation fan-out | D | B | `hux.budget_state.v1` is updated per `tool.result`/`delegation.*`; once `exhausted` the service refuses new approvals with `402`-style `budget_exhausted` and emits `budget.exhausted`. (SO-40) |
|
||||
| Receipt says `cancelled` while a shell still runs | R | B | `outcome: cancelled` only after the gateway reports the run's process registry empty; else `failed_to_cancel`. `side_effects` must include every `tool.result` with `ok: true` after `requested_at`, each marked `reverted`. Stop is not done until the receipt exists. (SO-41) |
|
||||
| Approval sits open forever | D | B | `expires_at` ≤ 24 h from `requested_at`; expiry is a terminal transition written by the retention thread. (SO-42) |
|
||||
|
||||
## 6. Storage
|
||||
|
||||
Layout per DATA-MODEL §1: `/opt/data/hux/v1/users/<usr_hash>/<family>/...`.
|
||||
No table, index or cache is shared between tenants because no two tenants
|
||||
share a PVC.
|
||||
|
||||
| Threat | STRIDE | Who | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Path traversal via id | I, T | B, C | `store.path_for` validates the user hash and every id against the `common.schema.json` patterns (`/` impossible), then `os.path.commonpath` against the user subtree, as `normalizeTenantMediaPath` does. No caller string reaches a path any other way. (SO-43) |
|
||||
| Lost update between chat and Telegram | T | C, D | `revision` served as `ETag`; PATCH/PUT need `If-Match`, mismatch `409 conflict`; writes are temp + `os.replace` + fsync under the family lock. Unconditional writes are audited `why: unconditional_write`. (SO-44) |
|
||||
| Agent edits the audit log; it has write on the PVC | R, T | B | hux-foundation runs as its own uid (not 10000); `/opt/data/hux` is 0700 to that uid; the agent reaches records only through the API. Requirement for Codex: separate `runAsUser`, `readOnlyRootFilesystem`, `drop: [ALL]`. (SO-45) |
|
||||
| Audit line removed or altered | R | B, E | `audit/outcomes/*.jsonl` lines carry `prev` = sha256 of the previous line; a break is reported in the next retention audit as `report`. Audit lines never contain record content. (SO-46) |
|
||||
| Purge deletes the wrong thing | D | E | `purge_forgotten_content` touches only tombstoned ids and blobs absent from `refs.json` for 7 days, writes the dry-run count into the retention record before the delete pass, and never touches `audit/`. (SO-47) |
|
||||
| Second replica interleaves appends | T | E | `v1/.lock` taken with `flock(LOCK_EX\|LOCK_NB)` at start-up; failure is fatal. (SO-48) |
|
||||
| PVC full stops the WebUI too | D | B | Per-family bounds from DATA-MODEL §4; a write over cap is `413 too_large`, and total `/opt/data/hux` above 4 GiB puts `/healthz` at `degraded`. (SO-49) |
|
||||
|
||||
## 7. Feature flags
|
||||
|
||||
| Threat | STRIDE | Who | Mitigation |
|
||||
|---|---|---|---|
|
||||
| `hux.memory_control` on, `hux.privacy` off | E | E | `flag_enabled` walks `depends_on`; a route serves only when the chain is on; `/hux/v1` is `404 flag_off` without `hux.foundation`. `GET /capabilities` reports the resolved chain, not the raw env. (SO-50) |
|
||||
| Old binary reads new records | D | E | Additive-only within 1.x; `_meta` stripped on serve; unknown `schema` skipped on list, `409` on fetch; `MANIFEST.json` `min_reader_layout` refuses a tree the binary cannot read. (SO-51) |
|
||||
| Rollback leaves data the disabled UI cannot show | I | E | Disabling hides routes, not data; TTL, forget and purge keep running. (SO-52) |
|
||||
|
||||
## 8. Abuse and DoS bounds
|
||||
|
||||
Sized for 4-8 tenants, one human each, on a 10 Gi PVC shared with the WebUI.
|
||||
|
||||
- Bodies: 64 KiB JSON, 25 MiB blob, rejected before parsing.
|
||||
- Rate: 30 writes/min and 300 reads/min per subject, `429 rate_limited`
|
||||
with `Retry-After`. Relay and worker get the same limits per slot.
|
||||
- Events: 50 000 per conversation then archived; 1 GiB total.
|
||||
- Memory: 5 000 live entries; proposals expire in 7 days; ledger compacts at 64 MiB.
|
||||
- Artifacts: 2 000, 200 versions each, 2 GiB blobs.
|
||||
- Research: 10 000 sources, 50 000 passages, 512 MiB.
|
||||
- Approvals: 50 pending per run; expiry ≤ 24 h.
|
||||
- SSE: one per (subject, conversation); idle timeout 15 min; 8 per subject.
|
||||
- Threads: `ThreadingHTTPServer` capped at 32 concurrent requests; beyond that `503`.
|
||||
|
||||
## Security obligations checklist
|
||||
|
||||
- SO-01 The service binds 127.0.0.1 only; any other bind address fails at startup.
|
||||
- SO-02 Tenant pod ingress admits `app: hermes-chat-router` only; no hux port is added to any NetworkPolicy.
|
||||
- SO-03 The router deletes inbound `X-Hux-*` and `X-Hermes-Tenant-Identity` before setting its own; the service rejects a slot header not equal to `HERMES_TENANT_SLOT`.
|
||||
- SO-04 Exactly one identity header set is accepted (`X-Hermes-Tenant-Identity`, `X-Hux-Subject`, `X-Hux-Surface`); any other vocabulary is `401`.
|
||||
- SO-05 Relay requests are checked with `hmac.compare_digest` against the relay key and carry `trust: relay`.
|
||||
- SO-06 A relay request carrying `X-Hux-Subject` is `401`; the owner is derived from the slot.
|
||||
- SO-07 The relay key and every `/opt/data/.env` value never appear in a record, event detail, audit line or log.
|
||||
- SO-08 `trust: worker` may call only `/releases`, `/capabilities` and `/admin/retention`; everything else is `403`.
|
||||
- SO-09 Every path contains the asserted `usr_` hash; a different subject on the same pod sees an empty tree.
|
||||
- SO-10 `identity`, `provenance.surface`, user-type `provenance.actor` and `recorded_at` are server-set and client values ignored.
|
||||
- SO-11 Event `detail` keys outside the per-kind allowlist are dropped before persistence.
|
||||
- SO-12 Every stored string passes the secret scrub; a hit becomes `[redacted:<class>]` and raises `redaction.level` to at least `partial`.
|
||||
- SO-13 `detail` over 32 KiB is replaced by a truncation marker; a line over 64 KiB is rejected.
|
||||
- SO-14 `redaction.level` is enforced at serve time; telegram and voice never receive `none`.
|
||||
- SO-15 `seq` and `id` are server-assigned; a body containing either is `400`.
|
||||
- SO-16 Idempotent replays are served only after the ownership check passes.
|
||||
- SO-17 Event pages are ≤ 200; one SSE stream per (subject, conversation); idle timeout 15 min.
|
||||
- SO-18 A conversation the caller does not own returns `404`, never `403`.
|
||||
- SO-19 The service never dereferences `evidence_ref.uri` or `source.uri`; stored URIs use an allowlisted scheme.
|
||||
- SO-20 Restricted or fully redacted events never enter the search index.
|
||||
- SO-21 `memory_policy_violations` runs on every memory write and transition; a violation is `422` plus a `memory.suppressed` event.
|
||||
- SO-22 `no_store` and `forget` write a content-free ledger line and a tombstone before returning.
|
||||
- SO-23 Retrieval, search, export and the agent read hook consult tombstones and `memory_disabled` first and return only `active` entries with `retrievable: true`.
|
||||
- SO-24 `forget` re-redacts events referencing the memory id to `full` and queues the ledger rewrite.
|
||||
- SO-25 An entry that `supersedes` or is sourced from a forgotten id requires a user-actor approval.
|
||||
- SO-26 Export is owner-scoped, excludes non-active entries, appends `exported` to `audit[]`, and its snapshot is pruned after 7 days.
|
||||
- SO-27 Expired entries are never served as active; a retention audit older than 48 h is reported as `audit_stale`.
|
||||
- SO-28 In `private` mode `POST /memory` is refused and event appends return `204` without writing.
|
||||
- SO-29 A test asserts no outbound HTTP or raw socket client is importable from the service's module graph.
|
||||
- SO-30 Blob hashes are recomputed on upload; a mismatch is `422`; blobs are 0400 and never rewritten.
|
||||
- SO-31 Artifact caps: 25 MiB per version, 200 versions, 2 000 artifacts, 2 GiB blobs.
|
||||
- SO-32 Blobs are served as `application/octet-stream` attachments with `nosniff`.
|
||||
- SO-33 Every referenced id (lineage, promotion, source, passage, citation, notebook) must resolve under the caller's subtree.
|
||||
- SO-34 Share tokens are owner-minted, read-only, expire within 7 days, and resolve only on the owning pod.
|
||||
- SO-35 Approvals are decided only from a human surface with `trust` router or relay; `decision.by` is the asserted user.
|
||||
- SO-36 A `once` approval can be consumed exactly once.
|
||||
- SO-37 Consume must present the argument hash recorded in `request.evidence` at request time.
|
||||
- SO-38 `always` grants carry a server-set `expires_at` of at most 30 days.
|
||||
- SO-39 `effective_decision` is the sole resolver; `deploy` and `external_side_effect` always resolve to `ask`; `request.external: true` always needs an approval record.
|
||||
- SO-40 An exhausted `budget_state` blocks new approvals with `budget_exhausted` and emits `budget.exhausted`.
|
||||
- SO-41 A receipt says `cancelled` only after the run's process registry is empty, and lists every successful side effect after `requested_at`.
|
||||
- SO-42 Pending approvals expire within 24 h of `requested_at`.
|
||||
- SO-43 All paths go through `path_for`: pattern-validated ids plus `commonpath` containment in the user subtree.
|
||||
- SO-44 Revisioned writes require `If-Match`; mismatch is `409`; unconditional writes are audited.
|
||||
- SO-45 The service runs as a distinct uid with `/opt/data/hux` mode 0700; the agent has no filesystem path to records.
|
||||
- SO-46 Audit outcome lines are hash-chained and a broken chain is reported in the next retention audit.
|
||||
- SO-47 Purge touches only tombstoned ids and unreferenced blobs older than 7 days, records a dry-run count first, and never touches `audit/`.
|
||||
- SO-48 A second service process against the same tree fails at startup on `v1/.lock`.
|
||||
- SO-49 Writes beyond family caps are `413`; total store above 4 GiB marks `/healthz` degraded.
|
||||
- SO-50 A route serves only when its flag and every dependency flag are on; `/capabilities` reports the resolved chain.
|
||||
- SO-51 Responses strip `_meta`, unknown `schema` values are skipped on list and `409` on fetch, and a tree above `min_reader_layout` is refused.
|
||||
- SO-52 Disabling a flag hides routes; TTL, forget and purge continue.
|
||||
- SO-53 Rate limits (30 writes/min, 300 reads/min per subject) return `429` with `Retry-After`.
|
||||
- SO-54 Bodies over 64 KiB (JSON) or 25 MiB (blob) are rejected with `413` before parsing.
|
||||
1
services/hermes/contracts/hux/VERSION
Normal file
1
services/hermes/contracts/hux/VERSION
Normal file
@ -0,0 +1 @@
|
||||
1.1.0
|
||||
@ -5,65 +5,192 @@
|
||||
"description": "Typed, versioned output (HUX-04). Content lives behind content_ref; versions are immutable and diffable; lineage records what an artifact was derived from; promotion copies a version into a project.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "id", "owner", "type", "title", "current_version", "versions", "sensitivity", "created_at", "updated_at"],
|
||||
"required": [
|
||||
"schema",
|
||||
"id",
|
||||
"owner",
|
||||
"type",
|
||||
"title",
|
||||
"current_version",
|
||||
"versions",
|
||||
"sensitivity",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"revision",
|
||||
"access"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "hux.artifact.v1"},
|
||||
"id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
|
||||
"conversation_id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"project_id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"type": {"type": "string", "enum": ["markdown", "code", "html", "svg", "image", "json", "csv", "document", "audio"]},
|
||||
"language": {"type": "string", "maxLength": 40},
|
||||
"title": {"type": "string", "minLength": 1, "maxLength": 200},
|
||||
"current_version": {"type": "integer", "minimum": 1},
|
||||
"schema": {
|
||||
"const": "hux.artifact.v1"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"owner": {
|
||||
"$ref": "common.schema.json#/$defs/user_ref"
|
||||
},
|
||||
"conversation_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"project_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"markdown",
|
||||
"code",
|
||||
"html",
|
||||
"svg",
|
||||
"image",
|
||||
"json",
|
||||
"csv",
|
||||
"document",
|
||||
"audio"
|
||||
]
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"maxLength": 40
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"current_version": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"versions": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["version", "created_at", "created_by", "content_ref"],
|
||||
"required": [
|
||||
"version",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"content_ref"
|
||||
],
|
||||
"properties": {
|
||||
"version": {"type": "integer", "minimum": 1},
|
||||
"created_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"created_by": {"$ref": "common.schema.json#/$defs/actor"},
|
||||
"message_id": {"type": "string", "maxLength": 120},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"created_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"created_by": {
|
||||
"$ref": "common.schema.json#/$defs/actor"
|
||||
},
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"maxLength": 120
|
||||
},
|
||||
"content_ref": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["hash", "bytes", "mime"],
|
||||
"required": [
|
||||
"hash",
|
||||
"bytes",
|
||||
"mime"
|
||||
],
|
||||
"properties": {
|
||||
"hash": {"$ref": "common.schema.json#/$defs/sha256"},
|
||||
"bytes": {"type": "integer", "minimum": 0},
|
||||
"mime": {"type": "string", "maxLength": 120}
|
||||
"hash": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"mime": {
|
||||
"type": "string",
|
||||
"maxLength": 120
|
||||
}
|
||||
}
|
||||
},
|
||||
"diff_from": {"type": "integer", "minimum": 1},
|
||||
"diff_from": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"lineage": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["artifact_id", "version"],
|
||||
"required": [
|
||||
"artifact_id",
|
||||
"version"
|
||||
],
|
||||
"properties": {
|
||||
"artifact_id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"version": {"type": "integer", "minimum": 1}
|
||||
"artifact_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"note": {"type": "string", "maxLength": 200}
|
||||
"note": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"promotion": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["project_id", "version", "at"],
|
||||
"required": [
|
||||
"project_id",
|
||||
"version",
|
||||
"at"
|
||||
],
|
||||
"properties": {
|
||||
"project_id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"version": {"type": "integer", "minimum": 1},
|
||||
"at": {"$ref": "common.schema.json#/$defs/timestamp"}
|
||||
"project_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensitivity": {"$ref": "common.schema.json#/$defs/sensitivity"},
|
||||
"created_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"}
|
||||
"sensitivity": {
|
||||
"$ref": "common.schema.json#/$defs/sensitivity"
|
||||
},
|
||||
"created_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"updated_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"revision": {
|
||||
"$ref": "common.schema.json#/$defs/revision"
|
||||
},
|
||||
"access": {
|
||||
"type": "object",
|
||||
"description": "Owner-only unless a share token is minted; shares are read-only and expire.",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"mode"
|
||||
],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"owner",
|
||||
"shared_readonly"
|
||||
]
|
||||
},
|
||||
"share_expires_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,82 +7,329 @@
|
||||
"source": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "id", "kind", "title", "classification", "retrieved_at"],
|
||||
"required": [
|
||||
"schema",
|
||||
"id",
|
||||
"kind",
|
||||
"title",
|
||||
"classification",
|
||||
"retrieved_at"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "hux.source.v1"},
|
||||
"id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"kind": {"type": "string", "enum": ["web", "document", "artifact", "memory", "tool_output", "dataset"]},
|
||||
"uri": {"type": "string", "maxLength": 2000},
|
||||
"title": {"type": "string", "minLength": 1, "maxLength": 300},
|
||||
"publisher": {"type": "string", "maxLength": 200},
|
||||
"published_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"retrieved_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"classification": {"type": "string", "enum": ["primary", "secondary", "unknown"]},
|
||||
"content_hash": {"$ref": "common.schema.json#/$defs/sha256"},
|
||||
"provenance": {"$ref": "common.schema.json#/$defs/provenance"}
|
||||
"schema": {
|
||||
"const": "hux.source.v1"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"web",
|
||||
"document",
|
||||
"artifact",
|
||||
"memory",
|
||||
"tool_output",
|
||||
"dataset"
|
||||
]
|
||||
},
|
||||
"uri": {
|
||||
"type": "string",
|
||||
"maxLength": 2000
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 300
|
||||
},
|
||||
"publisher": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"published_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"retrieved_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"classification": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"primary",
|
||||
"secondary",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"content_hash": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "common.schema.json#/$defs/provenance"
|
||||
},
|
||||
"dedupe_key": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"passage": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "id", "source_id", "text", "hash"],
|
||||
"required": [
|
||||
"schema",
|
||||
"id",
|
||||
"source_id",
|
||||
"text",
|
||||
"hash"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "hux.passage.v1"},
|
||||
"id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"source_id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"text": {"type": "string", "minLength": 1, "maxLength": 4000},
|
||||
"hash": {"$ref": "common.schema.json#/$defs/sha256"},
|
||||
"schema": {
|
||||
"const": "hux.passage.v1"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"source_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4000
|
||||
},
|
||||
"hash": {
|
||||
"$ref": "common.schema.json#/$defs/sha256"
|
||||
},
|
||||
"locator": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"page": {"type": "integer", "minimum": 1},
|
||||
"selector": {"type": "string", "maxLength": 500},
|
||||
"line_start": {"type": "integer", "minimum": 1},
|
||||
"line_end": {"type": "integer", "minimum": 1},
|
||||
"char_start": {"type": "integer", "minimum": 0},
|
||||
"char_end": {"type": "integer", "minimum": 0}
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"maxLength": 500
|
||||
},
|
||||
"line_start": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"line_end": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"char_start": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"char_end": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"dedupe_key": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"citation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "id", "message_id", "claim", "passage_ids", "support"],
|
||||
"required": [
|
||||
"schema",
|
||||
"id",
|
||||
"message_id",
|
||||
"claim",
|
||||
"passage_ids",
|
||||
"support"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "hux.citation.v1"},
|
||||
"id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"message_id": {"type": "string", "minLength": 1, "maxLength": 120},
|
||||
"claim": {"type": "string", "minLength": 1, "maxLength": 1000},
|
||||
"passage_ids": {"type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/id"}},
|
||||
"support": {"type": "string", "enum": ["supports", "partially_supports", "contradicts", "unverified"]},
|
||||
"note": {"type": "string", "maxLength": 500}
|
||||
"schema": {
|
||||
"const": "hux.citation.v1"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120
|
||||
},
|
||||
"claim": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1000
|
||||
},
|
||||
"passage_ids": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 32,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"supports",
|
||||
"partially_supports",
|
||||
"contradicts",
|
||||
"unverified"
|
||||
]
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"maxLength": 500
|
||||
},
|
||||
"dedupe_key": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "id", "conversation_id", "question", "status", "source_ids", "passage_ids", "citation_ids", "assumptions", "unresolved_questions", "updated_at"],
|
||||
"required": [
|
||||
"schema",
|
||||
"id",
|
||||
"conversation_id",
|
||||
"question",
|
||||
"status",
|
||||
"source_ids",
|
||||
"passage_ids",
|
||||
"citation_ids",
|
||||
"assumptions",
|
||||
"unresolved_questions",
|
||||
"updated_at",
|
||||
"notes",
|
||||
"revision"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "hux.research_notebook.v1"},
|
||||
"id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"conversation_id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"question": {"type": "string", "minLength": 1, "maxLength": 1000},
|
||||
"status": {"type": "string", "enum": ["open", "answered", "abandoned"]},
|
||||
"source_ids": {"type": "array", "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/id"}},
|
||||
"passage_ids": {"type": "array", "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/id"}},
|
||||
"citation_ids": {"type": "array", "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/id"}},
|
||||
"assumptions": {"type": "array", "maxItems": 64, "items": {"type": "string", "minLength": 1, "maxLength": 500}},
|
||||
"unresolved_questions": {"type": "array", "maxItems": 64, "items": {"type": "string", "minLength": 1, "maxLength": 500}},
|
||||
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"}
|
||||
"schema": {
|
||||
"const": "hux.research_notebook.v1"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"conversation_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"question": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1000
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"open",
|
||||
"answered",
|
||||
"abandoned"
|
||||
]
|
||||
},
|
||||
"source_ids": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
},
|
||||
"passage_ids": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
},
|
||||
"citation_ids": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
},
|
||||
"assumptions": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500
|
||||
}
|
||||
},
|
||||
"unresolved_questions": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500
|
||||
}
|
||||
},
|
||||
"updated_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"notes": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"at",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 2000
|
||||
},
|
||||
"source_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"revision": {
|
||||
"$ref": "common.schema.json#/$defs/revision"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/source"},
|
||||
{"$ref": "#/$defs/passage"},
|
||||
{"$ref": "#/$defs/citation"},
|
||||
{"$ref": "#/$defs/notebook"}
|
||||
{
|
||||
"$ref": "#/$defs/source"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/passage"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/citation"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/notebook"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -25,98 +25,318 @@
|
||||
},
|
||||
"surface": {
|
||||
"type": "string",
|
||||
"enum": ["chat", "worker", "telegram", "voice", "api"]
|
||||
"enum": [
|
||||
"chat",
|
||||
"worker",
|
||||
"telegram",
|
||||
"voice",
|
||||
"api"
|
||||
]
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "Provider class, never a vendor model name. Switchyard resolves the class to a concrete target.",
|
||||
"enum": ["codex", "claude", "local"]
|
||||
"enum": [
|
||||
"codex",
|
||||
"claude",
|
||||
"local"
|
||||
]
|
||||
},
|
||||
"effort": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high", "xhigh"]
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
]
|
||||
},
|
||||
"sensitivity": {
|
||||
"type": "string",
|
||||
"description": "public: safe to show anywhere; personal: user-owned but not sensitive; sensitive: health/finance/legal/relationships; restricted: credentials, minors, biometric, location traces.",
|
||||
"enum": ["public", "personal", "sensitive", "restricted"]
|
||||
"enum": [
|
||||
"public",
|
||||
"personal",
|
||||
"sensitive",
|
||||
"restricted"
|
||||
]
|
||||
},
|
||||
"redaction": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["level"],
|
||||
"required": [
|
||||
"level"
|
||||
],
|
||||
"properties": {
|
||||
"level": {"type": "string", "enum": ["none", "partial", "full"]},
|
||||
"reason": {"type": "string", "maxLength": 200}
|
||||
"level": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"none",
|
||||
"partial",
|
||||
"full"
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
}
|
||||
}
|
||||
},
|
||||
"actor": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "id"],
|
||||
"required": [
|
||||
"type",
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"type": {"type": "string", "enum": ["user", "assistant", "tool", "system", "operator"]},
|
||||
"id": {"type": "string", "minLength": 1, "maxLength": 120},
|
||||
"display": {"type": "string", "maxLength": 120}
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"user",
|
||||
"assistant",
|
||||
"tool",
|
||||
"system",
|
||||
"operator"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120
|
||||
},
|
||||
"display": {
|
||||
"type": "string",
|
||||
"maxLength": 120
|
||||
}
|
||||
}
|
||||
},
|
||||
"route": {
|
||||
"type": "object",
|
||||
"description": "What was asked of Switchyard and what it resolved. requested is a friendly mode or a route id; resolved_target is the Switchyard target name.",
|
||||
"additionalProperties": false,
|
||||
"required": ["requested"],
|
||||
"required": [
|
||||
"requested"
|
||||
],
|
||||
"properties": {
|
||||
"requested": {"type": "string", "minLength": 1, "maxLength": 120},
|
||||
"resolved_target": {"type": "string", "maxLength": 120},
|
||||
"provider": {"$ref": "#/$defs/provider"},
|
||||
"effort": {"$ref": "#/$defs/effort"}
|
||||
"requested": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120
|
||||
},
|
||||
"resolved_target": {
|
||||
"type": "string",
|
||||
"maxLength": 120
|
||||
},
|
||||
"provider": {
|
||||
"$ref": "#/$defs/provider"
|
||||
},
|
||||
"effort": {
|
||||
"$ref": "#/$defs/effort"
|
||||
}
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
|
||||
"image_digest": {"$ref": "#/$defs/sha256"}
|
||||
"commit": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{40}$"
|
||||
},
|
||||
"image_digest": {
|
||||
"$ref": "#/$defs/sha256"
|
||||
}
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"type": "object",
|
||||
"description": "Who produced a record, on which surface, under which session/run, through which route, from which build.",
|
||||
"additionalProperties": false,
|
||||
"required": ["surface", "actor", "recorded_at"],
|
||||
"required": [
|
||||
"surface",
|
||||
"actor",
|
||||
"recorded_at"
|
||||
],
|
||||
"properties": {
|
||||
"surface": {"$ref": "#/$defs/surface"},
|
||||
"actor": {"$ref": "#/$defs/actor"},
|
||||
"recorded_at": {"$ref": "#/$defs/timestamp"},
|
||||
"session_id": {"type": "string", "maxLength": 120},
|
||||
"conversation_id": {"$ref": "#/$defs/id"},
|
||||
"message_id": {"type": "string", "maxLength": 120},
|
||||
"run_id": {"type": "string", "maxLength": 120},
|
||||
"route": {"$ref": "#/$defs/route"},
|
||||
"build": {"$ref": "#/$defs/build"}
|
||||
"surface": {
|
||||
"$ref": "#/$defs/surface"
|
||||
},
|
||||
"actor": {
|
||||
"$ref": "#/$defs/actor"
|
||||
},
|
||||
"recorded_at": {
|
||||
"$ref": "#/$defs/timestamp"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"maxLength": 120
|
||||
},
|
||||
"conversation_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"maxLength": 120
|
||||
},
|
||||
"run_id": {
|
||||
"type": "string",
|
||||
"maxLength": 120
|
||||
},
|
||||
"route": {
|
||||
"$ref": "#/$defs/route"
|
||||
},
|
||||
"build": {
|
||||
"$ref": "#/$defs/build"
|
||||
}
|
||||
}
|
||||
},
|
||||
"evidence_ref": {
|
||||
"type": "object",
|
||||
"description": "Pointer to the thing that justifies a record. Never inline the payload here; the UI expands it through the owning API.",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "id"],
|
||||
"required": [
|
||||
"kind",
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["message", "tool_call", "tool_result", "artifact_version", "source", "passage", "memory", "approval", "run", "url", "file", "build", "flux", "pod"]
|
||||
"enum": [
|
||||
"message",
|
||||
"tool_call",
|
||||
"tool_result",
|
||||
"artifact_version",
|
||||
"source",
|
||||
"passage",
|
||||
"memory",
|
||||
"approval",
|
||||
"run",
|
||||
"url",
|
||||
"file",
|
||||
"build",
|
||||
"flux",
|
||||
"pod",
|
||||
"receipt"
|
||||
]
|
||||
},
|
||||
"id": {"type": "string", "minLength": 1, "maxLength": 200},
|
||||
"uri": {"type": "string", "maxLength": 2000},
|
||||
"hash": {"$ref": "#/$defs/sha256"}
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"uri": {
|
||||
"type": "string",
|
||||
"maxLength": 2000
|
||||
},
|
||||
"hash": {
|
||||
"$ref": "#/$defs/sha256"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"uniqueItems": true,
|
||||
"items": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,39}$"}
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9][a-z0-9-]{0,39}$"
|
||||
}
|
||||
},
|
||||
"tenant_slot": {
|
||||
"type": "string",
|
||||
"description": "Router-assigned tenant process slot.",
|
||||
"pattern": "^slot-[0-9]{1,3}$"
|
||||
},
|
||||
"trust": {
|
||||
"type": "string",
|
||||
"description": "How the identity was asserted: router (browser via chat router), relay (Telegram relay key), worker (owner control plane).",
|
||||
"enum": [
|
||||
"router",
|
||||
"relay",
|
||||
"worker"
|
||||
]
|
||||
},
|
||||
"identity": {
|
||||
"type": "object",
|
||||
"description": "Resolved caller identity. Every record path and authorization check derives from tenant_slot + subject.",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"tenant_slot",
|
||||
"subject",
|
||||
"surface",
|
||||
"trust"
|
||||
],
|
||||
"properties": {
|
||||
"tenant_slot": {
|
||||
"$ref": "#/$defs/tenant_slot"
|
||||
},
|
||||
"subject": {
|
||||
"$ref": "#/$defs/user_ref"
|
||||
},
|
||||
"surface": {
|
||||
"$ref": "#/$defs/surface"
|
||||
},
|
||||
"trust": {
|
||||
"$ref": "#/$defs/trust"
|
||||
}
|
||||
}
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"description": "Optimistic concurrency counter. Mutations send If-Match: <revision>; a mismatch is 409.",
|
||||
"minimum": 1
|
||||
},
|
||||
"idempotency_key": {
|
||||
"type": "string",
|
||||
"description": "Client-chosen key; a repeat with the same key returns the original record instead of creating a duplicate.",
|
||||
"pattern": "^[A-Za-z0-9._:-]{8,120}$"
|
||||
},
|
||||
"contract_version": {
|
||||
"type": "string",
|
||||
"pattern": "^1\\.[0-9]+\\.[0-9]+$"
|
||||
},
|
||||
"audit_outcome": {
|
||||
"type": "object",
|
||||
"description": "Result of an authorization decision, written for every read and mutation.",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"at",
|
||||
"identity",
|
||||
"action",
|
||||
"resource",
|
||||
"outcome"
|
||||
],
|
||||
"properties": {
|
||||
"at": {
|
||||
"$ref": "#/$defs/timestamp"
|
||||
},
|
||||
"identity": {
|
||||
"$ref": "#/$defs/identity"
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z_]+\\.[a-z_]+$"
|
||||
},
|
||||
"resource": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"allow",
|
||||
"deny",
|
||||
"not_found",
|
||||
"conflict",
|
||||
"flag_off"
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,38 +5,120 @@
|
||||
"description": "One entry in the per-conversation activity stream (HUX-01). seq is strictly monotonic per conversation; consumers resume with after_seq. Every other HUX record that changes state emits exactly one event.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "id", "seq", "ts", "conversation_id", "kind", "summary", "provenance", "sensitivity", "redaction"],
|
||||
"required": [
|
||||
"schema",
|
||||
"id",
|
||||
"seq",
|
||||
"ts",
|
||||
"conversation_id",
|
||||
"kind",
|
||||
"summary",
|
||||
"provenance",
|
||||
"sensitivity",
|
||||
"redaction",
|
||||
"turn",
|
||||
"identity"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "hux.event.v1"},
|
||||
"id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"seq": {"type": "integer", "minimum": 0},
|
||||
"ts": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"conversation_id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"run_id": {"type": "string", "maxLength": 120},
|
||||
"parent_event_id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"correlation_id": {"type": "string", "maxLength": 120},
|
||||
"schema": {
|
||||
"const": "hux.event.v1"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"seq": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"ts": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"conversation_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"run_id": {
|
||||
"type": "string",
|
||||
"maxLength": 120
|
||||
},
|
||||
"parent_event_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"correlation_id": {
|
||||
"type": "string",
|
||||
"maxLength": 120
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"message.user", "message.assistant",
|
||||
"decision.route", "decision.plan",
|
||||
"tool.call", "tool.result",
|
||||
"approval.requested", "approval.resolved",
|
||||
"memory.proposed", "memory.committed", "memory.forgotten",
|
||||
"artifact.created", "artifact.version", "artifact.promoted",
|
||||
"message.user",
|
||||
"message.assistant",
|
||||
"decision.route",
|
||||
"decision.plan",
|
||||
"tool.call",
|
||||
"tool.result",
|
||||
"approval.requested",
|
||||
"approval.resolved",
|
||||
"memory.proposed",
|
||||
"memory.committed",
|
||||
"memory.forgotten",
|
||||
"artifact.created",
|
||||
"artifact.version",
|
||||
"artifact.promoted",
|
||||
"citation.attached",
|
||||
"mode.changed",
|
||||
"run.started", "run.cancelled", "run.completed", "run.failed",
|
||||
"run.started",
|
||||
"run.cancelled",
|
||||
"run.completed",
|
||||
"run.failed",
|
||||
"privacy.notice",
|
||||
"suggestion.shown", "suggestion.dismissed",
|
||||
"release.transition"
|
||||
"suggestion.shown",
|
||||
"suggestion.dismissed",
|
||||
"release.transition",
|
||||
"delegation.started",
|
||||
"delegation.completed",
|
||||
"delegation.failed",
|
||||
"memory.suppressed",
|
||||
"memory.retrieval_removed",
|
||||
"budget.exhausted",
|
||||
"side_effect.blocked",
|
||||
"side_effect.released"
|
||||
]
|
||||
},
|
||||
"summary": {"type": "string", "minLength": 1, "maxLength": 280},
|
||||
"detail": {"type": "object", "description": "Kind-specific payload. Redacted according to redaction.level before leaving the tenant."},
|
||||
"evidence": {"type": "array", "maxItems": 64, "items": {"$ref": "common.schema.json#/$defs/evidence_ref"}},
|
||||
"provenance": {"$ref": "common.schema.json#/$defs/provenance"},
|
||||
"sensitivity": {"$ref": "common.schema.json#/$defs/sensitivity"},
|
||||
"redaction": {"$ref": "common.schema.json#/$defs/redaction"}
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 280
|
||||
},
|
||||
"detail": {
|
||||
"type": "object",
|
||||
"description": "Kind-specific payload restricted to the allowlisted keys for that kind. Raw tool arguments, logs, credentials and tokens are never stored here; the redaction pipeline drops unknown keys and scrubs secret patterns before the event is written."
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/evidence_ref"
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "common.schema.json#/$defs/provenance"
|
||||
},
|
||||
"sensitivity": {
|
||||
"$ref": "common.schema.json#/$defs/sensitivity"
|
||||
},
|
||||
"redaction": {
|
||||
"$ref": "common.schema.json#/$defs/redaction"
|
||||
},
|
||||
"turn": {
|
||||
"type": "integer",
|
||||
"description": "User-visible turn number within the conversation; several events share a turn.",
|
||||
"minimum": 0
|
||||
},
|
||||
"idempotency_key": {
|
||||
"$ref": "common.schema.json#/$defs/idempotency_key"
|
||||
},
|
||||
"identity": {
|
||||
"$ref": "common.schema.json#/$defs/identity"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1,30 @@
|
||||
{"schema":"hux.approval.v1","id":"apr_0001aaaa","run_id":"run_9f","conversation_id":"conv_0001abcd","capability":"write_files","request":{"summary":"Write supplier-comparison.md to the workspace","risk":"low","evidence":[{"kind":"tool_call","id":"call-7"}]},"status":"approved","decision":{"choice":"session","by":{"type":"user","id":"usr_0123456789abcdef"},"at":"2026-08-23T10:00:03Z"},"requested_at":"2026-08-23T10:00:02Z","expires_at":"2026-08-23T10:10:02Z"}
|
||||
{
|
||||
"schema": "hux.approval.v1",
|
||||
"id": "apr_0001aaaa",
|
||||
"run_id": "run_9f",
|
||||
"conversation_id": "conv_0001abcd",
|
||||
"capability": "write_files",
|
||||
"request": {
|
||||
"summary": "Write supplier-comparison.md to the workspace",
|
||||
"risk": "low",
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "tool_call",
|
||||
"id": "call-7"
|
||||
}
|
||||
],
|
||||
"external": false
|
||||
},
|
||||
"status": "approved",
|
||||
"decision": {
|
||||
"choice": "session",
|
||||
"by": {
|
||||
"type": "user",
|
||||
"id": "usr_0123456789abcdef"
|
||||
},
|
||||
"at": "2026-08-23T10:00:03Z"
|
||||
},
|
||||
"requested_at": "2026-08-23T10:00:02Z",
|
||||
"expires_at": "2026-08-23T10:10:02Z",
|
||||
"idempotency_key": "run_9f:approval:call-7"
|
||||
}
|
||||
|
||||
@ -1 +1,53 @@
|
||||
{"schema":"hux.artifact.v1","id":"art_0001aaaa","owner":"usr_0123456789abcdef","conversation_id":"conv_0001abcd","project_id":"prj_0001aaaa","type":"markdown","title":"Supplier comparison","current_version":2,"versions":[{"version":1,"created_at":"2026-08-22T10:00:00Z","created_by":{"type":"assistant","id":"hermes"},"message_id":"msg-20","content_ref":{"hash":"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881","bytes":2048,"mime":"text/markdown"}},{"version":2,"created_at":"2026-08-23T10:00:00Z","created_by":{"type":"user","id":"usr_0123456789abcdef"},"content_ref":{"hash":"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881","bytes":2100,"mime":"text/markdown"},"diff_from":1,"note":"added delivery lead times"}],"promotion":{"project_id":"prj_0001aaaa","version":2,"at":"2026-08-23T10:00:05Z"},"sensitivity":"personal","created_at":"2026-08-22T10:00:00Z","updated_at":"2026-08-23T10:00:05Z"}
|
||||
{
|
||||
"schema": "hux.artifact.v1",
|
||||
"id": "art_0001aaaa",
|
||||
"owner": "usr_0123456789abcdef",
|
||||
"conversation_id": "conv_0001abcd",
|
||||
"project_id": "prj_0001aaaa",
|
||||
"type": "markdown",
|
||||
"title": "Supplier comparison",
|
||||
"current_version": 2,
|
||||
"versions": [
|
||||
{
|
||||
"version": 1,
|
||||
"created_at": "2026-08-22T10:00:00Z",
|
||||
"created_by": {
|
||||
"type": "assistant",
|
||||
"id": "hermes"
|
||||
},
|
||||
"message_id": "msg-20",
|
||||
"content_ref": {
|
||||
"hash": "sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881",
|
||||
"bytes": 2048,
|
||||
"mime": "text/markdown"
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": 2,
|
||||
"created_at": "2026-08-23T10:00:00Z",
|
||||
"created_by": {
|
||||
"type": "user",
|
||||
"id": "usr_0123456789abcdef"
|
||||
},
|
||||
"content_ref": {
|
||||
"hash": "sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881",
|
||||
"bytes": 2100,
|
||||
"mime": "text/markdown"
|
||||
},
|
||||
"diff_from": 1,
|
||||
"note": "added delivery lead times"
|
||||
}
|
||||
],
|
||||
"promotion": {
|
||||
"project_id": "prj_0001aaaa",
|
||||
"version": 2,
|
||||
"at": "2026-08-23T10:00:05Z"
|
||||
},
|
||||
"sensitivity": "personal",
|
||||
"created_at": "2026-08-22T10:00:00Z",
|
||||
"updated_at": "2026-08-23T10:00:05Z",
|
||||
"revision": 2,
|
||||
"access": {
|
||||
"mode": "owner"
|
||||
}
|
||||
}
|
||||
|
||||
20
services/hermes/contracts/hux/examples/budget_state.json
Normal file
20
services/hermes/contracts/hux/examples/budget_state.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"schema": "hux.budget_state.v1",
|
||||
"run_id": "run_9f",
|
||||
"spent": {
|
||||
"tokens": 120000,
|
||||
"tool_calls": 12,
|
||||
"wall_clock_seconds": 300,
|
||||
"delegations": 1,
|
||||
"spend_units": 10,
|
||||
"subagents": 1
|
||||
},
|
||||
"limits": {
|
||||
"tokens_per_run": 200000,
|
||||
"tool_calls_per_run": 40,
|
||||
"wall_clock_seconds": 900,
|
||||
"spend_units": 50,
|
||||
"subagents_per_run": 2
|
||||
},
|
||||
"exhausted": []
|
||||
}
|
||||
@ -1 +1,24 @@
|
||||
{"schema":"hux.cancel_receipt.v1","id":"rcpt_0001aaaa","run_id":"run_9f","requested_by":{"type":"user","id":"usr_0123456789abcdef"},"requested_at":"2026-08-23T10:00:04Z","acknowledged_at":"2026-08-23T10:00:04Z","completed_at":"2026-08-23T10:00:05Z","outcome":"cancelled","side_effects":[{"description":"Partial file supplier-comparison.md left in workspace","reverted":true,"evidence":{"kind":"file","id":"supplier-comparison.md"}}]}
|
||||
{
|
||||
"schema": "hux.cancel_receipt.v1",
|
||||
"id": "rcpt_0001aaaa",
|
||||
"run_id": "run_9f",
|
||||
"requested_by": {
|
||||
"type": "user",
|
||||
"id": "usr_0123456789abcdef"
|
||||
},
|
||||
"requested_at": "2026-08-23T10:00:04Z",
|
||||
"acknowledged_at": "2026-08-23T10:00:04Z",
|
||||
"completed_at": "2026-08-23T10:00:05Z",
|
||||
"outcome": "cancelled",
|
||||
"side_effects": [
|
||||
{
|
||||
"description": "Partial file supplier-comparison.md left in workspace",
|
||||
"reverted": true,
|
||||
"evidence": {
|
||||
"kind": "file",
|
||||
"id": "supplier-comparison.md"
|
||||
}
|
||||
}
|
||||
],
|
||||
"conversation_id": "conv_0001abcd"
|
||||
}
|
||||
|
||||
33
services/hermes/contracts/hux/examples/capabilities.json
Normal file
33
services/hermes/contracts/hux/examples/capabilities.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"schema": "hux.capabilities.v1",
|
||||
"contract_version": "1.0.0",
|
||||
"identity": {
|
||||
"tenant_slot": "slot-3",
|
||||
"subject": "usr_0123456789abcdef",
|
||||
"surface": "chat",
|
||||
"trust": "router"
|
||||
},
|
||||
"cards": [
|
||||
{
|
||||
"card": "HUX-11",
|
||||
"flag": "hux.foundation",
|
||||
"enabled": true,
|
||||
"routes": [
|
||||
"/hux/v1/capabilities"
|
||||
]
|
||||
},
|
||||
{
|
||||
"card": "HUX-01",
|
||||
"flag": "hux.activity_timeline",
|
||||
"enabled": false,
|
||||
"routes": [
|
||||
"/hux/v1/conversations/{id}/events",
|
||||
"/hux/v1/conversations/{id}/events/stream"
|
||||
]
|
||||
}
|
||||
],
|
||||
"server": {
|
||||
"commit": "d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06",
|
||||
"image_digest": "sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"
|
||||
}
|
||||
}
|
||||
@ -1 +1,11 @@
|
||||
{"schema":"hux.citation.v1","id":"cit_0001aaaa","message_id":"msg-44","claim":"Base cabinets from Example Co take three to four weeks.","passage_ids":["psg_0001aaaa"],"support":"supports"}
|
||||
{
|
||||
"schema": "hux.citation.v1",
|
||||
"id": "cit_0001aaaa",
|
||||
"message_id": "msg-44",
|
||||
"claim": "Base cabinets from Example Co take three to four weeks.",
|
||||
"passage_ids": [
|
||||
"psg_0001aaaa"
|
||||
],
|
||||
"support": "supports",
|
||||
"dedupe_key": "sha256:abababababababababababababababababababababababababababababababab"
|
||||
}
|
||||
|
||||
@ -1 +1,24 @@
|
||||
{"schema":"hux.conversation.v1","id":"conv_0001abcd","owner":"usr_0123456789abcdef","project_id":"prj_0001aaaa","title":"Compare cabinet suppliers","tags":["suppliers"],"pinned":false,"archived":false,"mode":"research","branch":{"parent_conversation_id":"conv_0000root","branch_point_message_id":"msg-12"},"artifact_ids":["art_0001aaaa"],"last_message_at":"2026-08-23T10:00:01Z","created_at":"2026-08-22T09:00:00Z","updated_at":"2026-08-23T10:00:01Z"}
|
||||
{
|
||||
"schema": "hux.conversation.v1",
|
||||
"id": "conv_0001abcd",
|
||||
"owner": "usr_0123456789abcdef",
|
||||
"project_id": "prj_0001aaaa",
|
||||
"title": "Compare cabinet suppliers",
|
||||
"tags": [
|
||||
"suppliers"
|
||||
],
|
||||
"pinned": false,
|
||||
"archived": false,
|
||||
"mode": "research",
|
||||
"branch": {
|
||||
"parent_conversation_id": "conv_0000root",
|
||||
"branch_point_message_id": "msg-12"
|
||||
},
|
||||
"artifact_ids": [
|
||||
"art_0001aaaa"
|
||||
],
|
||||
"last_message_at": "2026-08-23T10:00:01Z",
|
||||
"created_at": "2026-08-22T09:00:00Z",
|
||||
"updated_at": "2026-08-23T10:00:01Z",
|
||||
"revision": 3
|
||||
}
|
||||
|
||||
9
services/hermes/contracts/hux/examples/error.json
Normal file
9
services/hermes/contracts/hux/examples/error.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"schema": "hux.error.v1",
|
||||
"status": 409,
|
||||
"code": "conflict",
|
||||
"message": "revision 2 does not match current revision 3",
|
||||
"details": [
|
||||
"If-Match: 2"
|
||||
]
|
||||
}
|
||||
@ -1 +1,52 @@
|
||||
{"schema":"hux.event.v1","id":"evt_0001aaaa","seq":17,"ts":"2026-08-23T10:00:01Z","conversation_id":"conv_0001abcd","run_id":"run_9f","kind":"decision.route","summary":"Routed to a high-effort Claude target for a multi-step refactor","detail":{"candidates":3},"evidence":[{"kind":"run","id":"run_9f"}],"provenance":{"surface":"chat","actor":{"type":"assistant","id":"hermes"},"recorded_at":"2026-08-23T10:00:00Z","session_id":"sess-a1","conversation_id":"conv_0001abcd","run_id":"run_9f","route":{"requested":"thoughtful","resolved_target":"claude_sonnet_high","provider":"claude","effort":"high"},"build":{"commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","image_digest":"sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"}},"sensitivity":"personal","redaction":{"level":"none"}}
|
||||
{
|
||||
"schema": "hux.event.v1",
|
||||
"id": "evt_0001aaaa",
|
||||
"seq": 17,
|
||||
"ts": "2026-08-23T10:00:01Z",
|
||||
"conversation_id": "conv_0001abcd",
|
||||
"run_id": "run_9f",
|
||||
"kind": "decision.route",
|
||||
"summary": "Routed to a high-effort Claude target for a multi-step refactor",
|
||||
"detail": {
|
||||
"candidates": 3
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "run",
|
||||
"id": "run_9f"
|
||||
}
|
||||
],
|
||||
"provenance": {
|
||||
"surface": "chat",
|
||||
"actor": {
|
||||
"type": "assistant",
|
||||
"id": "hermes"
|
||||
},
|
||||
"recorded_at": "2026-08-23T10:00:00Z",
|
||||
"session_id": "sess-a1",
|
||||
"conversation_id": "conv_0001abcd",
|
||||
"run_id": "run_9f",
|
||||
"route": {
|
||||
"requested": "thoughtful",
|
||||
"resolved_target": "claude_sonnet_high",
|
||||
"provider": "claude",
|
||||
"effort": "high"
|
||||
},
|
||||
"build": {
|
||||
"commit": "d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06",
|
||||
"image_digest": "sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"
|
||||
}
|
||||
},
|
||||
"sensitivity": "personal",
|
||||
"redaction": {
|
||||
"level": "none"
|
||||
},
|
||||
"turn": 5,
|
||||
"identity": {
|
||||
"tenant_slot": "slot-3",
|
||||
"subject": "usr_0123456789abcdef",
|
||||
"surface": "chat",
|
||||
"trust": "router"
|
||||
},
|
||||
"idempotency_key": "run_9f:decision:17"
|
||||
}
|
||||
|
||||
8
services/hermes/contracts/hux/examples/manifest.json
Normal file
8
services/hermes/contracts/hux/examples/manifest.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema": "hux.manifest.v1",
|
||||
"contract_version": "1.0.0",
|
||||
"data_layout_version": 1,
|
||||
"min_reader_contract_version": "1.0.0",
|
||||
"created_at": "2026-08-24T00:00:00Z",
|
||||
"updated_at": "2026-08-24T00:00:00Z"
|
||||
}
|
||||
@ -1 +1,65 @@
|
||||
{"schema":"hux.memory.v1","id":"mem_0001aaaa","owner":"usr_0123456789abcdef","scope":{"level":"project","scope_id":"prj_0001aaaa"},"kind":"preference","content":"Prefers terse answers with code first.","status":"proposed","approval_mode":"ask","sensitivity":"personal","topic":"general","ttl":{"policy":"decay","decay_days":180},"source":{"kind":"message","id":"msg-42"},"provenance":{"surface":"chat","actor":{"type":"assistant","id":"hermes"},"recorded_at":"2026-08-23T10:00:00Z","session_id":"sess-a1","conversation_id":"conv_0001abcd","run_id":"run_9f","route":{"requested":"thoughtful","resolved_target":"claude_sonnet_high","provider":"claude","effort":"high"},"build":{"commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","image_digest":"sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"}},"created_at":"2026-08-23T10:00:00Z","updated_at":"2026-08-23T10:00:00Z","audit":[{"at":"2026-08-23T10:00:00Z","action":"proposed","actor":{"type":"assistant","id":"hermes"}}]}
|
||||
{
|
||||
"schema": "hux.memory.v1",
|
||||
"id": "mem_0001aaaa",
|
||||
"owner": "usr_0123456789abcdef",
|
||||
"scope": {
|
||||
"level": "project",
|
||||
"scope_id": "prj_0001aaaa"
|
||||
},
|
||||
"kind": "preference",
|
||||
"content": "Prefers terse answers with code first.",
|
||||
"status": "proposed",
|
||||
"approval_mode": "ask",
|
||||
"sensitivity": "personal",
|
||||
"topic": "general",
|
||||
"ttl": {
|
||||
"policy": "decay",
|
||||
"decay_days": 180
|
||||
},
|
||||
"source": {
|
||||
"kind": "message",
|
||||
"id": "msg-42"
|
||||
},
|
||||
"provenance": {
|
||||
"surface": "chat",
|
||||
"actor": {
|
||||
"type": "assistant",
|
||||
"id": "hermes"
|
||||
},
|
||||
"recorded_at": "2026-08-23T10:00:00Z",
|
||||
"session_id": "sess-a1",
|
||||
"conversation_id": "conv_0001abcd",
|
||||
"run_id": "run_9f",
|
||||
"route": {
|
||||
"requested": "thoughtful",
|
||||
"resolved_target": "claude_sonnet_high",
|
||||
"provider": "claude",
|
||||
"effort": "high"
|
||||
},
|
||||
"build": {
|
||||
"commit": "d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06",
|
||||
"image_digest": "sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"
|
||||
}
|
||||
},
|
||||
"created_at": "2026-08-23T10:00:00Z",
|
||||
"updated_at": "2026-08-23T10:00:00Z",
|
||||
"audit": [
|
||||
{
|
||||
"at": "2026-08-23T10:00:00Z",
|
||||
"action": "proposed",
|
||||
"actor": {
|
||||
"type": "assistant",
|
||||
"id": "hermes"
|
||||
}
|
||||
}
|
||||
],
|
||||
"reason": "You asked for code-first answers twice in this project.",
|
||||
"retrievable": false,
|
||||
"revision": 1,
|
||||
"identity": {
|
||||
"tenant_slot": "slot-3",
|
||||
"subject": "usr_0123456789abcdef",
|
||||
"surface": "chat",
|
||||
"trust": "router"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1,31 @@
|
||||
{"schema":"hux.research_notebook.v1","id":"nb_0001aaaa","conversation_id":"conv_0001abcd","question":"Which supplier delivers fastest under budget?","status":"open","source_ids":["src_0001aaaa"],"passage_ids":["psg_0001aaaa"],"citation_ids":["cit_0001aaaa"],"assumptions":["Budget cap is unchanged at 12k"],"unresolved_questions":["Does the lead time include installation?"],"updated_at":"2026-08-23T10:00:01Z"}
|
||||
{
|
||||
"schema": "hux.research_notebook.v1",
|
||||
"id": "nb_0001aaaa",
|
||||
"conversation_id": "conv_0001abcd",
|
||||
"question": "Which supplier delivers fastest under budget?",
|
||||
"status": "open",
|
||||
"source_ids": [
|
||||
"src_0001aaaa"
|
||||
],
|
||||
"passage_ids": [
|
||||
"psg_0001aaaa"
|
||||
],
|
||||
"citation_ids": [
|
||||
"cit_0001aaaa"
|
||||
],
|
||||
"assumptions": [
|
||||
"Budget cap is unchanged at 12k"
|
||||
],
|
||||
"unresolved_questions": [
|
||||
"Does the lead time include installation?"
|
||||
],
|
||||
"updated_at": "2026-08-23T10:00:01Z",
|
||||
"notes": [
|
||||
{
|
||||
"at": "2026-08-23T10:00:00Z",
|
||||
"text": "Lead times exclude installation per supplier FAQ.",
|
||||
"source_id": "src_0001aaaa"
|
||||
}
|
||||
],
|
||||
"revision": 4
|
||||
}
|
||||
|
||||
@ -1 +1,13 @@
|
||||
{"schema":"hux.passage.v1","id":"psg_0001aaaa","source_id":"src_0001aaaa","text":"Standard base cabinets ship in 3-4 weeks.","hash":"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881","locator":{"selector":"#lead-times","char_start":120,"char_end":160}}
|
||||
{
|
||||
"schema": "hux.passage.v1",
|
||||
"id": "psg_0001aaaa",
|
||||
"source_id": "src_0001aaaa",
|
||||
"text": "Standard base cabinets ship in 3-4 weeks.",
|
||||
"hash": "sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881",
|
||||
"locator": {
|
||||
"selector": "#lead-times",
|
||||
"char_start": 120,
|
||||
"char_end": 160
|
||||
},
|
||||
"dedupe_key": "sha256:abababababababababababababababababababababababababababababababab"
|
||||
}
|
||||
|
||||
@ -1 +1,56 @@
|
||||
{"schema":"hux.policy.v1","id":"pol_0001aaaa","owner":"usr_0123456789abcdef","scope":{"level":"conversation","scope_id":"conv_0001abcd"},"autonomy":"safe","grants":[{"capability":"network","decision":"ask","expires_at":"2026-08-24T10:00:00Z","granted_by":{"type":"user","id":"usr_0123456789abcdef"}}],"budgets":{"tokens_per_run":200000,"tool_calls_per_run":40,"wall_clock_seconds":900},"provenance":{"surface":"chat","actor":{"type":"assistant","id":"hermes"},"recorded_at":"2026-08-23T10:00:00Z","session_id":"sess-a1","conversation_id":"conv_0001abcd","run_id":"run_9f","route":{"requested":"thoughtful","resolved_target":"claude_sonnet_high","provider":"claude","effort":"high"},"build":{"commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","image_digest":"sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"}},"updated_at":"2026-08-23T10:00:00Z"}
|
||||
{
|
||||
"schema": "hux.policy.v1",
|
||||
"id": "pol_0001aaaa",
|
||||
"owner": "usr_0123456789abcdef",
|
||||
"scope": {
|
||||
"level": "conversation",
|
||||
"scope_id": "conv_0001abcd"
|
||||
},
|
||||
"autonomy": "safe",
|
||||
"grants": [
|
||||
{
|
||||
"capability": "network",
|
||||
"decision": "ask",
|
||||
"expires_at": "2026-08-24T10:00:00Z",
|
||||
"granted_by": {
|
||||
"type": "user",
|
||||
"id": "usr_0123456789abcdef"
|
||||
}
|
||||
}
|
||||
],
|
||||
"budgets": {
|
||||
"tokens_per_run": 200000,
|
||||
"tool_calls_per_run": 40,
|
||||
"wall_clock_seconds": 900,
|
||||
"spend_units": 50,
|
||||
"subagents_per_run": 2,
|
||||
"scope": {
|
||||
"conversations": [
|
||||
"conv_0001abcd"
|
||||
]
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"surface": "chat",
|
||||
"actor": {
|
||||
"type": "assistant",
|
||||
"id": "hermes"
|
||||
},
|
||||
"recorded_at": "2026-08-23T10:00:00Z",
|
||||
"session_id": "sess-a1",
|
||||
"conversation_id": "conv_0001abcd",
|
||||
"run_id": "run_9f",
|
||||
"route": {
|
||||
"requested": "thoughtful",
|
||||
"resolved_target": "claude_sonnet_high",
|
||||
"provider": "claude",
|
||||
"effort": "high"
|
||||
},
|
||||
"build": {
|
||||
"commit": "d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06",
|
||||
"image_digest": "sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"
|
||||
}
|
||||
},
|
||||
"updated_at": "2026-08-23T10:00:00Z",
|
||||
"revision": 1
|
||||
}
|
||||
|
||||
@ -1 +1,17 @@
|
||||
{"schema":"hux.project.v1","id":"prj_0001aaaa","owner":"usr_0123456789abcdef","name":"Kitchen renovation","description":"Quotes, plans and supplier research.","tags":["home","2026"],"pinned":true,"archived":false,"default_mode":"research","created_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-23T10:00:00Z"}
|
||||
{
|
||||
"schema": "hux.project.v1",
|
||||
"id": "prj_0001aaaa",
|
||||
"owner": "usr_0123456789abcdef",
|
||||
"name": "Kitchen renovation",
|
||||
"description": "Quotes, plans and supplier research.",
|
||||
"tags": [
|
||||
"home",
|
||||
"2026"
|
||||
],
|
||||
"pinned": true,
|
||||
"archived": false,
|
||||
"default_mode": "research",
|
||||
"created_at": "2026-08-20T09:00:00Z",
|
||||
"updated_at": "2026-08-23T10:00:00Z",
|
||||
"revision": 3
|
||||
}
|
||||
|
||||
@ -1 +1,33 @@
|
||||
{"schema":"hux.source.v1","id":"src_0001aaaa","kind":"web","uri":"https://example.com/cabinets","title":"Cabinet pricing 2026","publisher":"Example Co","retrieved_at":"2026-08-23T09:59:00Z","classification":"primary","content_hash":"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881","provenance":{"surface":"chat","actor":{"type":"assistant","id":"hermes"},"recorded_at":"2026-08-23T10:00:00Z","session_id":"sess-a1","conversation_id":"conv_0001abcd","run_id":"run_9f","route":{"requested":"thoughtful","resolved_target":"claude_sonnet_high","provider":"claude","effort":"high"},"build":{"commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","image_digest":"sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"}}}
|
||||
{
|
||||
"schema": "hux.source.v1",
|
||||
"id": "src_0001aaaa",
|
||||
"kind": "web",
|
||||
"uri": "https://example.com/cabinets",
|
||||
"title": "Cabinet pricing 2026",
|
||||
"publisher": "Example Co",
|
||||
"retrieved_at": "2026-08-23T09:59:00Z",
|
||||
"classification": "primary",
|
||||
"content_hash": "sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881",
|
||||
"provenance": {
|
||||
"surface": "chat",
|
||||
"actor": {
|
||||
"type": "assistant",
|
||||
"id": "hermes"
|
||||
},
|
||||
"recorded_at": "2026-08-23T10:00:00Z",
|
||||
"session_id": "sess-a1",
|
||||
"conversation_id": "conv_0001abcd",
|
||||
"run_id": "run_9f",
|
||||
"route": {
|
||||
"requested": "thoughtful",
|
||||
"resolved_target": "claude_sonnet_high",
|
||||
"provider": "claude",
|
||||
"effort": "high"
|
||||
},
|
||||
"build": {
|
||||
"commit": "d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06",
|
||||
"image_digest": "sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"
|
||||
}
|
||||
},
|
||||
"dedupe_key": "sha256:abababababababababababababababababababababababababababababababab"
|
||||
}
|
||||
|
||||
@ -2,17 +2,202 @@
|
||||
"schema": "hux.flags.v1",
|
||||
"env_var": "HUX_FLAGS",
|
||||
"cards": [
|
||||
{"card": "HUX-01", "flag": "hux.activity_timeline", "title": "Activity timeline", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["event.schema.json"], "depends_on": ["HUX-11"], "default": false, "rollback": "disable flag; events keep accumulating on the tenant PVC and are not user-visible"},
|
||||
{"card": "HUX-02", "flag": "hux.memory_control", "title": "Memory control", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["memory.schema.json"], "depends_on": ["HUX-11", "HUX-10"], "default": false, "rollback": "disable flag; ledger stays read-only, upstream memory tool unchanged"},
|
||||
{"card": "HUX-03", "flag": "hux.projects", "title": "Projects and chats", "wave": "B", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["project.schema.json"], "depends_on": ["HUX-11"], "default": false, "rollback": "disable flag; upstream project list remains authoritative"},
|
||||
{"card": "HUX-04", "flag": "hux.artifacts", "title": "Artifact workspace", "wave": "B", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["artifact.schema.json"], "depends_on": ["HUX-11", "HUX-03"], "default": false, "rollback": "disable flag; artifacts remain downloadable files in the tenant workspace"},
|
||||
{"card": "HUX-05", "flag": "hux.autonomy", "title": "Autonomy controls", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["permission.schema.json"], "depends_on": ["HUX-11", "HUX-01"], "default": false, "rollback": "disable flag; gateway approval mode reverts to the tenant default"},
|
||||
{"card": "HUX-06", "flag": "hux.friendly_modes", "title": "Friendly modes", "wave": "B", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["mode.schema.json"], "depends_on": ["HUX-11"], "default": false, "rollback": "disable flag; routing-priority chips remain"},
|
||||
{"card": "HUX-07", "flag": "hux.multimodal", "title": "Multimodal", "wave": "C", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["artifact.schema.json", "event.schema.json"], "depends_on": ["HUX-04", "HUX-05"], "default": false, "rollback": "disable flag; upstream upload path remains"},
|
||||
{"card": "HUX-08", "flag": "hux.research", "title": "Research and citations", "wave": "B", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["citation.schema.json"], "depends_on": ["HUX-11", "HUX-06"], "default": false, "rollback": "disable flag; prose citations remain"},
|
||||
{"card": "HUX-09", "flag": "hux.onboarding", "title": "Onboarding", "wave": "C", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["suggestion.schema.json"], "depends_on": ["HUX-03", "HUX-06"], "default": false, "rollback": "disable flag; no suggestions shown"},
|
||||
{"card": "HUX-10", "flag": "hux.privacy", "title": "Privacy behaviour", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["privacy.schema.json"], "depends_on": ["HUX-11"], "default": false, "rollback": "disable flag; tenant isolation remains the privacy model"},
|
||||
{"card": "HUX-11", "flag": "hux.foundation", "title": "Shared foundation", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["common.schema.json"], "depends_on": [], "default": false, "rollback": "disable flag; /hux/v1 returns 404 from the router"},
|
||||
{"card": "HUX-12", "flag": "hux.release_followthrough", "title": "Deployment follow-through", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["release.schema.json"], "depends_on": [], "default": false, "rollback": "disable flag; release lane evidence archive remains the source of truth"}
|
||||
]
|
||||
{
|
||||
"card": "HUX-01",
|
||||
"flag": "hux.activity_timeline",
|
||||
"title": "Activity timeline",
|
||||
"wave": "A",
|
||||
"backend_owner": "claude",
|
||||
"frontend_owner": "codex",
|
||||
"contracts": [
|
||||
"event.schema.json"
|
||||
],
|
||||
"depends_on": [
|
||||
"HUX-11"
|
||||
],
|
||||
"default": false,
|
||||
"rollback": "disable flag; events keep accumulating on the tenant PVC and are not user-visible"
|
||||
},
|
||||
{
|
||||
"card": "HUX-02",
|
||||
"flag": "hux.memory_control",
|
||||
"title": "Memory control",
|
||||
"wave": "A",
|
||||
"backend_owner": "claude",
|
||||
"frontend_owner": "codex",
|
||||
"contracts": [
|
||||
"memory.schema.json"
|
||||
],
|
||||
"depends_on": [
|
||||
"HUX-11",
|
||||
"HUX-10"
|
||||
],
|
||||
"default": false,
|
||||
"rollback": "disable flag; ledger stays read-only, upstream memory tool unchanged"
|
||||
},
|
||||
{
|
||||
"card": "HUX-03",
|
||||
"flag": "hux.projects",
|
||||
"title": "Projects and chats",
|
||||
"wave": "B",
|
||||
"backend_owner": "claude",
|
||||
"frontend_owner": "codex",
|
||||
"contracts": [
|
||||
"project.schema.json"
|
||||
],
|
||||
"depends_on": [
|
||||
"HUX-11"
|
||||
],
|
||||
"default": false,
|
||||
"rollback": "disable flag; upstream project list remains authoritative"
|
||||
},
|
||||
{
|
||||
"card": "HUX-04",
|
||||
"flag": "hux.artifacts",
|
||||
"title": "Artifact workspace",
|
||||
"wave": "B",
|
||||
"backend_owner": "claude",
|
||||
"frontend_owner": "codex",
|
||||
"contracts": [
|
||||
"artifact.schema.json"
|
||||
],
|
||||
"depends_on": [
|
||||
"HUX-11",
|
||||
"HUX-03"
|
||||
],
|
||||
"default": false,
|
||||
"rollback": "disable flag; artifacts remain downloadable files in the tenant workspace"
|
||||
},
|
||||
{
|
||||
"card": "HUX-05",
|
||||
"flag": "hux.autonomy",
|
||||
"title": "Autonomy controls",
|
||||
"wave": "A",
|
||||
"backend_owner": "claude",
|
||||
"frontend_owner": "codex",
|
||||
"contracts": [
|
||||
"permission.schema.json"
|
||||
],
|
||||
"depends_on": [
|
||||
"HUX-11",
|
||||
"HUX-01"
|
||||
],
|
||||
"default": false,
|
||||
"rollback": "disable flag; gateway approval mode reverts to the tenant default"
|
||||
},
|
||||
{
|
||||
"card": "HUX-06",
|
||||
"flag": "hux.friendly_modes",
|
||||
"title": "Friendly modes",
|
||||
"wave": "B",
|
||||
"backend_owner": "claude",
|
||||
"frontend_owner": "codex",
|
||||
"contracts": [
|
||||
"mode.schema.json"
|
||||
],
|
||||
"depends_on": [
|
||||
"HUX-11"
|
||||
],
|
||||
"default": false,
|
||||
"rollback": "disable flag; routing-priority chips remain"
|
||||
},
|
||||
{
|
||||
"card": "HUX-07",
|
||||
"flag": "hux.multimodal",
|
||||
"title": "Multimodal",
|
||||
"wave": "C",
|
||||
"backend_owner": "claude",
|
||||
"frontend_owner": "codex",
|
||||
"contracts": [
|
||||
"artifact.schema.json",
|
||||
"event.schema.json"
|
||||
],
|
||||
"depends_on": [
|
||||
"HUX-04",
|
||||
"HUX-05"
|
||||
],
|
||||
"default": false,
|
||||
"rollback": "disable flag; upstream upload path remains"
|
||||
},
|
||||
{
|
||||
"card": "HUX-08",
|
||||
"flag": "hux.research",
|
||||
"title": "Research and citations",
|
||||
"wave": "B",
|
||||
"backend_owner": "claude",
|
||||
"frontend_owner": "codex",
|
||||
"contracts": [
|
||||
"citation.schema.json"
|
||||
],
|
||||
"depends_on": [
|
||||
"HUX-11",
|
||||
"HUX-06"
|
||||
],
|
||||
"default": false,
|
||||
"rollback": "disable flag; prose citations remain"
|
||||
},
|
||||
{
|
||||
"card": "HUX-09",
|
||||
"flag": "hux.onboarding",
|
||||
"title": "Onboarding",
|
||||
"wave": "C",
|
||||
"backend_owner": "claude",
|
||||
"frontend_owner": "codex",
|
||||
"contracts": [
|
||||
"suggestion.schema.json"
|
||||
],
|
||||
"depends_on": [
|
||||
"HUX-03",
|
||||
"HUX-06"
|
||||
],
|
||||
"default": false,
|
||||
"rollback": "disable flag; no suggestions shown"
|
||||
},
|
||||
{
|
||||
"card": "HUX-10",
|
||||
"flag": "hux.privacy",
|
||||
"title": "Privacy behaviour",
|
||||
"wave": "A",
|
||||
"backend_owner": "claude",
|
||||
"frontend_owner": "codex",
|
||||
"contracts": [
|
||||
"privacy.schema.json"
|
||||
],
|
||||
"depends_on": [
|
||||
"HUX-11"
|
||||
],
|
||||
"default": false,
|
||||
"rollback": "disable flag; tenant isolation remains the privacy model"
|
||||
},
|
||||
{
|
||||
"card": "HUX-11",
|
||||
"flag": "hux.foundation",
|
||||
"title": "Shared foundation",
|
||||
"wave": "A",
|
||||
"backend_owner": "claude",
|
||||
"frontend_owner": "codex",
|
||||
"contracts": [
|
||||
"common.schema.json",
|
||||
"identity.schema.json"
|
||||
],
|
||||
"depends_on": [],
|
||||
"default": false,
|
||||
"rollback": "disable flag; /hux/v1 returns 404 from the router"
|
||||
},
|
||||
{
|
||||
"card": "HUX-12",
|
||||
"flag": "hux.release_followthrough",
|
||||
"title": "Deployment follow-through",
|
||||
"wave": "A",
|
||||
"backend_owner": "claude",
|
||||
"frontend_owner": "codex",
|
||||
"contracts": [
|
||||
"release.schema.json"
|
||||
],
|
||||
"depends_on": [],
|
||||
"default": false,
|
||||
"rollback": "disable flag; release lane evidence archive remains the source of truth"
|
||||
}
|
||||
],
|
||||
"contract_version": "1.1.0"
|
||||
}
|
||||
|
||||
158
services/hermes/contracts/hux/identity.schema.json
Normal file
158
services/hermes/contracts/hux/identity.schema.json
Normal file
@ -0,0 +1,158 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://hermes.bstein.dev/contracts/hux/v1/identity.schema.json",
|
||||
"title": "HUX identity, capability negotiation and data manifest",
|
||||
"description": "HUX-11 foundation records. capabilities is the first call every client makes: it learns the contract version, which cards are on for this tenant (a card is on only when its whole dependency chain is on), and the server build. manifest is stored on the tenant PVC and tells a rolled-back reader whether it can read the data.",
|
||||
"$defs": {
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema",
|
||||
"contract_version",
|
||||
"identity",
|
||||
"cards",
|
||||
"server"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "hux.capabilities.v1"
|
||||
},
|
||||
"contract_version": {
|
||||
"$ref": "common.schema.json#/$defs/contract_version"
|
||||
},
|
||||
"identity": {
|
||||
"$ref": "common.schema.json#/$defs/identity"
|
||||
},
|
||||
"cards": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"card",
|
||||
"flag",
|
||||
"enabled",
|
||||
"routes"
|
||||
],
|
||||
"properties": {
|
||||
"card": {
|
||||
"type": "string",
|
||||
"pattern": "^HUX-[0-9]{2}$"
|
||||
},
|
||||
"flag": {
|
||||
"type": "string",
|
||||
"pattern": "^hux\\.[a-z_]+$"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"routes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^/hux/v1/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"$ref": "common.schema.json#/$defs/build"
|
||||
}
|
||||
}
|
||||
},
|
||||
"manifest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema",
|
||||
"contract_version",
|
||||
"data_layout_version",
|
||||
"min_reader_contract_version",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "hux.manifest.v1"
|
||||
},
|
||||
"contract_version": {
|
||||
"$ref": "common.schema.json#/$defs/contract_version"
|
||||
},
|
||||
"data_layout_version": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"min_reader_contract_version": {
|
||||
"$ref": "common.schema.json#/$defs/contract_version"
|
||||
},
|
||||
"created_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"updated_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema",
|
||||
"status",
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "hux.error.v1"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"minimum": 400,
|
||||
"maximum": 599
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"not_found",
|
||||
"conflict",
|
||||
"flag_off",
|
||||
"invalid",
|
||||
"too_large",
|
||||
"rate_limited",
|
||||
"approval_required",
|
||||
"budget_exhausted",
|
||||
"unprocessable"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"maxLength": 280
|
||||
},
|
||||
"details": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 280
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/capabilities"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/manifest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/error"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -5,54 +5,201 @@
|
||||
"description": "A single remembered item (HUX-02). Memory is a ledger: entries are appended, approved, expired or forgotten, never edited in place. Forgotten entries keep their audit trail but drop content.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "id", "owner", "scope", "kind", "content", "status", "approval_mode", "sensitivity", "ttl", "source", "provenance", "created_at", "updated_at", "audit"],
|
||||
"required": [
|
||||
"schema",
|
||||
"id",
|
||||
"owner",
|
||||
"scope",
|
||||
"kind",
|
||||
"content",
|
||||
"status",
|
||||
"approval_mode",
|
||||
"sensitivity",
|
||||
"ttl",
|
||||
"source",
|
||||
"provenance",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"audit",
|
||||
"reason",
|
||||
"retrievable",
|
||||
"revision"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "hux.memory.v1"},
|
||||
"id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
|
||||
"schema": {
|
||||
"const": "hux.memory.v1"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"owner": {
|
||||
"$ref": "common.schema.json#/$defs/user_ref"
|
||||
},
|
||||
"scope": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["level"],
|
||||
"required": [
|
||||
"level"
|
||||
],
|
||||
"properties": {
|
||||
"level": {"type": "string", "enum": ["global", "project", "conversation"]},
|
||||
"scope_id": {"$ref": "common.schema.json#/$defs/id"}
|
||||
"level": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"global",
|
||||
"project",
|
||||
"conversation"
|
||||
]
|
||||
},
|
||||
"scope_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"kind": {"type": "string", "enum": ["preference", "fact", "instruction", "context"]},
|
||||
"content": {"type": "string", "maxLength": 2000},
|
||||
"status": {"type": "string", "enum": ["proposed", "active", "rejected", "expired", "forgotten"]},
|
||||
"approval_mode": {"type": "string", "enum": ["automatic", "ask"]},
|
||||
"sensitivity": {"$ref": "common.schema.json#/$defs/sensitivity"},
|
||||
"topic": {"type": "string", "enum": ["general", "health", "finance", "legal", "relationships", "credentials", "minors", "location", "biometric"]},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"preference",
|
||||
"fact",
|
||||
"instruction",
|
||||
"context"
|
||||
]
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"maxLength": 2000
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"proposed",
|
||||
"active",
|
||||
"rejected",
|
||||
"expired",
|
||||
"forgotten",
|
||||
"no_store"
|
||||
]
|
||||
},
|
||||
"approval_mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"automatic",
|
||||
"ask",
|
||||
"no_store"
|
||||
]
|
||||
},
|
||||
"sensitivity": {
|
||||
"$ref": "common.schema.json#/$defs/sensitivity"
|
||||
},
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"general",
|
||||
"health",
|
||||
"finance",
|
||||
"legal",
|
||||
"relationships",
|
||||
"credentials",
|
||||
"minors",
|
||||
"location",
|
||||
"biometric"
|
||||
]
|
||||
},
|
||||
"ttl": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["policy"],
|
||||
"required": [
|
||||
"policy"
|
||||
],
|
||||
"properties": {
|
||||
"policy": {"type": "string", "enum": ["never", "expires_at", "decay"]},
|
||||
"expires_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"decay_days": {"type": "integer", "minimum": 1, "maximum": 3650}
|
||||
"policy": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"never",
|
||||
"expires_at",
|
||||
"decay"
|
||||
]
|
||||
},
|
||||
"expires_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"decay_days": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 3650
|
||||
}
|
||||
}
|
||||
},
|
||||
"source": {"$ref": "common.schema.json#/$defs/evidence_ref"},
|
||||
"provenance": {"$ref": "common.schema.json#/$defs/provenance"},
|
||||
"created_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"source": {
|
||||
"$ref": "common.schema.json#/$defs/evidence_ref"
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "common.schema.json#/$defs/provenance"
|
||||
},
|
||||
"created_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"updated_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"audit": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["at", "action", "actor"],
|
||||
"required": [
|
||||
"at",
|
||||
"action",
|
||||
"actor"
|
||||
],
|
||||
"properties": {
|
||||
"at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"action": {"type": "string", "enum": ["proposed", "approved", "rejected", "expired", "forgotten", "exported"]},
|
||||
"actor": {"$ref": "common.schema.json#/$defs/actor"},
|
||||
"note": {"type": "string", "maxLength": 200}
|
||||
"at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"proposed",
|
||||
"approved",
|
||||
"rejected",
|
||||
"expired",
|
||||
"forgotten",
|
||||
"exported",
|
||||
"no_store",
|
||||
"superseded",
|
||||
"retrieval_removed",
|
||||
"edited"
|
||||
]
|
||||
},
|
||||
"actor": {
|
||||
"$ref": "common.schema.json#/$defs/actor"
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Why the assistant proposed remembering this, in the user's terms.",
|
||||
"minLength": 1,
|
||||
"maxLength": 280
|
||||
},
|
||||
"supersedes": {
|
||||
"$ref": "common.schema.json#/$defs/id",
|
||||
"description": "An edit is a new entry that supersedes the old one; the old entry moves to forgotten."
|
||||
},
|
||||
"retrievable": {
|
||||
"type": "boolean",
|
||||
"description": "False removes the entry from retrieval without deleting audit. Forgotten, rejected, expired and no_store entries are never retrievable."
|
||||
},
|
||||
"revision": {
|
||||
"$ref": "common.schema.json#/$defs/revision"
|
||||
},
|
||||
"identity": {
|
||||
"$ref": "common.schema.json#/$defs/identity"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,39 +6,105 @@
|
||||
"$defs": {
|
||||
"capability": {
|
||||
"type": "string",
|
||||
"enum": ["read_files", "write_files", "shell", "network", "web_search", "send_message", "memory_write", "artifact_write", "spend_tokens", "delegate", "deploy"]
|
||||
"enum": [
|
||||
"read_files",
|
||||
"write_files",
|
||||
"shell",
|
||||
"network",
|
||||
"web_search",
|
||||
"send_message",
|
||||
"memory_write",
|
||||
"artifact_write",
|
||||
"spend_tokens",
|
||||
"delegate",
|
||||
"deploy",
|
||||
"external_side_effect"
|
||||
]
|
||||
},
|
||||
"decision": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"allow",
|
||||
"ask",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"decision": {"type": "string", "enum": ["allow", "ask", "deny"]},
|
||||
"policy": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "id", "owner", "scope", "autonomy", "grants", "budgets", "provenance", "updated_at"],
|
||||
"required": [
|
||||
"schema",
|
||||
"id",
|
||||
"owner",
|
||||
"scope",
|
||||
"autonomy",
|
||||
"grants",
|
||||
"budgets",
|
||||
"provenance",
|
||||
"updated_at",
|
||||
"revision"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "hux.policy.v1"},
|
||||
"id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
|
||||
"schema": {
|
||||
"const": "hux.policy.v1"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"owner": {
|
||||
"$ref": "common.schema.json#/$defs/user_ref"
|
||||
},
|
||||
"scope": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["level"],
|
||||
"required": [
|
||||
"level"
|
||||
],
|
||||
"properties": {
|
||||
"level": {"type": "string", "enum": ["global", "project", "conversation"]},
|
||||
"scope_id": {"$ref": "common.schema.json#/$defs/id"}
|
||||
"level": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"global",
|
||||
"project",
|
||||
"conversation"
|
||||
]
|
||||
},
|
||||
"scope_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"autonomy": {"type": "string", "enum": ["ask_first", "safe", "autonomous"]},
|
||||
"autonomy": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask_first",
|
||||
"safe",
|
||||
"autonomous"
|
||||
]
|
||||
},
|
||||
"grants": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["capability", "decision"],
|
||||
"required": [
|
||||
"capability",
|
||||
"decision"
|
||||
],
|
||||
"properties": {
|
||||
"capability": {"$ref": "#/$defs/capability"},
|
||||
"decision": {"$ref": "#/$defs/decision"},
|
||||
"expires_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"granted_by": {"$ref": "common.schema.json#/$defs/actor"}
|
||||
"capability": {
|
||||
"$ref": "#/$defs/capability"
|
||||
},
|
||||
"decision": {
|
||||
"$ref": "#/$defs/decision"
|
||||
},
|
||||
"expires_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"granted_by": {
|
||||
"$ref": "common.schema.json#/$defs/actor"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -46,85 +112,337 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tokens_per_run": {"type": "integer", "minimum": 0},
|
||||
"tool_calls_per_run": {"type": "integer", "minimum": 0},
|
||||
"wall_clock_seconds": {"type": "integer", "minimum": 0},
|
||||
"delegations_per_run": {"type": "integer", "minimum": 0}
|
||||
"tokens_per_run": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"tool_calls_per_run": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"wall_clock_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"delegations_per_run": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"spend_units": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"subagents_per_run": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"scope": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"conversations": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"provenance": {"$ref": "common.schema.json#/$defs/provenance"},
|
||||
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"}
|
||||
"provenance": {
|
||||
"$ref": "common.schema.json#/$defs/provenance"
|
||||
},
|
||||
"updated_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"revision": {
|
||||
"$ref": "common.schema.json#/$defs/revision"
|
||||
}
|
||||
}
|
||||
},
|
||||
"approval": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "id", "run_id", "conversation_id", "capability", "request", "status", "requested_at", "expires_at"],
|
||||
"required": [
|
||||
"schema",
|
||||
"id",
|
||||
"run_id",
|
||||
"conversation_id",
|
||||
"capability",
|
||||
"request",
|
||||
"status",
|
||||
"requested_at",
|
||||
"expires_at"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "hux.approval.v1"},
|
||||
"id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"run_id": {"type": "string", "minLength": 1, "maxLength": 120},
|
||||
"conversation_id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"capability": {"$ref": "#/$defs/capability"},
|
||||
"schema": {
|
||||
"const": "hux.approval.v1"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"run_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120
|
||||
},
|
||||
"conversation_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"capability": {
|
||||
"$ref": "#/$defs/capability"
|
||||
},
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["summary", "risk"],
|
||||
"required": [
|
||||
"summary",
|
||||
"risk",
|
||||
"external"
|
||||
],
|
||||
"properties": {
|
||||
"summary": {"type": "string", "minLength": 1, "maxLength": 280},
|
||||
"detail": {"type": "string", "maxLength": 4000},
|
||||
"risk": {"type": "string", "enum": ["low", "medium", "high"]},
|
||||
"evidence": {"type": "array", "maxItems": 16, "items": {"$ref": "common.schema.json#/$defs/evidence_ref"}}
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 280
|
||||
},
|
||||
"detail": {
|
||||
"type": "string",
|
||||
"maxLength": 4000
|
||||
},
|
||||
"risk": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"maxItems": 16,
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/evidence_ref"
|
||||
}
|
||||
},
|
||||
"external": {
|
||||
"type": "boolean",
|
||||
"description": "True when the action leaves the tenant (message, network call, deploy). External side effects always need an approval record regardless of autonomy level."
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": {"type": "string", "enum": ["pending", "approved", "denied", "expired", "cancelled"]},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
"approved",
|
||||
"denied",
|
||||
"expired",
|
||||
"cancelled"
|
||||
]
|
||||
},
|
||||
"decision": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["choice", "by", "at"],
|
||||
"required": [
|
||||
"choice",
|
||||
"by",
|
||||
"at"
|
||||
],
|
||||
"properties": {
|
||||
"choice": {"type": "string", "enum": ["once", "session", "always", "deny"]},
|
||||
"by": {"$ref": "common.schema.json#/$defs/actor"},
|
||||
"at": {"$ref": "common.schema.json#/$defs/timestamp"}
|
||||
"choice": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"once",
|
||||
"session",
|
||||
"always",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"by": {
|
||||
"$ref": "common.schema.json#/$defs/actor"
|
||||
},
|
||||
"at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
}
|
||||
}
|
||||
},
|
||||
"requested_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"expires_at": {"$ref": "common.schema.json#/$defs/timestamp"}
|
||||
"requested_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"expires_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"idempotency_key": {
|
||||
"$ref": "common.schema.json#/$defs/idempotency_key"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cancellation_receipt": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "id", "run_id", "requested_by", "requested_at", "outcome", "side_effects"],
|
||||
"required": [
|
||||
"schema",
|
||||
"id",
|
||||
"run_id",
|
||||
"requested_by",
|
||||
"requested_at",
|
||||
"outcome",
|
||||
"side_effects"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "hux.cancel_receipt.v1"},
|
||||
"id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"run_id": {"type": "string", "minLength": 1, "maxLength": 120},
|
||||
"requested_by": {"$ref": "common.schema.json#/$defs/actor"},
|
||||
"requested_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"acknowledged_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"completed_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"outcome": {"type": "string", "enum": ["cancelled", "already_complete", "failed_to_cancel"]},
|
||||
"schema": {
|
||||
"const": "hux.cancel_receipt.v1"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"run_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120
|
||||
},
|
||||
"requested_by": {
|
||||
"$ref": "common.schema.json#/$defs/actor"
|
||||
},
|
||||
"requested_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"acknowledged_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"completed_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"cancelled",
|
||||
"already_complete",
|
||||
"failed_to_cancel"
|
||||
]
|
||||
},
|
||||
"side_effects": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["description", "reverted"],
|
||||
"required": [
|
||||
"description",
|
||||
"reverted"
|
||||
],
|
||||
"properties": {
|
||||
"description": {"type": "string", "minLength": 1, "maxLength": 280},
|
||||
"reverted": {"type": "boolean"},
|
||||
"evidence": {"$ref": "common.schema.json#/$defs/evidence_ref"}
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 280
|
||||
},
|
||||
"reverted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"evidence": {
|
||||
"$ref": "common.schema.json#/$defs/evidence_ref"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"conversation_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"budget_state": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema",
|
||||
"run_id",
|
||||
"spent",
|
||||
"limits",
|
||||
"exhausted"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "hux.budget_state.v1"
|
||||
},
|
||||
"run_id": {
|
||||
"type": "string",
|
||||
"maxLength": 120
|
||||
},
|
||||
"spent": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tokens": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"tool_calls": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"wall_clock_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"delegations": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"spend_units": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"subagents": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"limits": {
|
||||
"$ref": "#/$defs/policy/properties/budgets"
|
||||
},
|
||||
"exhausted": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"tokens_per_run",
|
||||
"tool_calls_per_run",
|
||||
"wall_clock_seconds",
|
||||
"delegations_per_run",
|
||||
"spend_units",
|
||||
"subagents_per_run"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/policy"},
|
||||
{"$ref": "#/$defs/approval"},
|
||||
{"$ref": "#/$defs/cancellation_receipt"}
|
||||
{
|
||||
"$ref": "#/$defs/policy"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/approval"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/cancellation_receipt"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/budget_state"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -7,63 +7,193 @@
|
||||
"project": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "id", "owner", "name", "tags", "pinned", "archived", "created_at", "updated_at"],
|
||||
"required": [
|
||||
"schema",
|
||||
"id",
|
||||
"owner",
|
||||
"name",
|
||||
"tags",
|
||||
"pinned",
|
||||
"archived",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"revision"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "hux.project.v1"},
|
||||
"id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 120},
|
||||
"description": {"type": "string", "maxLength": 2000},
|
||||
"tags": {"$ref": "common.schema.json#/$defs/tags"},
|
||||
"pinned": {"type": "boolean"},
|
||||
"archived": {"type": "boolean"},
|
||||
"default_mode": {"type": "string", "enum": ["fast", "thoughtful", "research", "create", "private"]},
|
||||
"memory_scope_id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"created_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"}
|
||||
"schema": {
|
||||
"const": "hux.project.v1"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"owner": {
|
||||
"$ref": "common.schema.json#/$defs/user_ref"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 2000
|
||||
},
|
||||
"tags": {
|
||||
"$ref": "common.schema.json#/$defs/tags"
|
||||
},
|
||||
"pinned": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"archived": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"default_mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"fast",
|
||||
"thoughtful",
|
||||
"research",
|
||||
"create",
|
||||
"private"
|
||||
]
|
||||
},
|
||||
"memory_scope_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"created_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"updated_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"revision": {
|
||||
"$ref": "common.schema.json#/$defs/revision"
|
||||
}
|
||||
}
|
||||
},
|
||||
"conversation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "id", "owner", "title", "tags", "pinned", "archived", "artifact_ids", "created_at", "updated_at"],
|
||||
"required": [
|
||||
"schema",
|
||||
"id",
|
||||
"owner",
|
||||
"title",
|
||||
"tags",
|
||||
"pinned",
|
||||
"archived",
|
||||
"artifact_ids",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"revision"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "hux.conversation.v1"},
|
||||
"id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
|
||||
"project_id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"title": {"type": "string", "minLength": 1, "maxLength": 200},
|
||||
"tags": {"$ref": "common.schema.json#/$defs/tags"},
|
||||
"pinned": {"type": "boolean"},
|
||||
"archived": {"type": "boolean"},
|
||||
"mode": {"type": "string", "enum": ["fast", "thoughtful", "research", "create", "private"]},
|
||||
"schema": {
|
||||
"const": "hux.conversation.v1"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"owner": {
|
||||
"$ref": "common.schema.json#/$defs/user_ref"
|
||||
},
|
||||
"project_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"tags": {
|
||||
"$ref": "common.schema.json#/$defs/tags"
|
||||
},
|
||||
"pinned": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"archived": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"fast",
|
||||
"thoughtful",
|
||||
"research",
|
||||
"create",
|
||||
"private"
|
||||
]
|
||||
},
|
||||
"branch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["parent_conversation_id", "branch_point_message_id"],
|
||||
"required": [
|
||||
"parent_conversation_id",
|
||||
"branch_point_message_id"
|
||||
],
|
||||
"properties": {
|
||||
"parent_conversation_id": {"$ref": "common.schema.json#/$defs/id"},
|
||||
"branch_point_message_id": {"type": "string", "minLength": 1, "maxLength": 120}
|
||||
"parent_conversation_id": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
},
|
||||
"branch_point_message_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120
|
||||
}
|
||||
}
|
||||
},
|
||||
"artifact_ids": {"type": "array", "maxItems": 500, "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/id"}},
|
||||
"last_message_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"created_at": {"$ref": "common.schema.json#/$defs/timestamp"},
|
||||
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"}
|
||||
"artifact_ids": {
|
||||
"type": "array",
|
||||
"maxItems": 500,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "common.schema.json#/$defs/id"
|
||||
}
|
||||
},
|
||||
"last_message_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"created_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"updated_at": {
|
||||
"$ref": "common.schema.json#/$defs/timestamp"
|
||||
},
|
||||
"revision": {
|
||||
"$ref": "common.schema.json#/$defs/revision"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search_index": {
|
||||
"type": "object",
|
||||
"description": "Fields the tenant indexes; the UI may not assume anything else is searchable.",
|
||||
"additionalProperties": false,
|
||||
"required": ["conversation"],
|
||||
"required": [
|
||||
"conversation"
|
||||
],
|
||||
"properties": {
|
||||
"conversation": {"type": "array", "items": {"type": "string", "enum": ["title", "tags", "message_text", "artifact_titles", "project_name"]}}
|
||||
"conversation": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"title",
|
||||
"tags",
|
||||
"message_text",
|
||||
"artifact_titles",
|
||||
"project_name"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/project"},
|
||||
{"$ref": "#/$defs/conversation"}
|
||||
{
|
||||
"$ref": "#/$defs/project"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/conversation"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
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
|
||||
365
testing/tests/test_hermes_hux_contract_foundation.py
Normal file
365
testing/tests/test_hermes_hux_contract_foundation.py
Normal file
@ -0,0 +1,365 @@
|
||||
"""HUX-11 foundation: identity, flags, tenant store, audit and the HTTP pipeline.
|
||||
|
||||
Security obligations exercised here: identity comes only from trusted headers
|
||||
(relay/worker keys compared in constant time), every request lands in a
|
||||
tenant-scoped directory, disabled cards are indistinguishable from unknown
|
||||
routes, every read and denial leaves an audit outcome, and revisions guard
|
||||
concurrent writers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from http.client import HTTPConnection
|
||||
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, flags, identity, store # noqa: E402
|
||||
from hux.http import Router, Response, page, serve # 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(**overrides) -> identity.Identity:
|
||||
base = {"tenant_slot": "slot-3", "subject": "usr_0123456789abcdef", "surface": "chat", "trust": "router"}
|
||||
return identity.Identity(**{**base, **overrides})
|
||||
|
||||
|
||||
# --- identity -------------------------------------------------------------------
|
||||
|
||||
def test_identity_from_router_headers():
|
||||
who = identity.resolve(HEADERS, {})
|
||||
assert who == ident()
|
||||
assert contracts.validate("common.schema.json", who.record(), SCHEMAS, "/$defs/identity") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
{"X-Hermes-Tenant-Identity": ""}, {"X-Hermes-Tenant-Identity": "slot-x"}, {"X-Hux-Subject": "brad@bstein.dev"},
|
||||
{"X-Hux-Subject": ""}, {"X-Hux-Surface": "admin"}, {"X-Hux-Trust": "god"}, {"X-Hux-Surface": "worker"},
|
||||
])
|
||||
def test_identity_rejects_bad_headers(bad):
|
||||
with pytest.raises(errors.Unauthorized):
|
||||
identity.resolve({**HEADERS, **bad}, {})
|
||||
|
||||
|
||||
def test_relay_and_worker_need_their_keys():
|
||||
relay = {**HEADERS, "X-Hux-Trust": "relay", "X-Hux-Surface": "telegram"}
|
||||
with pytest.raises(errors.Unauthorized):
|
||||
identity.resolve(relay, {})
|
||||
with pytest.raises(errors.Unauthorized):
|
||||
identity.resolve({**relay, "X-Hux-Relay-Key": "nope"}, {"HUX_RELAY_KEY": "secret"})
|
||||
assert identity.resolve({**relay, "X-Hux-Relay-Key": "secret"}, {"HUX_RELAY_KEY": "secret"}).trust == "relay"
|
||||
worker = {**HEADERS, "X-Hux-Trust": "worker", "X-Hux-Surface": "worker", "X-Hux-Relay-Key": "wk"}
|
||||
assert identity.resolve(worker, {"HUX_WORKER_KEY": "wk"}).surface == "worker"
|
||||
assert identity.resolve(HEADERS).trust == "router"
|
||||
|
||||
|
||||
def test_slot_must_match_the_pod_it_reaches():
|
||||
with pytest.raises(errors.Unauthorized):
|
||||
identity.resolve(HEADERS, {"HUX_TENANT_SLOT": "slot-4"})
|
||||
assert identity.resolve(HEADERS, {"HUX_TENANT_SLOT": "slot-3"}).tenant_slot == "slot-3"
|
||||
|
||||
|
||||
def test_server_refuses_non_loopback_bind():
|
||||
from hux import server
|
||||
assert server.bind_address({}) == "127.0.0.1"
|
||||
with pytest.raises(SystemExit):
|
||||
server.bind_address({"HUX_BIND": "0.0.0.0"})
|
||||
|
||||
|
||||
# --- flags ---------------------------------------------------------------------
|
||||
|
||||
def test_flags_fail_closed_and_capabilities_validate():
|
||||
off = flags.Flags({})
|
||||
assert not off.enabled("HUX-11")
|
||||
with pytest.raises(errors.FlagOff):
|
||||
off.require("HUX-01")
|
||||
partial = flags.Flags({"HUX_FLAGS": "hux.activity_timeline"})
|
||||
assert not partial.enabled("HUX-01")
|
||||
on = flags.Flags({"HUX_FLAGS": ALL_ON})
|
||||
record = on.capabilities(ident(), {"commit": "d3cbeb06" * 5, "image_digest": "sha256:" + "4a" * 32, "junk": "x"})
|
||||
assert contracts.validate_record(record, SCHEMAS) == []
|
||||
assert all(card["enabled"] for card in record["cards"])
|
||||
assert {card["card"] for card in record["cards"]} == {f"HUX-{n:02d}" for n in range(1, 13)}
|
||||
assert not on.enabled("HUX-99")
|
||||
assert flags.build_from_environ({"HUX_BUILD_COMMIT": "abc"}) == {"commit": "abc", "image_digest": ""}
|
||||
assert "commit" in flags.build_from_environ()
|
||||
|
||||
|
||||
def test_every_declared_route_belongs_to_exactly_one_card():
|
||||
seen: dict[str, str] = {}
|
||||
for card, routes in flags.CARD_ROUTES.items():
|
||||
for route in routes:
|
||||
assert route.startswith("/hux/v1/")
|
||||
assert route not in seen, f"{route} owned by {seen[route]} and {card}"
|
||||
seen[route] = card
|
||||
|
||||
|
||||
# --- store ---------------------------------------------------------------------
|
||||
|
||||
def test_store_paths_are_tenant_scoped_and_ids_validated(tmp_path):
|
||||
a = store.TenantStore(tmp_path, ident())
|
||||
b = store.TenantStore(tmp_path, ident(subject="usr_fedcba9876543210"))
|
||||
assert a.root != b.root and a.root.parent == b.root.parent
|
||||
a.put("things", {"id": "thg_0001", "v": 1})
|
||||
assert not b.exists("things", "thg_0001")
|
||||
with pytest.raises(errors.Invalid):
|
||||
a.get("things", "../../etc/passwd")
|
||||
with pytest.raises(errors.Invalid):
|
||||
a.put("things", {"id": "no-prefix"})
|
||||
with pytest.raises(errors.Invalid):
|
||||
a.append("ledger", "../x", {})
|
||||
with pytest.raises(errors.NotFound):
|
||||
a.get("things", "thg_9999")
|
||||
assert store.ID_RE.match(store.new_id("evt"))
|
||||
assert store.now_iso().endswith("Z")
|
||||
|
||||
|
||||
def test_store_revisions_and_conflicts(tmp_path):
|
||||
s = store.TenantStore(tmp_path, ident())
|
||||
first = s.put("docs", {"id": "doc_0001", "n": 1})
|
||||
assert first["revision"] == 1
|
||||
second = s.put("docs", {"id": "doc_0001", "n": 2}, expected_revision=1)
|
||||
assert second["revision"] == 2
|
||||
with pytest.raises(errors.Conflict):
|
||||
s.put("docs", {"id": "doc_0001", "n": 3}, expected_revision=1)
|
||||
with pytest.raises(errors.Conflict):
|
||||
s.put("docs", {"id": "doc_0002", "n": 1}, expected_revision=4)
|
||||
assert s.put("docs", {"id": "doc_0003"}, expected_revision=0)["revision"] == 1
|
||||
assert s.count("docs") == 2
|
||||
assert [d["id"] for d in s.scan("docs")] == ["doc_0001", "doc_0003"]
|
||||
assert list(s.scan("nothing")) == []
|
||||
s.delete("docs", "doc_0003")
|
||||
s.delete("docs", "doc_0003")
|
||||
assert s.count("docs") == 1
|
||||
|
||||
|
||||
def test_store_bounds(tmp_path, monkeypatch):
|
||||
s = store.TenantStore(tmp_path, ident())
|
||||
with pytest.raises(errors.TooLarge):
|
||||
s.put("docs", {"id": "doc_0001", "blob": "x" * store.MAX_RECORD_BYTES})
|
||||
with pytest.raises(errors.TooLarge):
|
||||
s.append("ledger", "big", {"blob": "x" * store.MAX_RECORD_BYTES})
|
||||
monkeypatch.setattr(store, "MAX_FAMILY_RECORDS", 1)
|
||||
s.put("docs", {"id": "doc_0001"})
|
||||
with pytest.raises(errors.TooLarge):
|
||||
s.put("docs", {"id": "doc_0002"})
|
||||
s.put("docs", {"id": "doc_0001", "again": True})
|
||||
|
||||
|
||||
def test_store_ledgers_survive_torn_writes(tmp_path):
|
||||
s = store.TenantStore(tmp_path, ident())
|
||||
s.append("ledger", "conv_1", {"seq": 1})
|
||||
s.append("ledger", "conv_1", {"seq": 2})
|
||||
path = s.root / "ledger" / "conv_1.jsonl"
|
||||
with open(path, "ab") as handle:
|
||||
handle.write(b'{"seq": 3, "tru')
|
||||
assert [r["seq"] for r in s.read("ledger", "conv_1")] == [1, 2]
|
||||
assert s.read("ledger", "missing") == []
|
||||
assert s.ledgers("ledger") == ["conv_1"] and s.ledgers("none") == []
|
||||
s.rewrite("ledger", "conv_1", [{"seq": 9}])
|
||||
assert s.read("ledger", "conv_1") == [{"seq": 9}]
|
||||
|
||||
|
||||
def test_store_blobs_and_manifest(tmp_path):
|
||||
s = store.TenantStore(tmp_path, ident())
|
||||
digest = "ab" * 32
|
||||
s.put_blob(digest, b"hello")
|
||||
s.put_blob(digest, b"ignored")
|
||||
assert s.get_blob(digest) == b"hello"
|
||||
with pytest.raises(errors.NotFound):
|
||||
s.get_blob("cd" * 32)
|
||||
with pytest.raises(errors.Invalid):
|
||||
s.put_blob("../x", b"")
|
||||
with pytest.raises(errors.Invalid):
|
||||
s.get_blob("zz")
|
||||
manifest = s.manifest("1.0.0")
|
||||
assert contracts.validate_record(manifest, SCHEMAS) == []
|
||||
assert s.manifest("1.9.0") == manifest
|
||||
|
||||
|
||||
def test_store_locks_serialise_concurrent_writers(tmp_path):
|
||||
s = store.TenantStore(tmp_path, ident())
|
||||
s.put("docs", {"id": "doc_0001", "n": 0})
|
||||
|
||||
def bump() -> None:
|
||||
for _ in range(20):
|
||||
with s.lock("docs"):
|
||||
current = s.get("docs", "doc_0001")
|
||||
s.put("docs", {**current, "n": current["n"] + 1}, expected_revision=current["revision"])
|
||||
|
||||
workers = [threading.Thread(target=bump) for _ in range(4)]
|
||||
for worker in workers:
|
||||
worker.start()
|
||||
for worker in workers:
|
||||
worker.join()
|
||||
assert s.get("docs", "doc_0001")["n"] == 80
|
||||
|
||||
|
||||
# --- audit ---------------------------------------------------------------------
|
||||
|
||||
def test_audit_rows_validate_and_never_carry_bodies(tmp_path):
|
||||
s = store.TenantStore(tmp_path, ident())
|
||||
row = audit.record(s, ident(), "memory.read", "mem_0001", "deny", "not owner")
|
||||
assert contracts.validate("common.schema.json", row, SCHEMAS, "/$defs/audit_outcome") == []
|
||||
with pytest.raises(ValueError):
|
||||
audit.record(s, ident(), "x.y", "r", "maybe")
|
||||
for _ in range(5):
|
||||
audit.record(s, ident(), "memory.read", "mem_0002", "allow")
|
||||
assert len(audit.recent(s, limit=3)) == 3
|
||||
assert audit.recent(s)[0]["outcome"] == "deny"
|
||||
|
||||
|
||||
# --- http pipeline ---------------------------------------------------------------
|
||||
|
||||
def _router(tmp_path, flags_value=ALL_ON, environ=None) -> Router:
|
||||
env = {"HUX_FLAGS": flags_value, **(environ or {})}
|
||||
return build_router(tmp_path, env)
|
||||
|
||||
|
||||
def _call(router, method, path, headers=HEADERS, body=b"") -> tuple[int, dict]:
|
||||
response = router.dispatch(method, path, headers, body)
|
||||
return response.status, response.body
|
||||
|
||||
|
||||
def test_capabilities_and_manifest_roundtrip(tmp_path):
|
||||
router = _router(tmp_path, environ={"HUX_BUILD_COMMIT": "d3cbeb06" * 5})
|
||||
status, body = _call(router, "GET", "/hux/v1/capabilities")
|
||||
assert status == 200 and contracts.validate_record(body, SCHEMAS) == []
|
||||
assert body["server"] == {"commit": "d3cbeb06" * 5}
|
||||
status, body = _call(router, "GET", "/hux/v1/manifest")
|
||||
assert status == 200 and body["schema"] == "hux.manifest.v1"
|
||||
rows = audit.recent(store.TenantStore(tmp_path, ident()))
|
||||
assert [r["action"] for r in rows] == ["foundation.capabilities", "foundation.manifest"]
|
||||
|
||||
|
||||
def test_flag_off_and_unknown_route_look_the_same(tmp_path):
|
||||
off = _router(tmp_path, flags_value="")
|
||||
status, body = _call(off, "GET", "/hux/v1/capabilities")
|
||||
assert (status, body["code"]) == (404, "flag_off")
|
||||
status, body = _call(off, "GET", "/hux/v1/nothing")
|
||||
assert (status, body["code"]) == (404, "not_found")
|
||||
assert contracts.validate_record(body, SCHEMAS) == []
|
||||
status, body = _call(off, "POST", "/hux/v1/capabilities")
|
||||
assert status == 405
|
||||
outcomes = [r["outcome"] for r in audit.recent(store.TenantStore(tmp_path, ident()))]
|
||||
assert outcomes == ["flag_off", "not_found", "not_found"]
|
||||
|
||||
|
||||
def test_unauthorized_requests_never_touch_storage(tmp_path):
|
||||
router = _router(tmp_path)
|
||||
status, body = _call(router, "GET", "/hux/v1/capabilities", headers={})
|
||||
assert (status, body["code"]) == (401, "unauthorized")
|
||||
assert not (tmp_path / "hux").exists()
|
||||
|
||||
|
||||
def test_body_decoding_and_request_helpers(tmp_path):
|
||||
router = _router(tmp_path)
|
||||
captured = {}
|
||||
|
||||
def echo(request):
|
||||
captured.update(body=request.body, if_match=request.if_match(), key=request.idempotency_key(), q=request.query)
|
||||
return page([request.body], None)
|
||||
|
||||
router.add("POST", "/hux/v1/echo/{id}", "HUX-11", "test.echo", echo)
|
||||
status, body = _call(router, "POST", "/hux/v1/echo/abc?x=1&x=2", {**HEADERS, "If-Match": "3", "Idempotency-Key": "run:1234567"}, b'{"a": 1}')
|
||||
assert status == 200 and body == {"items": [{"a": 1}], "next": None}
|
||||
assert captured == {"body": {"a": 1}, "if_match": 3, "key": "run:1234567", "q": {"x": "2"}}
|
||||
assert _call(router, "POST", "/hux/v1/echo/abc", HEADERS, b"{oops")[1]["code"] == "invalid"
|
||||
assert _call(router, "POST", "/hux/v1/echo/abc", HEADERS, b"x" * (1024 * 1024 + 1))[1]["code"] == "too_large"
|
||||
assert _call(router, "POST", "/hux/v1/echo/abc", {**HEADERS, "If-Match": "abc"})[1]["code"] == "invalid"
|
||||
assert _call(router, "POST", "/hux/v1/echo/abc", {**HEADERS, "Idempotency-Key": "!"})[1]["code"] == "invalid"
|
||||
_, body = _call(router, "POST", "/hux/v1/echo/abc", HEADERS)
|
||||
assert body["items"] == [None]
|
||||
|
||||
|
||||
def test_handler_errors_are_audited_with_outcome(tmp_path):
|
||||
router = _router(tmp_path)
|
||||
|
||||
def conflict(request):
|
||||
raise errors.Conflict("stale")
|
||||
|
||||
def denied(request):
|
||||
raise errors.Forbidden("not yours")
|
||||
|
||||
router.add("GET", "/hux/v1/c", "HUX-11", "test.conflict", conflict)
|
||||
router.add("GET", "/hux/v1/d", "HUX-11", "test.denied", denied)
|
||||
assert _call(router, "GET", "/hux/v1/c")[0] == 409
|
||||
assert _call(router, "GET", "/hux/v1/d")[0] == 403
|
||||
outcomes = [(r["action"], r["outcome"]) for r in audit.recent(store.TenantStore(tmp_path, ident()))]
|
||||
assert outcomes == [("test.conflict", "conflict"), ("test.denied", "deny")]
|
||||
|
||||
|
||||
def test_real_server_serves_json_and_sse(tmp_path):
|
||||
router = _router(tmp_path)
|
||||
|
||||
def stream(request):
|
||||
return Response(200, stream=lambda: (b"id: 1\ndata: {}\n\n", b"id: 2\ndata: {}\n\n"))
|
||||
|
||||
router.add("GET", "/hux/v1/s", "HUX-11", "test.stream", stream)
|
||||
server = serve(router, "127.0.0.1", 0)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
conn = HTTPConnection("127.0.0.1", server.server_address[1], timeout=5)
|
||||
conn.request("GET", "/healthz")
|
||||
assert json.loads(conn.getresponse().read())["status"] == "ok"
|
||||
conn.request("GET", "/hux/v1/capabilities", headers=HEADERS)
|
||||
reply = conn.getresponse()
|
||||
assert reply.status == 200 and json.loads(reply.read())["schema"] == "hux.capabilities.v1"
|
||||
conn.request("POST", "/hux/v1/capabilities", body=b"{}", headers={**HEADERS, "Content-Length": "2"})
|
||||
assert conn.getresponse().status == 405
|
||||
conn.request("GET", "/hux/v1/s", headers=HEADERS)
|
||||
reply = conn.getresponse()
|
||||
assert reply.getheader("Content-Type") == "text/event-stream"
|
||||
assert reply.read() == b"id: 1\ndata: {}\n\nid: 2\ndata: {}\n\n"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def test_all_errors_serialise_to_contract():
|
||||
for cls in (errors.Unauthorized, errors.Forbidden, errors.NotFound, errors.FlagOff, errors.Conflict, errors.Invalid, errors.TooLarge, errors.ApprovalRequired, errors.BudgetExhausted):
|
||||
record = cls("m" * 300, ["d" * 300] * 40).record()
|
||||
assert contracts.validate_record(record, SCHEMAS) == [], cls
|
||||
|
||||
|
||||
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")
|
||||
@ -9,7 +9,6 @@ refuse the transitions that would let merged/built/deployed be confused.
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import tomllib
|
||||
@ -20,21 +19,13 @@ import pytest
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPTS = ROOT / "services" / "hermes" / "scripts"
|
||||
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
|
||||
CONTRACTS = ROOT / "services" / "hermes" / "contracts" / "hux"
|
||||
|
||||
|
||||
def _load(name: str):
|
||||
spec = importlib.util.spec_from_file_location(name, SCRIPTS / f"{name}.py")
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
contracts = _load("hux_contracts")
|
||||
policy = _load("hux_policy")
|
||||
if str(FOUNDATION) not in sys.path:
|
||||
sys.path.insert(0, str(FOUNDATION))
|
||||
from hux import contracts, rules as policy # noqa: E402
|
||||
SCHEMAS = contracts.load_all()
|
||||
EXAMPLES = {path.stem: json.loads(path.read_text()) for path in sorted((CONTRACTS / "examples").glob("*.json"))}
|
||||
NOW = datetime(2026, 8, 23, 12, 0, tzinfo=timezone.utc)
|
||||
@ -166,6 +157,7 @@ def test_capability_matrix_shape():
|
||||
for level, row in matrix.items():
|
||||
assert set(row) == set(policy.CAPABILITIES)
|
||||
assert row["deploy"] == "ask", level
|
||||
assert row["external_side_effect"] == "ask", level
|
||||
assert row["read_files"] == "allow"
|
||||
assert matrix["ask_first"]["shell"] == "ask"
|
||||
assert matrix["safe"]["network"] == "deny"
|
||||
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