Compare commits
18 Commits
124206b748
...
e1ef110c8a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1ef110c8a | ||
|
|
a75ca29934 | ||
|
|
681b040885 | ||
|
|
6964a9d8c8 | ||
|
|
74c6549965 | ||
|
|
aeef0f8484 | ||
|
|
1b1a14e972 | ||
|
|
b8fb72fbcc | ||
|
|
3ebee2cc15 | ||
|
|
9f0ffe031c | ||
|
|
01abf89270 | ||
|
|
b9b7126ee1 | ||
|
|
6a5e0d872b | ||
|
|
b4145861d2 | ||
|
|
1a586d9523 | ||
|
|
91eb4f92b7 | ||
|
|
c7aa7f462b | ||
|
|
e9a6eba30b |
@ -156,6 +156,7 @@ spec:
|
||||
dockerfiles/hermes_jetson_tts_cues.py
|
||||
PYTHONPATH=/tmp/hermes-voice-test-deps python3 -m pytest -q \
|
||||
testing/tests/test_hermes_stt_streaming.py \
|
||||
testing/tests/test_hermes_stt_rolling_model.py \
|
||||
testing/tests/test_hermes_tts_language_routing.py \
|
||||
testing/tests/test_hermes_voice_language_routing.py \
|
||||
testing/tests/test_hermes_oci_promote.py \
|
||||
|
||||
@ -17,6 +17,9 @@ ADD --checksum=sha256:aff26ae408abcba5fbf8813c21e62b0941638c5f6eebfb145be0c98392
|
||||
ADD --checksum=sha256:9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794 --chmod=0444 \
|
||||
https://openaipublic.azureedge.net/main/whisper/models/9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794/small.pt \
|
||||
/opt/models/whisper/small.pt
|
||||
ADD --checksum=sha256:65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9 --chmod=0444 \
|
||||
https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/tiny.pt \
|
||||
/opt/models/whisper/tiny.pt
|
||||
RUN chmod 0555 /opt/models /opt/models/whisper
|
||||
|
||||
COPY dockerfiles/hermes-jetson-stt-server.py /opt/atlas/hermes-jetson-stt-server.py
|
||||
@ -28,11 +31,12 @@ WORKDIR /opt/atlas
|
||||
|
||||
# Import the Xavier CUDA stack and confirm Whisper resolves the baked artifact.
|
||||
# Full GPU warm-up is covered by the Kubernetes startup probe on titan-21.
|
||||
RUN python3 -c "import stat; from pathlib import Path; import torch, whisper; p=Path('/opt/models/whisper'); print(whisper.__file__, whisper.__version__, whisper.available_models()); assert {'large-v3-turbo','small'} <= set(whisper.available_models()); assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE((p/name).stat().st_mode)==0o444 for name in ('large-v3-turbo.pt','small.pt')); print(torch.__version__)"
|
||||
RUN python3 -c "import stat; from pathlib import Path; import torch, whisper; p=Path('/opt/models/whisper'); print(whisper.__file__, whisper.__version__, whisper.available_models()); assert {'large-v3-turbo','small','tiny'} <= set(whisper.available_models()); assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE((p/name).stat().st_mode)==0o444 for name in ('large-v3-turbo.pt','small.pt','tiny.pt')); print(torch.__version__)"
|
||||
|
||||
ENV HERMES_STT_HOST=0.0.0.0 \
|
||||
HERMES_STT_PORT=9000 \
|
||||
HERMES_STT_MODEL=small \
|
||||
HERMES_STT_ROLLING_MODEL=tiny \
|
||||
HERMES_STT_CACHE=/opt/models/whisper \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
0
dockerfiles/hermes-hux-foundation/hux/__init__.py
Normal file
0
dockerfiles/hermes-hux-foundation/hux/__init__.py
Normal file
424
dockerfiles/hermes-hux-foundation/hux/artifacts.py
Normal file
424
dockerfiles/hermes-hux-foundation/hux/artifacts.py
Normal file
@ -0,0 +1,424 @@
|
||||
"""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 secret_hits(data: bytes) -> list[str]:
|
||||
"""Secret-pattern classes found in text content; binary content is not scanned (SO-12)."""
|
||||
from hux.redaction import scrub_text
|
||||
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return []
|
||||
return scrub_text(text)[1]
|
||||
|
||||
|
||||
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")
|
||||
hits = secret_hits(_content(body)[0])
|
||||
if hits:
|
||||
sensitivity = "restricted"
|
||||
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"], reason="secret_pattern_restricted" if hits else "")
|
||||
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
|
||||
hits = secret_hits(_content(body)[0])
|
||||
if hits:
|
||||
artifact = {**artifact, "sensitivity": "restricted"}
|
||||
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}", reason="secret_pattern_restricted" if hits else "")
|
||||
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:]
|
||||
236
dockerfiles/hermes-hux-foundation/hux/budgets.py
Normal file
236
dockerfiles/hermes-hux-foundation/hux/budgets.py
Normal file
@ -0,0 +1,236 @@
|
||||
"""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 run_conversation(store: TenantStore, run_id: str) -> str | None:
|
||||
"""The conversation a run belongs to, from its budget document or an approval it raised; never the body (F4)."""
|
||||
doc_id = f"bud_{policy.run_key(run_id)}"
|
||||
if store.exists(BUDGETS, doc_id) and store.get(BUDGETS, doc_id).get("_conversation_id"):
|
||||
return store.get(BUDGETS, doc_id)["_conversation_id"]
|
||||
for record in store.scan(policy.APPROVALS):
|
||||
if record["run_id"] == run_id:
|
||||
return record["conversation_id"]
|
||||
return None
|
||||
|
||||
|
||||
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 conversation the gate settled on.
|
||||
|
||||
External effects release only against an approval for the same run and
|
||||
the same argument hash, whatever the choice (F4, SO-39). Non-external
|
||||
session/always approvals stay reusable inside their conversation, which
|
||||
is the run's own conversation when the run is known and otherwise the
|
||||
approval's. A ``once`` approval names exactly one hash (SO-36, SO-37).
|
||||
"""
|
||||
reason = "no approval for this run and capability"
|
||||
for record in store.scan(policy.APPROVALS):
|
||||
record = policy.refresh(store, record)
|
||||
if record["capability"] != capability:
|
||||
continue
|
||||
same_run = record["run_id"] == run_id
|
||||
choice = record.get("decision", {}).get("choice")
|
||||
reusable = choice in ("session", "always") and not external and not record["request"]["external"]
|
||||
same_conv = record["conversation_id"] == (conversation_id or record["conversation_id"])
|
||||
if not (same_run or (reusable 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 (external or 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 or record["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 = run_conversation(request.store, run_id)
|
||||
state = budget_state(request.store, request.identity, run_id, conversation_id)
|
||||
if state["exhausted"]: # F6: an exhausted run releases nothing, whatever was approved
|
||||
request.audit("gate.check", f"{run_id}:{capability}", outcome="deny", reason="budget_exhausted")
|
||||
policy.emit(request.store, request.identity, conversation_id, "budget.exhausted", f"Budget exhausted: {', '.join(state['exhausted'])}", run_id=run_id)
|
||||
return Response(200, {"proceed": False, "reason": "budget_exhausted", "exhausted": state["exhausted"]})
|
||||
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_outcome(request: Request, body: dict[str, Any]) -> tuple[str, str]:
|
||||
"""(outcome, reason) for a stop; only the gateway (worker trust) may vouch for an empty process registry (F8, SO-41)."""
|
||||
if body.get("already_complete"):
|
||||
return "already_complete", "already_complete"
|
||||
if body.get("process_registry_empty") is not True:
|
||||
return "failed_to_cancel", "process_registry_not_empty"
|
||||
if request.identity.trust != "worker":
|
||||
return "failed_to_cancel", "registry_state_not_from_gateway"
|
||||
return "cancelled", "cancelled"
|
||||
|
||||
|
||||
def stop(request: Request) -> Response:
|
||||
"""``POST /hux/v1/runs/{id}/stop``: write the cancellation receipt; a repeat returns it.
|
||||
|
||||
A ``failed_to_cancel`` receipt is the one non-terminal outcome: a later
|
||||
stop that really cancels or finds the run complete supersedes it with a
|
||||
revision bump; an identical repeat still replays (F8).
|
||||
"""
|
||||
body = policy.body_dict(request)
|
||||
run_id = run_id_from(request)
|
||||
receipt_id = f"rcpt_{policy.run_key(run_id)}"
|
||||
side_effects = body.get("side_effects", [])
|
||||
if not isinstance(side_effects, list):
|
||||
raise Invalid("side_effects must be a list")
|
||||
outcome, reason = stop_outcome(request, body)
|
||||
with request.store.lock(RECEIPTS):
|
||||
existing = request.store.get(RECEIPTS, receipt_id) if request.store.exists(RECEIPTS, receipt_id) else None
|
||||
if existing is not None and (existing["outcome"] != "failed_to_cancel" or outcome == "failed_to_cancel"):
|
||||
request.audit("runs.stop", receipt_id, reason="replayed")
|
||||
return Response(200, policy.public(existing))
|
||||
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": existing["requested_at"] if existing else 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", existing.get("conversation_id") if existing else None)
|
||||
if conversation_id is not None:
|
||||
record["conversation_id"] = check_id(conversation_id)
|
||||
stored = request.store.put(RECEIPTS, policy.checked(record), expected_revision=existing["revision"] if existing else None)
|
||||
request.audit("runs.stop", receipt_id, reason=reason if existing is None else f"superseded:{reason}")
|
||||
policy.emit(request.store, request.identity, record.get("conversation_id"), "run.cancelled", f"Run stopped: {outcome} ({reason})",
|
||||
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"
|
||||
315
dockerfiles/hermes-hux-foundation/hux/events.py
Normal file
315
dockerfiles/hermes-hux-foundation/hux/events.py
Normal file
@ -0,0 +1,315 @@
|
||||
"""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 pathlib import Path
|
||||
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 _ledger_path(store: TenantStore, conversation_id: str) -> Path:
|
||||
return store.root / FAMILY / f"{conversation_id}.jsonl"
|
||||
|
||||
|
||||
def conversation_known(store: TenantStore, conversation_id: str) -> bool:
|
||||
"""Ownership check (SO-18): the conversation document, its seq checkpoint or its event ledger exists in this subject's tree."""
|
||||
return (
|
||||
store.exists("conversations", conversation_id)
|
||||
or store.exists(SEQ_FAMILY, _seq_id(conversation_id))
|
||||
or _ledger_path(store, conversation_id).is_file()
|
||||
)
|
||||
|
||||
|
||||
def last_seq(store: TenantStore, conversation_id: str) -> int:
|
||||
"""The seq of the newest complete ledger line, read from the file tail (0 when the ledger is empty or absent).
|
||||
|
||||
A crash between the ledger append and the checkpoint write leaves the
|
||||
checkpoint behind the ledger; ``emit`` takes the larger of the two so a seq
|
||||
is never handed out twice (F5). Only the last ``LINE_CAP_BYTES`` window is
|
||||
read, and a torn trailing line is skipped.
|
||||
"""
|
||||
path = _ledger_path(store, conversation_id)
|
||||
if not path.is_file():
|
||||
return 0
|
||||
with open(path, "rb") as handle:
|
||||
handle.seek(0, 2)
|
||||
size = handle.tell()
|
||||
handle.seek(max(0, size - redaction.LINE_CAP_BYTES - 2))
|
||||
tail = handle.read()
|
||||
for line in reversed(tail.split(b"\n")):
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
return int(json.loads(line).get("seq", 0))
|
||||
except (json.JSONDecodeError, AttributeError, ValueError):
|
||||
continue
|
||||
return 0
|
||||
|
||||
|
||||
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"] = max(int(checkpoint["next_seq"]), last_seq(store, conversation_id) + 1)
|
||||
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)
|
||||
97
dockerfiles/hermes-hux-foundation/hux/flags.py
Normal file
97
dockerfiles/hermes-hux-foundation/hux/flags.py
Normal file
@ -0,0 +1,97 @@
|
||||
"""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, and the
|
||||
worker allowlist (SO-08) says which of those a ``trust: worker`` caller may
|
||||
reach at all.
|
||||
"""
|
||||
|
||||
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-07": [],
|
||||
"HUX-08": ["/hux/v1/sources", "/hux/v1/sources/{id}", "/hux/v1/passages", "/hux/v1/messages/{id}/citations", "/hux/v1/notebooks", "/hux/v1/notebooks/{id}"],
|
||||
"HUX-09": [],
|
||||
"HUX-10": ["/hux/v1/privacy/policy", "/hux/v1/privacy/notices", "/hux/v1/conversations/{id}/forget", "/hux/v1/privacy/audit"],
|
||||
"HUX-12": [],
|
||||
}
|
||||
# Cards whose routes are not shipped yet declare [] above until they land (F12).
|
||||
|
||||
# The only (method, template) pairs a ``trust: worker`` caller may reach (SO-08).
|
||||
# Everything else is 403 before the flag check. These are the agent-hook
|
||||
# routes: negotiation, the approval/gate/budget/stop loop, activity events,
|
||||
# the privacy policy, memory retrieval and proposals, research inputs and
|
||||
# artifact writes. The conversation read is there so the hook can honour
|
||||
# private mode (SO-28) before proposing a memory.
|
||||
WORKER_ROUTES: frozenset[tuple[str, str]] = frozenset({
|
||||
("GET", "/hux/v1/capabilities"), ("GET", "/hux/v1/manifest"), ("GET", "/hux/v1/releases"),
|
||||
("POST", "/hux/v1/approvals"),
|
||||
("POST", "/hux/v1/runs/{id}/gate"), ("POST", "/hux/v1/runs/{id}/budget"), ("POST", "/hux/v1/runs/{id}/stop"),
|
||||
("GET", "/hux/v1/runs/{id}/budget"),
|
||||
("POST", "/hux/v1/conversations/{id}/events"), ("GET", "/hux/v1/conversations/{id}"),
|
||||
("GET", "/hux/v1/privacy/policy"),
|
||||
("GET", "/hux/v1/memory"), ("POST", "/hux/v1/memory"),
|
||||
("POST", "/hux/v1/sources"), ("POST", "/hux/v1/passages"), ("POST", "/hux/v1/messages/{id}/citations"),
|
||||
("POST", "/hux/v1/artifacts"), ("POST", "/hux/v1/artifacts/{id}/versions"),
|
||||
})
|
||||
|
||||
|
||||
def worker_may_call(method: str, template: str) -> bool:
|
||||
"""True when a ``trust: worker`` caller is allowed on this route (SO-08)."""
|
||||
return (method, template) in WORKER_ROUTES
|
||||
|
||||
|
||||
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)
|
||||
222
dockerfiles/hermes-hux-foundation/hux/http.py
Normal file
222
dockerfiles/hermes-hux-foundation/hux/http.py
Normal file
@ -0,0 +1,222 @@
|
||||
"""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 Forbidden, HuxError, Invalid, NotFound, TooLarge
|
||||
from hux.flags import CONTRACT_VERSION, Flags, build_from_environ, worker_may_call
|
||||
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:
|
||||
# SO-08: the worker allowlist is checked before the flag so a
|
||||
# worker cannot even learn which cards are on.
|
||||
if identity.trust == "worker" and not worker_may_call(method, route.template):
|
||||
raise Forbidden("route is not available to worker trust")
|
||||
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())
|
||||
except Exception: # noqa: BLE001 - the pipeline never raises; anything else is a 500 with no detail leaked
|
||||
error = HuxError("internal error")
|
||||
audit.record(store, identity, route.action, parts.path, "deny", 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": CONTRACT_VERSION}))
|
||||
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)
|
||||
459
dockerfiles/hermes-hux-foundation/hux/memory.py
Normal file
459
dockerfiles/hermes-hux-foundation/hux/memory.py
Normal file
@ -0,0 +1,459 @@
|
||||
"""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]:
|
||||
"""Move an entry to ``target`` from its *current* stored state (F7).
|
||||
|
||||
The caller's ``record`` may be stale: the entry is re-read under the family
|
||||
lock, ``expected`` (If-Match) is checked against that copy, and the write is
|
||||
always conditional on the loaded revision so a stale unconditional write can
|
||||
never resurrect a forgotten entry (SO-22, SO-44).
|
||||
"""
|
||||
with store.lock(FAMILY):
|
||||
record = _current(store, record, expected)
|
||||
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, record["revision"])
|
||||
if target == "forgotten":
|
||||
_tombstone(store, stored["id"], action)
|
||||
return stored
|
||||
|
||||
|
||||
def _current(store: TenantStore, record: dict[str, Any], expected: int | None) -> dict[str, Any]:
|
||||
"""Fresh copy of ``record`` from the store; Conflict when If-Match no longer matches it."""
|
||||
current = store.get(FAMILY, record["id"])
|
||||
if expected is not None and expected != current["revision"]:
|
||||
raise Conflict(f"revision {expected} does not match current revision {current['revision']}", [str(current["revision"])])
|
||||
return current
|
||||
|
||||
|
||||
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 _classify(store: TenantStore, body: dict[str, Any], content: str, conversation_id: str | None) -> dict[str, Any]:
|
||||
"""Scrub ``content`` and decide topic, sensitivity floor and the privacy gate; shared by create and edit (F3)."""
|
||||
from hux import privacy
|
||||
|
||||
hits: list[str] = []
|
||||
content = redaction.scrub_value(content.strip(), hits)[:2000]
|
||||
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
|
||||
allowed, why = privacy.memory_write_allowed(store, conversation_id, {"sensitivity": sensitivity, "topic": topic})
|
||||
if why == "private_mode":
|
||||
raise Forbidden("memory writes are refused in private mode")
|
||||
return {"content": content, "topic": topic, "sensitivity": sensitivity, "allowed": allowed, "why": why}
|
||||
|
||||
|
||||
def _decline(entry: dict[str, Any], why: str, actor: dict[str, str], stamp: str) -> dict[str, Any]:
|
||||
"""Content-free ``no_store`` 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.
|
||||
"""
|
||||
topic = entry.pop("topic")
|
||||
entry.update(status="no_store", approval_mode="ask" if entry["sensitivity"] == "sensitive" else "no_store", content="", retrievable=False,
|
||||
audit=[{"at": stamp, "action": "no_store", "actor": actor, "note": f"{why}; topic={topic}"[:200]}])
|
||||
return entry
|
||||
|
||||
|
||||
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)."""
|
||||
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")
|
||||
conversation_id = body.get("conversation_id") if isinstance(body.get("conversation_id"), str) else None
|
||||
verdict = _classify(request.store, body, content, conversation_id)
|
||||
topic, sensitivity = verdict["topic"], verdict["sensitivity"]
|
||||
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()
|
||||
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": verdict["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"]
|
||||
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")
|
||||
why = verdict["why"]
|
||||
if not verdict["allowed"] or wants == "no_store":
|
||||
why = why if not verdict["allowed"] else "declined"
|
||||
_decline(entry, why, actor, stamp)
|
||||
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 _replay(request: Request, key: str) -> Response | None:
|
||||
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"})
|
||||
return None
|
||||
|
||||
|
||||
def post_memory(request: Request) -> Response:
|
||||
"""``POST /hux/v1/memory``: propose an entry; policy decides active, proposed (ask) or no_store (202).
|
||||
|
||||
The Idempotency-Key lookup, the write and the key mapping all happen under
|
||||
the family lock so concurrent retries with one key yield one record (F13b).
|
||||
"""
|
||||
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()
|
||||
with request.store.lock(FAMILY):
|
||||
replayed = _replay(request, key) if key else None
|
||||
if replayed is not None:
|
||||
return replayed
|
||||
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]:
|
||||
"""Supersede ``record`` with edited content under the same privacy shaping as a proposal (F3).
|
||||
|
||||
A deny verdict (deny topic, restricted, memory disabled, conversation
|
||||
forgotten) yields a content-free ``no_store`` record and leaves the old
|
||||
entry untouched; a sensitive floor makes the replacement ``proposed`` with
|
||||
``approval_mode: ask``. Private mode is refused outright.
|
||||
"""
|
||||
body = request.body if isinstance(request.body, dict) else {}
|
||||
content = body.get("content")
|
||||
if not isinstance(content, str) or not content.strip() or len(content) > 2000:
|
||||
raise Invalid("edit needs new content of at most 2000 chars")
|
||||
conversation_id = record["provenance"].get("conversation_id")
|
||||
verdict = _classify(request.store, {**body, "sensitivity": record["sensitivity"]}, content, conversation_id)
|
||||
stamp = now_iso()
|
||||
fresh = {
|
||||
**{k: v for k, v in record.items() if k not in {"revision", "supersedes"}}, "id": new_id("mem"), "content": verdict["content"],
|
||||
"topic": verdict["topic"], "sensitivity": verdict["sensitivity"], "status": "active", "approval_mode": "automatic", "retrievable": True,
|
||||
"created_at": stamp, "updated_at": stamp, "audit": [{"at": stamp, "action": "edited", "actor": actor, "note": f"edit of {record['id']}"}],
|
||||
}
|
||||
if not verdict["allowed"]:
|
||||
stored = _persist(request.store, _decline(fresh, verdict["why"], actor, stamp), None)
|
||||
_tombstone(request.store, stored["id"], verdict["why"])
|
||||
return stored
|
||||
old = _transition(request.store, request.identity, record, "forgotten", "superseded", actor, "edited", expected)
|
||||
from hux import events
|
||||
|
||||
events.redact_memory_references(request.store, old["id"])
|
||||
fresh["supersedes"] = old["id"]
|
||||
fresh["audit"][0]["note"] = f"supersedes {old['id']}"
|
||||
if fresh["sensitivity"] == "sensitive":
|
||||
fresh.update(status="proposed", approval_mode="ask", retrievable=False)
|
||||
else:
|
||||
fresh["audit"].append({"at": stamp, "action": "approved", "actor": actor})
|
||||
return _persist(request.store, fresh, None)
|
||||
|
||||
|
||||
def _set_retrievable(store: TenantStore, record: dict[str, Any], retrievable: bool, actor: dict[str, str], expected: int | None) -> dict[str, Any]:
|
||||
"""Flip ``retrievable`` on an active entry, re-reading it under the lock so a stale copy cannot overwrite a forget (F7)."""
|
||||
with store.lock(FAMILY):
|
||||
record = _current(store, record, expected)
|
||||
if record["status"] != "active":
|
||||
raise Conflict("retrieval can only change on active entries")
|
||||
stamp = now_iso()
|
||||
entry = {"at": stamp, "action": "approved" if retrievable else "retrieval_removed", "actor": actor, "note": "retrieval restored" if retrievable else ""}
|
||||
return _persist(store, {**record, "retrievable": retrievable, "updated_at": stamp, "audit": [*record["audit"], entry]}, record["revision"])
|
||||
|
||||
|
||||
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)
|
||||
if stored["status"] == "no_store":
|
||||
_emit(store, identity, stored, "memory.suppressed", f"Edit not remembered ({stored['audit'][0]['note'].split(';')[0]})")
|
||||
request.audit("memory.edit", memory_id, "allow", "no_store")
|
||||
return Response(202, stored)
|
||||
_emit(store, identity, stored, "memory.committed" if stored["status"] == "active" else "memory.proposed", f"Memory edited, supersedes {record['id']}")
|
||||
else:
|
||||
retrievable = action == "restore_retrieval"
|
||||
stored = _set_retrievable(store, record, retrievable, actor, 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)
|
||||
323
dockerfiles/hermes-hux-foundation/hux/organization.py
Normal file
323
dockerfiles/hermes-hux-foundation/hux/organization.py
Normal file
@ -0,0 +1,323 @@
|
||||
"""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, redaction
|
||||
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, secret-scrubbed (F9); ids, owner, timestamps and revision are server-set."""
|
||||
return redaction.scrub_value({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_index(store: TenantStore) -> dict[str, list[str]]:
|
||||
"""Conversation id -> titles of the artifacts filed under it (F13c): both the conversation's ``artifact_ids`` and artifacts that name the conversation."""
|
||||
try:
|
||||
from hux import artifacts
|
||||
except ModuleNotFoundError:
|
||||
return {}
|
||||
index: dict[str, list[str]] = {}
|
||||
for artifact in store.scan(artifacts.FAMILY):
|
||||
if isinstance(artifact.get("conversation_id"), str) and isinstance(artifact.get("title"), str):
|
||||
index.setdefault(artifact["conversation_id"], []).append(artifact["title"])
|
||||
for conversation in store.scan(CONVERSATIONS):
|
||||
ids = [i for i in conversation.get("artifact_ids", []) if isinstance(i, str)]
|
||||
titles = artifacts.artifact_titles(store, ids).values() if ids else []
|
||||
for title in titles:
|
||||
index.setdefault(conversation["id"], []).append(title)
|
||||
return index
|
||||
|
||||
|
||||
def artifact_titles(store: TenantStore, conversation: dict[str, Any], index: dict[str, list[str]] | None = None) -> list[str]:
|
||||
"""Artifact titles for a conversation via the artifacts lane, or nothing when it is absent."""
|
||||
index = artifact_index(store) if index is None else index
|
||||
return list(dict.fromkeys(index.get(conversation["id"], [])))
|
||||
|
||||
|
||||
def mark_forgotten(store: TenantStore, conversation_id: str) -> bool:
|
||||
"""Blank a forgotten conversation's title and tags and archive it (F9); False when there is no document."""
|
||||
with store.lock(CONVERSATIONS):
|
||||
if not conversation_exists(store, conversation_id):
|
||||
return False
|
||||
current = store.get(CONVERSATIONS, conversation_id)
|
||||
record = {**current, "title": "[forgotten]", "tags": [], "archived": True, "updated_at": now_iso()}
|
||||
store.put(CONVERSATIONS, checked(record), expected_revision=current["revision"])
|
||||
return True
|
||||
|
||||
|
||||
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)}
|
||||
index = artifact_index(request.store)
|
||||
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, index))
|
||||
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)
|
||||
374
dockerfiles/hermes-hux-foundation/hux/policy.py
Normal file
374
dockerfiles/hermes-hux-foundation/hux/policy.py
Normal file
@ -0,0 +1,374 @@
|
||||
"""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 is_human(identity: Identity) -> bool:
|
||||
"""True when a person is behind the call: router/relay trust on a chat, telegram or voice surface."""
|
||||
return identity.trust in HUMAN_TRUSTS and identity.surface in HUMAN_SURFACES
|
||||
|
||||
|
||||
def require_human(identity: Identity, what: str) -> None:
|
||||
"""Forbidden unless a human actor is behind the call (F1: policy writes and allow grants)."""
|
||||
if not is_human(identity):
|
||||
raise Forbidden(f"{what} only from a human surface")
|
||||
|
||||
|
||||
def actor_for(identity: Identity) -> dict[str, str]:
|
||||
"""The actor a record attributes to this caller: humans are users, hops are system."""
|
||||
if is_human(identity):
|
||||
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")
|
||||
if grant["decision"] == "allow":
|
||||
require_human(identity, "allow grants")
|
||||
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; humans only (F1)."""
|
||||
require_human(request.identity, "policy writes")
|
||||
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")
|
||||
if body["autonomy"] == "autonomous":
|
||||
require_human(request.identity, "autonomy escalation")
|
||||
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; the decider must be human."""
|
||||
require_human(identity, "allow grants")
|
||||
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))
|
||||
evidence = req.get("evidence") if isinstance(req.get("evidence"), list) else []
|
||||
if sum(1 for e in evidence if isinstance(e, dict) and e.get("kind") == "tool_call") > 1:
|
||||
raise Invalid("an approval names exactly one tool_call; ask once per side effect (SO-37)")
|
||||
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)
|
||||
|
||||
273
dockerfiles/hermes-hux-foundation/hux/privacy.py
Normal file
273
dockerfiles/hermes-hux-foundation/hux/privacy.py
Normal file
@ -0,0 +1,273 @@
|
||||
"""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, "document_blanked": _blank_document(store, conversation_id)}
|
||||
store.append(FAMILY, "forgotten", {"conv_id": conversation_id, "requested_at": now_iso(), "purged_at": "", "counts": counts})
|
||||
return {"conversation_id": conversation_id, "forgotten": True, **counts}
|
||||
|
||||
|
||||
def _blank_document(store: TenantStore, conversation_id: str) -> bool:
|
||||
"""Blank the HUX-03 conversation document (title, tags, archived) when the organisation lane holds one (F9)."""
|
||||
try:
|
||||
from hux import organization
|
||||
except ModuleNotFoundError:
|
||||
return False
|
||||
return organization.mark_forgotten(store, conversation_id)
|
||||
|
||||
|
||||
# -- routes ---------------------------------------------------------------------
|
||||
|
||||
def _strip(record: dict[str, Any]) -> dict[str, Any]:
|
||||
return {key: value for key, value in record.items() if key != "revision"}
|
||||
|
||||
|
||||
def get_policy(request: Request) -> Response:
|
||||
"""``GET /hux/v1/privacy/policy``: the frozen policy; ``HUX-Audit-Stale`` says whether retention ran lately (SO-27)."""
|
||||
request.audit("privacy.policy", "policy")
|
||||
stale = audit_stale(request.store)
|
||||
return Response(200, rules.privacy_policy(), {"HUX-Audit-Stale": "true" if stale else "false"})
|
||||
|
||||
|
||||
def post_notice(request: Request) -> Response:
|
||||
"""``POST /hux/v1/privacy/notices``: record a just-in-time notice as shown and scope the topic to the conversation."""
|
||||
from hux import events
|
||||
|
||||
body = request.body if isinstance(request.body, dict) else {}
|
||||
topic, conversation_id = body.get("topic"), body.get("conversation_id")
|
||||
if topic not in rules.PRIVACY_TOPICS or not isinstance(conversation_id, str):
|
||||
raise Invalid("topic and conversation_id are required")
|
||||
controls = [c for c in body.get("controls", list(CONTROLS)) if c in CONTROLS] or list(CONTROLS)
|
||||
text = next(t["notice"] for t in rules.privacy_policy()["topics"] if t["topic"] == topic)
|
||||
notice = {"schema": "hux.privacy_notice.v1", "topic": topic, "conversation_id": conversation_id, "text": text, "controls": sorted(set(controls), key=CONTROLS.index), "shown_at": now_iso()}
|
||||
problems = contracts.validate_record(notice, SCHEMAS)
|
||||
if problems:
|
||||
raise Invalid("notice failed contract validation", problems)
|
||||
request.store.append(FAMILY, "notices", notice)
|
||||
mark_topic(request.store, conversation_id, topic)
|
||||
chosen = body.get("chosen")
|
||||
if chosen == "disable_memory_here":
|
||||
set_flag(request.store, conversation_id, "memory_disabled", True)
|
||||
elif chosen == "forget_this_conversation":
|
||||
forget_conversation(request.store, request.identity, conversation_id)
|
||||
# F13a: a notice for a conversation this subject never had must not conjure
|
||||
# an event ledger for it; the notice itself is still on record.
|
||||
known = events.conversation_known(request.store, conversation_id)
|
||||
if known:
|
||||
events.emit(request.store, request.identity, conversation_id, "privacy.notice", text, notice, sensitivity=rules.PRIVACY_TOPICS[topic]["sensitivity"])
|
||||
request.audit("privacy.notice", conversation_id, "allow", "" if known else "unknown_conversation")
|
||||
return Response(201, notice)
|
||||
|
||||
|
||||
def post_forget(request: Request) -> Response:
|
||||
"""``POST /hux/v1/conversations/{id}/forget``: user-initiated forget; unknown conversations are 404 (SO-18)."""
|
||||
from hux import events
|
||||
|
||||
conversation_id = request.params["id"]
|
||||
if not (events.conversation_known(request.store, conversation_id) or conversation_id in load_topics(request.store)["items"]):
|
||||
raise NotFound("conversation not found")
|
||||
result = forget_conversation(request.store, request.identity, conversation_id)
|
||||
request.audit("privacy.forget", conversation_id)
|
||||
return Response(200, result)
|
||||
|
||||
|
||||
def get_audit(request: Request) -> Response:
|
||||
"""``GET /hux/v1/privacy/audit``: retention audit records, newest first."""
|
||||
rows = sorted(request.store.scan(RETENTION_FAMILY), key=lambda row: row["ran_at"], reverse=True)
|
||||
request.audit("privacy.audit", "retention")
|
||||
return page([_strip(row) for row in rows[:PAGE_LIMIT]], None)
|
||||
|
||||
|
||||
PAGE_LIMIT = 200
|
||||
|
||||
|
||||
def 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)
|
||||
229
dockerfiles/hermes-hux-foundation/hux/redaction.py
Normal file
229
dockerfiles/hermes-hux-foundation/hux/redaction.py
Normal file
@ -0,0 +1,229 @@
|
||||
"""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.
|
||||
# F13d: normalise before the prefix check so ``workspace/../.env`` cannot
|
||||
# pass as a workspace path; the stored value is the normalised one.
|
||||
path = os.path.normpath(kept["target_path"]) if isinstance(kept.get("target_path"), str) else ""
|
||||
if path.startswith(WORKSPACE_PREFIX + "/"):
|
||||
kept["target_path"] = path
|
||||
else:
|
||||
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
|
||||
384
dockerfiles/hermes-hux-foundation/hux/research.py
Normal file
384
dockerfiles/hermes-hux-foundation/hux/research.py
Normal file
@ -0,0 +1,384 @@
|
||||
"""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 import redaction
|
||||
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 _clean(value: Any) -> Any:
|
||||
"""Secret-scrub free text before it is stored (F9); ids and enums never carry secrets so they skip this."""
|
||||
return redaction.scrub_value(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 = _clean(_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 = _clean(_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 = _clean(_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"] = _clean(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 _clean(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": _clean(_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": _clean(_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
|
||||
@ -25,6 +25,7 @@ import whisper
|
||||
HOST = os.getenv("HERMES_STT_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("HERMES_STT_PORT", "9000"))
|
||||
MODEL_NAME = os.getenv("HERMES_STT_MODEL", "small")
|
||||
ROLLING_MODEL_NAME = os.getenv("HERMES_STT_ROLLING_MODEL", "tiny")
|
||||
CACHE_DIR = Path(os.getenv("HERMES_STT_CACHE", "/cache/whisper"))
|
||||
MAX_AUDIO_BYTES = 30 * 1024 * 1024
|
||||
STREAM_SAMPLE_RATE = 16_000
|
||||
@ -68,6 +69,8 @@ class InferenceGate:
|
||||
self._last_final_wait_ms = 0.0
|
||||
self._last_final_decode_ms = 0.0
|
||||
self._last_background_decode_ms = 0.0
|
||||
self._last_rolling_decode_ms = 0.0
|
||||
self._last_speculative_decode_ms = 0.0
|
||||
self._background_skips = 0
|
||||
|
||||
def run_final(self, callback):
|
||||
@ -87,14 +90,14 @@ class InferenceGate:
|
||||
return callback()
|
||||
finally:
|
||||
with self._condition:
|
||||
self._last_final_decode_ms = (
|
||||
time.monotonic() - decode_started
|
||||
) * 1000
|
||||
self._last_final_decode_ms = (time.monotonic() - decode_started) * 1000
|
||||
self._busy = False
|
||||
self._condition.notify_all()
|
||||
|
||||
def try_background(self, callback):
|
||||
def try_background(self, callback, *, kind: str):
|
||||
"""Run one useful predecode only when no final is active or waiting."""
|
||||
if kind not in {"rolling", "speculative"}:
|
||||
raise ValueError("unknown background inference kind")
|
||||
with self._condition:
|
||||
if self._busy or self._final_waiters:
|
||||
self._background_skips += 1
|
||||
@ -105,9 +108,12 @@ class InferenceGate:
|
||||
return callback()
|
||||
finally:
|
||||
with self._condition:
|
||||
self._last_background_decode_ms = (
|
||||
time.monotonic() - decode_started
|
||||
) * 1000
|
||||
elapsed_ms = (time.monotonic() - decode_started) * 1000
|
||||
self._last_background_decode_ms = elapsed_ms
|
||||
if kind == "rolling":
|
||||
self._last_rolling_decode_ms = elapsed_ms
|
||||
else:
|
||||
self._last_speculative_decode_ms = elapsed_ms
|
||||
self._busy = False
|
||||
self._condition.notify_all()
|
||||
|
||||
@ -119,8 +125,10 @@ class InferenceGate:
|
||||
"final_waiters": self._final_waiters,
|
||||
"last_final_wait_ms": round(self._last_final_wait_ms, 1),
|
||||
"last_final_decode_ms": round(self._last_final_decode_ms, 1),
|
||||
"last_background_decode_ms": round(
|
||||
self._last_background_decode_ms, 1
|
||||
"last_background_decode_ms": round(self._last_background_decode_ms, 1),
|
||||
"last_rolling_decode_ms": round(self._last_rolling_decode_ms, 1),
|
||||
"last_speculative_decode_ms": round(
|
||||
self._last_speculative_decode_ms, 1
|
||||
),
|
||||
"background_skips": self._background_skips,
|
||||
}
|
||||
@ -128,6 +136,7 @@ class InferenceGate:
|
||||
|
||||
INFERENCE_GATE = InferenceGate()
|
||||
STARTUP_WARMUP_MS: float | None = None
|
||||
STARTUP_ROLLING_WARMUP_MS: float | None = None
|
||||
|
||||
|
||||
def _repetitive_token(token: str) -> bool:
|
||||
@ -264,20 +273,19 @@ def _warm_model(model: object) -> float:
|
||||
return (time.monotonic() - started) * 1000
|
||||
|
||||
|
||||
def _transcribe_pcm_rolling(
|
||||
model: object, pcm: bytes, language: str
|
||||
) -> dict | None:
|
||||
def _transcribe_pcm_rolling(model: object, pcm: bytes, language: str) -> dict | None:
|
||||
"""Decode a rolling window only when the single GPU worker is immediately free.
|
||||
|
||||
Partial work is disposable. It must never form an inference backlog ahead of
|
||||
a final utterance or another user's request, so a busy model means this
|
||||
snapshot is skipped and a later audio frame may try again.
|
||||
speech epoch is skipped rather than retried.
|
||||
"""
|
||||
temp_path = ""
|
||||
try:
|
||||
temp_path = _write_pcm_wav(pcm)
|
||||
return INFERENCE_GATE.try_background(
|
||||
lambda: _decode_path(model, temp_path, language)
|
||||
lambda: _decode_path(model, temp_path, language),
|
||||
kind="rolling",
|
||||
)
|
||||
finally:
|
||||
if temp_path:
|
||||
@ -295,7 +303,8 @@ def _transcribe_pcm_speculative(
|
||||
try:
|
||||
temp_path = _write_pcm_wav(pcm)
|
||||
return INFERENCE_GATE.try_background(
|
||||
lambda: _decode_path(model, temp_path, language)
|
||||
lambda: _decode_path(model, temp_path, language),
|
||||
kind="speculative",
|
||||
)
|
||||
finally:
|
||||
if temp_path:
|
||||
@ -462,9 +471,15 @@ def _pcm_rms(pcm: bytes) -> float:
|
||||
class StreamingTranscription:
|
||||
"""Own one turn's bounded PCM, VAD state and speculative Whisper work."""
|
||||
|
||||
def __init__(self, connection: WebSocketConnection, model: object) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
connection: WebSocketConnection,
|
||||
model: object,
|
||||
rolling_model: object | None = None,
|
||||
) -> None:
|
||||
self.connection = connection
|
||||
self.model = model
|
||||
self.rolling_model = rolling_model if rolling_model is not None else model
|
||||
self.turn_id = ""
|
||||
self.language = "auto"
|
||||
self._pcm = bytearray()
|
||||
@ -489,6 +504,7 @@ class StreamingTranscription:
|
||||
self._rolling_previous = ""
|
||||
self._rolling_previous_window_start = 0
|
||||
self._rolling_revision = 0
|
||||
self._rolling_attempted = False
|
||||
|
||||
def _response(self, message_type: str, **values: object) -> dict:
|
||||
return {"type": message_type, "turn_id": self.turn_id, **values}
|
||||
@ -543,7 +559,7 @@ class StreamingTranscription:
|
||||
start_ms = start * 1000 // (STREAM_SAMPLE_RATE * STREAM_SAMPLE_WIDTH)
|
||||
return pcm, start_ms
|
||||
|
||||
def _reset_rolling_history_locked(self) -> None:
|
||||
def _reset_rolling_history_locked(self, *, rearm: bool) -> None:
|
||||
"""Discard provisional agreement after the client's speech epoch changes."""
|
||||
self._rolling_previous = ""
|
||||
self._rolling_previous_window_start = 0
|
||||
@ -551,10 +567,10 @@ class StreamingTranscription:
|
||||
self._rolling_speech_bytes = 0
|
||||
self._rolling_last_speech_bytes = 0
|
||||
self._rolling_last_started_at = 0.0
|
||||
if rearm:
|
||||
self._rolling_attempted = False
|
||||
|
||||
def _begin_rolling_locked(
|
||||
self, now: float
|
||||
) -> tuple[bytes, int, int, int] | None:
|
||||
def _begin_rolling_locked(self, now: float) -> tuple[bytes, int, int, int] | None:
|
||||
"""Reserve one due rolling decode without ever queuing a second one."""
|
||||
total_bytes = len(self._pcm)
|
||||
minimum_bytes = _pcm_bytes_for_ms(ROLLING_MIN_AUDIO_MS)
|
||||
@ -564,17 +580,20 @@ class StreamingTranscription:
|
||||
)
|
||||
if (
|
||||
self._rolling_inflight
|
||||
or self._rolling_attempted
|
||||
or self._rolling_speech_bytes < minimum_bytes
|
||||
or self._rolling_speech_bytes - self._rolling_last_speech_bytes
|
||||
< required_new_bytes
|
||||
or (
|
||||
self._rolling_last_started_at
|
||||
and (now - self._rolling_last_started_at) * 1000
|
||||
< ROLLING_INTERVAL_MS
|
||||
and (now - self._rolling_last_started_at) * 1000 < ROLLING_INTERVAL_MS
|
||||
)
|
||||
):
|
||||
return None
|
||||
pcm, window_start_ms = self._rolling_snapshot_locked()
|
||||
# Reserve the epoch before spawning. A busy gate, decode error or stale
|
||||
# result must not create a retry loop that can collide with the final.
|
||||
self._rolling_attempted = True
|
||||
self._rolling_inflight = True
|
||||
self._rolling_last_started_at = now
|
||||
self._rolling_last_speech_bytes = self._rolling_speech_bytes
|
||||
@ -592,7 +611,11 @@ class StreamingTranscription:
|
||||
def worker() -> None:
|
||||
payload: dict | None = None
|
||||
try:
|
||||
payload = _transcribe_pcm_rolling(self.model, pcm, self.language)
|
||||
payload = _transcribe_pcm_rolling(
|
||||
self.rolling_model,
|
||||
pcm,
|
||||
self.language,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Partial inference is best-effort. The canonical commit path
|
||||
# remains authoritative and reports its own actionable errors.
|
||||
@ -670,7 +693,7 @@ class StreamingTranscription:
|
||||
self._epoch += 1
|
||||
self._cached_key = ""
|
||||
self._cached_payload = None
|
||||
self._reset_rolling_history_locked()
|
||||
self._reset_rolling_history_locked(rearm=True)
|
||||
self._heard_speech = True
|
||||
self._at_eos = False
|
||||
self._silence_bytes = 0
|
||||
@ -687,7 +710,7 @@ class StreamingTranscription:
|
||||
)
|
||||
if self._silence_bytes >= required and not self._at_eos:
|
||||
self._at_eos = True
|
||||
self._reset_rolling_history_locked()
|
||||
self._reset_rolling_history_locked(rearm=False)
|
||||
auto_speculate = True
|
||||
if auto_speculate:
|
||||
self.speculate()
|
||||
@ -705,7 +728,7 @@ class StreamingTranscription:
|
||||
self._client_active = True
|
||||
self._cached_key = ""
|
||||
self._cached_payload = None
|
||||
self._reset_rolling_history_locked()
|
||||
self._reset_rolling_history_locked(rearm=True)
|
||||
|
||||
def speculate(self) -> None:
|
||||
"""Decode a stable snapshot once; only an unchanged turn may consume it."""
|
||||
@ -846,11 +869,19 @@ class SpeechHandler(BaseHTTPRequestHandler):
|
||||
{
|
||||
"ok": True,
|
||||
"model": MODEL_NAME,
|
||||
"rolling_model": ROLLING_MODEL_NAME,
|
||||
"device": "cuda" if torch.cuda.is_available() else "cpu",
|
||||
"inference": INFERENCE_GATE.snapshot(),
|
||||
"startup": {
|
||||
"warmed": STARTUP_WARMUP_MS is not None,
|
||||
"warmed": (
|
||||
STARTUP_WARMUP_MS is not None
|
||||
and STARTUP_ROLLING_WARMUP_MS is not None
|
||||
),
|
||||
"warmup_ms": round(STARTUP_WARMUP_MS or 0.0, 1),
|
||||
"rolling_warmup_ms": round(
|
||||
STARTUP_ROLLING_WARMUP_MS or 0.0,
|
||||
1,
|
||||
),
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": True,
|
||||
@ -909,6 +940,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
|
||||
session = StreamingTranscription(
|
||||
websocket,
|
||||
self.server.model, # type: ignore[attr-defined]
|
||||
self.server.rolling_model, # type: ignore[attr-defined]
|
||||
)
|
||||
close_code = 1000
|
||||
close_reason = ""
|
||||
@ -1018,16 +1050,32 @@ class SpeechHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def main() -> None:
|
||||
"""Warm Whisper once, then serve concurrent clients through one GPU lock."""
|
||||
global STARTUP_WARMUP_MS
|
||||
global STARTUP_ROLLING_WARMUP_MS, STARTUP_WARMUP_MS
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if not torch.cuda.is_available():
|
||||
raise RuntimeError("CUDA is required for the Atlas Whisper service")
|
||||
print(f"[stt] loading Whisper {MODEL_NAME} into CUDA", flush=True)
|
||||
print(f"[stt] loading Whisper {MODEL_NAME} for final CUDA", flush=True)
|
||||
model = whisper.load_model(MODEL_NAME, device="cuda", download_root=str(CACHE_DIR))
|
||||
print(f"[stt] loading Whisper {ROLLING_MODEL_NAME} for rolling CUDA", flush=True)
|
||||
rolling_model = whisper.load_model(
|
||||
ROLLING_MODEL_NAME,
|
||||
device="cuda",
|
||||
download_root=str(CACHE_DIR),
|
||||
)
|
||||
STARTUP_ROLLING_WARMUP_MS = _warm_model(rolling_model)
|
||||
print(
|
||||
"[stt] rolling CUDA warm-up completed in " f"{STARTUP_ROLLING_WARMUP_MS:.1f}ms",
|
||||
flush=True,
|
||||
)
|
||||
# Warm the authoritative model last so the first real final never inherits
|
||||
# lazy CUDA work from either model's startup path.
|
||||
STARTUP_WARMUP_MS = _warm_model(model)
|
||||
print(f"[stt] CUDA warm-up completed in {STARTUP_WARMUP_MS:.1f}ms", flush=True)
|
||||
print(
|
||||
f"[stt] final CUDA warm-up completed in {STARTUP_WARMUP_MS:.1f}ms", flush=True
|
||||
)
|
||||
server = ThreadingHTTPServer((HOST, PORT), SpeechHandler)
|
||||
server.model = model # type: ignore[attr-defined]
|
||||
server.rolling_model = rolling_model # type: ignore[attr-defined]
|
||||
print(f"[stt] ready on {HOST}:{PORT}", flush=True)
|
||||
server.serve_forever(poll_interval=0.25)
|
||||
|
||||
|
||||
@ -638,12 +638,16 @@
|
||||
if(Number.isFinite(revision)&&revision>partialRevision){
|
||||
partialRevision=revision;
|
||||
const stable=String(payload.stable_transcript||'').trim();
|
||||
if(stable&&active&&captureTurnId===turnId&&state==='listening'){
|
||||
const preview=stable.length>72?stable.slice(0,69)+'…':stable;
|
||||
label.textContent='Listening · '+preview;
|
||||
const provisional=String(payload.transcript||'').trim();
|
||||
const visible=stable||provisional;
|
||||
if(visible&&active&&captureTurnId===turnId&&state==='listening'){
|
||||
const preview=visible.length>72?visible.slice(0,69)+'…':visible;
|
||||
label.textContent='Listening · '+preview+(stable?'':' · provisional');
|
||||
if(stable!==lastPreflightText){
|
||||
lastPreflightText=stable;
|
||||
scheduleVoicePreflight(turnId,revision,stable);
|
||||
if(stable){
|
||||
lastPreflightText=stable;
|
||||
scheduleVoicePreflight(turnId,revision,stable);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,13 +78,22 @@
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:root.dark .messages {
|
||||
:root.dark .messages-shell {
|
||||
background-image:
|
||||
linear-gradient(var(--hermes-grid) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--hermes-grid) 1px, transparent 1px),
|
||||
radial-gradient(circle at 76% 22%, rgba(76, 164, 205, 0.055), transparent 30rem),
|
||||
radial-gradient(circle at 22% 78%, var(--hermes-violet-muted), transparent 35rem);
|
||||
background-size: 32px 32px, 32px 32px, auto, auto;
|
||||
background-repeat: repeat, repeat, no-repeat, no-repeat;
|
||||
}
|
||||
|
||||
/* The transcript is an accelerated scroll surface. Painting gradients on it
|
||||
produces stale compositor tiles on Chromium as the scroll offset changes,
|
||||
which looks like a dark rectangle moving along the right edge. Keep the
|
||||
scroll surface transparent and paint the texture on its stationary shell. */
|
||||
:root.dark .messages {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
:root.dark .rail {
|
||||
|
||||
83
dockerfiles/hermes-worker-hux/hux_hook/NOTES.md
Normal file
83
dockerfiles/hermes-worker-hux/hux_hook/NOTES.md
Normal file
@ -0,0 +1,83 @@
|
||||
# hux_hook: wiring notes for the agent runtime patch
|
||||
|
||||
`hux_hook` is a stdlib-only library the Hermes agent process imports. HUX
|
||||
(`hermes-hux-foundation`, loopback `127.0.0.1:8790` in the same pod) is the
|
||||
source of truth; the agent never decides an approval, never reads the tenant
|
||||
ledger directly and never persists raw tool arguments or output.
|
||||
|
||||
## Environment the agent process needs
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `HUX_BASE_URL` | default `http://127.0.0.1:8790`; the service is loopback-only |
|
||||
| `HUX_TENANT_SLOT` | `slot-N`, same value the service was started with (`HUX_TENANT_SLOT` on the service side) |
|
||||
| `HUX_SUBJECT` | `usr_<hash>` of the slot owner; the router derives it, the pod env carries it |
|
||||
| `HUX_WORKER_KEY` | shared key mounted read-only under `/runtime-access`; sent as `X-Hux-Relay-Key` |
|
||||
| `HUX_SURFACE` / `HUX_TRUST` | default `worker` / `worker`; the agent hook is never a human surface (SO-35) |
|
||||
| `HUX_TIMEOUT_SECONDS` | default 5 |
|
||||
|
||||
Headers the client sends (exactly `hux/identity.py`): `X-Hermes-Tenant-Identity`,
|
||||
`X-Hux-Subject`, `X-Hux-Surface`, `X-Hux-Trust`, `X-Hux-Relay-Key`, plus
|
||||
`Idempotency-Key` on creates and `If-Match` on revisioned PUTs.
|
||||
|
||||
## Construction
|
||||
|
||||
```python
|
||||
from hux_hook import HuxClient
|
||||
client = HuxClient(os.environ.get("HUX_BASE_URL", "http://127.0.0.1:8790"),
|
||||
{"tenant_slot": os.environ["HUX_TENANT_SLOT"], "subject": os.environ["HUX_SUBJECT"],
|
||||
"surface": "worker", "trust": "worker"},
|
||||
key=os.environ.get("HUX_WORKER_KEY"), timeout=float(os.environ.get("HUX_TIMEOUT_SECONDS", "5")))
|
||||
```
|
||||
|
||||
One client per process. `client.capabilities()` is cached; call
|
||||
`client.forget_capabilities()` on SIGHUP or when the WebUI reports a flag change.
|
||||
|
||||
## Where each call goes in the tool loop
|
||||
|
||||
1. Run start: `emit(client, conversation_id, "run.started", "...", run_id=run_id)`.
|
||||
2. Immediately before executing any tool that is not read-only:
|
||||
`d = before_tool(client, run_id, conversation_id, tool_name, arguments, capability, external, risk)`.
|
||||
- `d.proceed is True`: execute now, with exactly the `arguments` object that was hashed.
|
||||
Re-serialising or "normalising" arguments after the gate breaks SO-37.
|
||||
- `d.reason == "approval_required"`: do not execute. Surface `d.approval_id` to the UI
|
||||
(chat posts `POST /hux/v1/approvals/{id}` with `once|session|always|deny` from a human surface),
|
||||
park the turn, and call `before_tool` again with the same arguments after the decision.
|
||||
The idempotent replay reaches the gate; a `once` approval releases exactly once.
|
||||
- Any other reason (`approval_denied`, `budget_exhausted`, `hux_unavailable`, `autonomy_off`,
|
||||
`service_error:*`, a gate reason): refuse the tool and tell the model why. Never fall back to
|
||||
the upstream gateway approval prompt while `hux.autonomy` is on.
|
||||
Capability mapping is the runtime's job: `read_files`, `write_files`, `shell`, `network`,
|
||||
`web_search`, `send_message`, `memory_write`, `artifact_write`, `spend_tokens`, `delegate`,
|
||||
`deploy`, `external_side_effect` (see `hux/rules.py`). `external=True` for anything that
|
||||
leaves the tenant (messages, network, deploy).
|
||||
3. After every tool: `after_tool(client, run_id, conversation_id, tool_name, ok, bytes_out, turn,
|
||||
argument_hash=canonical_argument_hash(tool_name, arguments), duration_ms=..., exit_code=...)`
|
||||
then `record_spend(client, run_id, conversation_id, tool_calls=1, tokens=<delta>)`.
|
||||
Both are best-effort: they return `None` on failure and never raise.
|
||||
4. Delegation: `record_spend(..., delegations=1)` / `subagents=1` when spawning; the child run
|
||||
uses its own `run_id` and the same `conversation_id`.
|
||||
5. Memory: before proposing a memory write call `memory_gate(client, conversation_id)`;
|
||||
`False` means do not even propose. The write itself still goes through `POST /hux/v1/memory`,
|
||||
which enforces forget, disable and topic rules server-side.
|
||||
6. Stop: when the user cancels, kill the tool processes, then
|
||||
`receipt = on_stop(client, run_id, conversation_id, process_registry_empty=<real registry check>,
|
||||
side_effects=[{"description": ..., "reverted": bool}, ...])`. `None` means no receipt exists and
|
||||
the stop is NOT done: retry, and never report "cancelled" to the user without a receipt (SO-41).
|
||||
`process_registry_empty` must come from the gateway's process registry, not from a timer.
|
||||
7. Run end: `emit(..., "run.completed" | "run.failed", ...)`.
|
||||
|
||||
## What must never happen
|
||||
|
||||
- Raw arguments, file contents, command output, prompts or secrets in any `summary`, `detail`
|
||||
or `evidence`. The library only ever sends tool name, capability, hash, byte counts, status.
|
||||
- Deciding an approval with the worker identity (the service answers 403; do not retry as `chat`).
|
||||
- Executing a tool after `proceed=False` for any reason, including HUX being unreachable.
|
||||
- Logging `HuxServiceError` bodies with request payloads: exceptions carry status/code/message only.
|
||||
|
||||
## Routes used
|
||||
|
||||
`GET /hux/v1/capabilities`, `POST /hux/v1/approvals`, `POST /hux/v1/runs/{id}/gate`,
|
||||
`POST /hux/v1/runs/{id}/budget`, `POST /hux/v1/runs/{id}/stop`,
|
||||
`POST /hux/v1/conversations/{id}/events`, `GET /hux/v1/privacy/policy`,
|
||||
`GET /hux/v1/conversations/{id}`.
|
||||
24
dockerfiles/hermes-worker-hux/hux_hook/__init__.py
Normal file
24
dockerfiles/hermes-worker-hux/hux_hook/__init__.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Agent-side hook library for the per-tenant HUX service.
|
||||
|
||||
Stdlib only. The Hermes agent process (tenant pod or Worker) calls these
|
||||
functions around its tool loop so that HUX, not the agent, decides approvals,
|
||||
gates, budgets, stop receipts and what lands on the activity timeline.
|
||||
Side effects fail closed when the service is unreachable; telemetry fails open.
|
||||
"""
|
||||
|
||||
from hux_hook.client import HuxClient, HuxServiceError, HuxUnavailable
|
||||
from hux_hook.hooks import (
|
||||
Decision,
|
||||
after_tool,
|
||||
before_tool,
|
||||
canonical_argument_hash,
|
||||
emit,
|
||||
memory_gate,
|
||||
on_stop,
|
||||
record_spend,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Decision", "HuxClient", "HuxServiceError", "HuxUnavailable", "after_tool", "before_tool",
|
||||
"canonical_argument_hash", "emit", "memory_gate", "on_stop", "record_spend",
|
||||
]
|
||||
178
dockerfiles/hermes-worker-hux/hux_hook/client.py
Normal file
178
dockerfiles/hermes-worker-hux/hux_hook/client.py
Normal file
@ -0,0 +1,178 @@
|
||||
"""Thin urllib client for the HUX service on the pod loopback.
|
||||
|
||||
The client owns three things: the identity headers ``hux.identity`` expects,
|
||||
the mapping of ``hux.error.v1`` bodies to ``HuxServiceError``, and the
|
||||
per-process capabilities cache. It never logs or embeds request bodies in
|
||||
exceptions (SO-07, SO-11): an error carries status, code and the service's
|
||||
own message only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
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"
|
||||
DEFAULT_BASE_URL = "http://127.0.0.1:8790"
|
||||
MESSAGE_MAX = 280
|
||||
|
||||
|
||||
class HuxServiceError(Exception):
|
||||
"""The service answered with a ``hux.error.v1`` body (or a non-JSON failure)."""
|
||||
|
||||
def __init__(self, status: int, code: str, message: str) -> None:
|
||||
super().__init__(f"{status} {code}: {message}")
|
||||
self.status = int(status)
|
||||
self.code = str(code)
|
||||
self.message = str(message)[:MESSAGE_MAX]
|
||||
|
||||
|
||||
class HuxUnavailable(HuxServiceError):
|
||||
"""The service could not be reached at all; callers decide open or closed."""
|
||||
|
||||
def __init__(self, message: str = "hux service unreachable") -> None:
|
||||
super().__init__(0, "unavailable", message)
|
||||
|
||||
|
||||
class HuxResponse:
|
||||
"""Status, parsed JSON body and headers of one answer."""
|
||||
|
||||
def __init__(self, status: int, body: Any, headers: Mapping[str, str]) -> None:
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.headers = {k.lower(): v for k, v in headers.items()}
|
||||
|
||||
def header(self, name: str) -> str:
|
||||
"""Case-insensitive header lookup; empty when absent."""
|
||||
return self.headers.get(name.lower(), "")
|
||||
|
||||
|
||||
def _error_from(status: int, body: Any) -> HuxServiceError:
|
||||
if isinstance(body, dict) and body.get("schema") == "hux.error.v1":
|
||||
return HuxServiceError(int(body.get("status", status)), str(body.get("code", "invalid")), str(body.get("message", "")))
|
||||
return HuxServiceError(status, "invalid", f"unexpected response {status}")
|
||||
|
||||
|
||||
class HuxClient:
|
||||
"""One tenant identity talking to one HUX base URL.
|
||||
|
||||
``identity`` carries ``tenant_slot``, ``subject``, ``surface`` and
|
||||
``trust``; ``key`` is the relay or worker shared key when ``trust`` needs
|
||||
one. The agent hook normally runs as ``surface=worker, trust=worker``.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str = DEFAULT_BASE_URL, identity: Mapping[str, str] | None = None,
|
||||
key: str | None = None, timeout: float = 5) -> None:
|
||||
identity = dict(identity or {})
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.identity = {
|
||||
"tenant_slot": str(identity.get("tenant_slot", "")), "subject": str(identity.get("subject", "")),
|
||||
"surface": str(identity.get("surface", "worker")), "trust": str(identity.get("trust", "worker")),
|
||||
}
|
||||
self._key = key
|
||||
self.timeout = timeout
|
||||
self._capabilities: dict[str, Any] | None = None
|
||||
self._guard = threading.Lock()
|
||||
|
||||
# -- transport -------------------------------------------------------------
|
||||
|
||||
def headers(self, extra: Mapping[str, str] | None = None) -> dict[str, str]:
|
||||
"""Identity headers exactly as ``hux.identity.resolve`` reads them, plus ``extra``."""
|
||||
out = {
|
||||
HEADER_SLOT: self.identity["tenant_slot"], HEADER_SUBJECT: self.identity["subject"],
|
||||
HEADER_SURFACE: self.identity["surface"], HEADER_TRUST: self.identity["trust"],
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if self._key:
|
||||
out[HEADER_KEY] = self._key
|
||||
for name, value in (extra or {}).items():
|
||||
if value:
|
||||
out[name] = str(value)
|
||||
return out
|
||||
|
||||
def request(self, method: str, path: str, body: Any = None, *, idempotency_key: str | None = None,
|
||||
if_match: int | str | None = None, query: Mapping[str, str] | None = None) -> HuxResponse:
|
||||
"""Send one request; raise ``HuxServiceError`` on 4xx/5xx and ``HuxUnavailable`` on transport failure."""
|
||||
extra: dict[str, str] = {}
|
||||
if idempotency_key:
|
||||
extra["Idempotency-Key"] = idempotency_key
|
||||
if if_match is not None:
|
||||
extra["If-Match"] = str(if_match)
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
|
||||
extra["Content-Type"] = "application/json"
|
||||
url = self.base_url + path
|
||||
if query:
|
||||
url += "?" + "&".join(f"{k}={urllib.request.quote(str(v), safe='')}" for k, v in query.items())
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=self.headers(extra))
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as raw: # noqa: S310 - loopback only
|
||||
response = HuxResponse(raw.status, _decode(raw.read()), dict(raw.headers.items()))
|
||||
except urllib.error.HTTPError as error:
|
||||
payload = _decode(error.read())
|
||||
raise _error_from(error.code, payload) from None
|
||||
except (urllib.error.URLError, OSError, TimeoutError) as error:
|
||||
raise HuxUnavailable(f"hux service unreachable: {type(error).__name__}") from None
|
||||
except http.client.InvalidURL:
|
||||
# http.client refuses control characters and spaces in the path; treat it as our own bad request.
|
||||
raise HuxServiceError(400, "invalid", "malformed request path") from None
|
||||
except http.client.HTTPException as error:
|
||||
raise HuxUnavailable(f"hux service unreachable: {type(error).__name__}") from None
|
||||
if response.status >= 400:
|
||||
raise _error_from(response.status, response.body)
|
||||
return response
|
||||
|
||||
def get(self, path: str, query: Mapping[str, str] | None = None) -> HuxResponse:
|
||||
"""``GET path``."""
|
||||
return self.request("GET", path, query=query)
|
||||
|
||||
def post(self, path: str, body: Any, idempotency_key: str | None = None) -> HuxResponse:
|
||||
"""``POST path`` with an optional ``Idempotency-Key``."""
|
||||
return self.request("POST", path, body, idempotency_key=idempotency_key)
|
||||
|
||||
def put(self, path: str, body: Any, if_match: int | str | None = None) -> HuxResponse:
|
||||
"""``PUT path`` with an optional ``If-Match`` revision."""
|
||||
return self.request("PUT", path, body, if_match=if_match)
|
||||
|
||||
# -- capabilities ------------------------------------------------------------
|
||||
|
||||
def capabilities(self, refresh: bool = False) -> dict[str, Any]:
|
||||
"""Which cards are on, cached per process; unreachable or off means every card reads as off."""
|
||||
with self._guard:
|
||||
if self._capabilities is not None and not refresh:
|
||||
return self._capabilities
|
||||
try:
|
||||
body = self.get("/hux/v1/capabilities").body
|
||||
except HuxServiceError as error:
|
||||
return {"reachable": error.code != "unavailable", "cards": {}, "contract_version": ""}
|
||||
cards = {c["card"]: bool(c.get("enabled")) for c in body.get("cards", []) if isinstance(c, dict) and "card" in c}
|
||||
self._capabilities = {"reachable": True, "cards": cards, "contract_version": str(body.get("contract_version", ""))}
|
||||
return self._capabilities
|
||||
|
||||
def card_enabled(self, card: str) -> bool:
|
||||
"""True only when the service answered and reports ``card`` on."""
|
||||
return bool(self.capabilities()["cards"].get(card))
|
||||
|
||||
def forget_capabilities(self) -> None:
|
||||
"""Drop the cache so the next call re-reads flags (used after a reload signal)."""
|
||||
with self._guard:
|
||||
self._capabilities = None
|
||||
|
||||
|
||||
def _decode(raw: bytes) -> Any:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
191
dockerfiles/hermes-worker-hux/hux_hook/hooks.py
Normal file
191
dockerfiles/hermes-worker-hux/hux_hook/hooks.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""Pure hook functions the agent calls around its tool loop.
|
||||
|
||||
Every function takes a ``HuxClient`` and never touches the agent's own state.
|
||||
``before_tool`` is the only call whose answer the agent must obey: it asks for
|
||||
an approval, then asks the gate, and fails closed on any doubt (SO-37, SO-39,
|
||||
SO-40). ``after_tool``, ``record_spend`` and ``emit`` are telemetry: they never
|
||||
raise into the loop and never carry raw arguments or tool output (SO-11).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from hux_hook.client import HuxClient, HuxServiceError
|
||||
|
||||
CARD_EVENTS = "HUX-01"
|
||||
CARD_AUTONOMY = "HUX-05"
|
||||
CARD_PRIVACY = "HUX-10"
|
||||
SPEND_KEYS = ("tokens", "tool_calls", "wall_clock_seconds", "delegations", "spend_units", "subagents")
|
||||
RISKS = ("low", "medium", "high")
|
||||
PATH_SEGMENT = re.compile(r"^[A-Za-z0-9._:-]{1,120}$")
|
||||
KEY_CHARS = re.compile(r"[^A-Za-z0-9._:-]")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
"""What the agent may do next; ``proceed`` is False unless the gate released the call."""
|
||||
|
||||
proceed: bool
|
||||
approval_id: str | None
|
||||
reason: str
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
"""Canonical JSON: sorted keys, no whitespace, ASCII escapes; key order and formatting cannot change it."""
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
||||
|
||||
|
||||
def canonical_argument_hash(tool_name: str, arguments: Any) -> str:
|
||||
"""``sha256:<hex>`` over the canonical JSON of ``{"tool": name, "arguments": ...}``; the gate compares this exact value."""
|
||||
return "sha256:" + hashlib.sha256(canonical_json({"tool": tool_name, "arguments": arguments})).hexdigest()
|
||||
|
||||
|
||||
def call_ref(tool_name: str, argument_hash: str) -> str:
|
||||
"""Evidence id for one tool call: name plus a hash prefix, never the arguments."""
|
||||
return f"{KEY_CHARS.sub('-', tool_name)[:40]}:{argument_hash[7:23]}"
|
||||
|
||||
|
||||
def idempotency_key(run_id: str, kind: str, token: str) -> str:
|
||||
"""A key that satisfies the contract pattern whatever the run id looks like."""
|
||||
key = KEY_CHARS.sub("-", f"{run_id}:{kind}:{token}")
|
||||
return key[:120] if len(key) >= 8 else (key + ".pad").ljust(8, "0")
|
||||
|
||||
|
||||
def _safe_summary(tool_name: str, capability: str, argument_hash: str, size: int) -> str:
|
||||
return f"{tool_name} ({capability}) arguments {argument_hash[:23]} {size} bytes"[:280]
|
||||
|
||||
|
||||
def before_tool(client: HuxClient, run_id: str, conversation_id: str, tool_name: str, arguments: Any, capability: str,
|
||||
external: bool = False, risk: str = "medium", turn: int | None = None) -> Decision:
|
||||
"""Request (or replay) the approval for this exact call, then ask the gate. Fails closed.
|
||||
|
||||
A pending approval returns ``proceed=False, reason="approval_required"`` with
|
||||
the approval id so the UI can prompt; the agent calls again with the same
|
||||
arguments once the human has decided and the idempotent replay reaches the gate.
|
||||
"""
|
||||
if not PATH_SEGMENT.match(str(run_id)):
|
||||
return Decision(False, None, "invalid_run_id")
|
||||
if not client.card_enabled(CARD_AUTONOMY):
|
||||
return Decision(False, None, "hux_unavailable" if not client.capabilities()["reachable"] else "autonomy_off")
|
||||
argument_hash = canonical_argument_hash(tool_name, arguments)
|
||||
size = len(canonical_json(arguments))
|
||||
body = {
|
||||
"run_id": run_id, "conversation_id": conversation_id, "capability": capability,
|
||||
"request": {
|
||||
"summary": _safe_summary(tool_name, capability, argument_hash, size),
|
||||
"risk": risk if risk in RISKS else "high", "external": bool(external),
|
||||
"evidence": [{"kind": "tool_call", "id": call_ref(tool_name, argument_hash), "hash": argument_hash}],
|
||||
},
|
||||
}
|
||||
try:
|
||||
approval = client.post("/hux/v1/approvals", body, idempotency_key(run_id, "approval", argument_hash[7:39])).body
|
||||
except HuxServiceError as error:
|
||||
return Decision(False, None, _failure_reason(error))
|
||||
approval_id = approval.get("id") if isinstance(approval, dict) else None
|
||||
status = approval.get("status") if isinstance(approval, dict) else None
|
||||
if status == "pending":
|
||||
return Decision(False, approval_id, "approval_required")
|
||||
if status != "approved":
|
||||
return Decision(False, approval_id, f"approval_{status or 'invalid'}")
|
||||
gate_body = {"capability": capability, "argument_hash": argument_hash, "external": bool(external), "conversation_id": conversation_id}
|
||||
try:
|
||||
verdict = client.post(f"/hux/v1/runs/{run_id}/gate", gate_body).body
|
||||
except HuxServiceError as error:
|
||||
return Decision(False, approval_id, _failure_reason(error))
|
||||
if not isinstance(verdict, dict) or verdict.get("proceed") is not True:
|
||||
reason = verdict.get("reason", "gate_blocked") if isinstance(verdict, dict) else "gate_invalid"
|
||||
return Decision(False, approval_id, str(reason)[:280])
|
||||
return Decision(True, str(verdict.get("approval_id") or approval_id), "released")
|
||||
|
||||
|
||||
def _failure_reason(error: HuxServiceError) -> str:
|
||||
if error.code in {"unavailable", "budget_exhausted", "flag_off", "approval_required"}:
|
||||
return "hux_unavailable" if error.code == "unavailable" else error.code
|
||||
return f"service_error:{error.code}"
|
||||
|
||||
|
||||
def emit(client: HuxClient, conversation_id: str, kind: str, summary: str, detail: dict[str, Any] | None = None,
|
||||
evidence: list[dict[str, Any]] | 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:
|
||||
"""Best-effort activity event; returns the stored record, or None when it could not be recorded."""
|
||||
body: dict[str, Any] = {"kind": kind, "summary": str(summary)[:280], "sensitivity": sensitivity}
|
||||
if detail:
|
||||
body["detail"] = detail
|
||||
if evidence:
|
||||
body["evidence"] = evidence
|
||||
if run_id:
|
||||
body["run_id"] = run_id
|
||||
if turn is not None:
|
||||
body["turn"] = int(turn)
|
||||
if correlation_id:
|
||||
body["correlation_id"] = correlation_id
|
||||
try:
|
||||
response = client.post(f"/hux/v1/conversations/{conversation_id}/events", body, idempotency_key)
|
||||
except (HuxServiceError, TypeError, ValueError):
|
||||
return None
|
||||
return response.body if isinstance(response.body, dict) else None
|
||||
|
||||
|
||||
def after_tool(client: HuxClient, run_id: str, conversation_id: str, tool_name: str, ok: bool, bytes_out: int,
|
||||
turn: int | None = None, argument_hash: str | None = None, duration_ms: int | None = None,
|
||||
exit_code: int | None = None) -> dict[str, Any] | None:
|
||||
"""Emit ``tool.result`` with status and sizes only; the output itself never leaves the agent."""
|
||||
detail: dict[str, Any] = {"tool": tool_name, "ok": bool(ok), "bytes": max(0, int(bytes_out))}
|
||||
if duration_ms is not None:
|
||||
detail["duration_ms"] = max(0, int(duration_ms))
|
||||
if exit_code is not None:
|
||||
detail["exit_code"] = int(exit_code)
|
||||
evidence = [{"kind": "tool_call", "id": call_ref(tool_name, argument_hash), "hash": argument_hash}] if argument_hash else None
|
||||
summary = f"{tool_name} {'succeeded' if ok else 'failed'} ({detail['bytes']} bytes)"
|
||||
key = idempotency_key(run_id, "result", f"{turn or 0}:{(argument_hash or 'none')[7:23]}")
|
||||
return emit(client, conversation_id, "tool.result", summary, detail, evidence, run_id=run_id, turn=turn, idempotency_key=key)
|
||||
|
||||
|
||||
def record_spend(client: HuxClient, run_id: str, conversation_id: str | None = None, **increments: int) -> dict[str, Any] | None:
|
||||
"""Add spend to the run budget; returns the new ``hux.budget_state.v1`` or None. Never raises."""
|
||||
body: dict[str, Any] = {k: max(0, int(v)) for k, v in increments.items() if k in SPEND_KEYS}
|
||||
if conversation_id:
|
||||
body["conversation_id"] = conversation_id
|
||||
try:
|
||||
response = client.post(f"/hux/v1/runs/{run_id}/budget", body)
|
||||
except (HuxServiceError, TypeError, ValueError):
|
||||
return None
|
||||
return response.body if isinstance(response.body, dict) else None
|
||||
|
||||
|
||||
def on_stop(client: HuxClient, run_id: str, conversation_id: str | None, process_registry_empty: bool,
|
||||
side_effects: list[dict[str, Any]] | None = None, already_complete: bool = False) -> dict[str, Any] | None:
|
||||
"""Write the cancellation receipt (SO-41). None means no receipt exists and the stop is not done."""
|
||||
body: dict[str, Any] = {"process_registry_empty": bool(process_registry_empty), "side_effects": list(side_effects or [])}
|
||||
if already_complete:
|
||||
body["already_complete"] = True
|
||||
if conversation_id:
|
||||
body["conversation_id"] = conversation_id
|
||||
try:
|
||||
response = client.post(f"/hux/v1/runs/{run_id}/stop", body)
|
||||
except HuxServiceError:
|
||||
return None
|
||||
return response.body if isinstance(response.body, dict) else None
|
||||
|
||||
|
||||
def memory_gate(client: HuxClient, conversation_id: str) -> bool:
|
||||
"""May the agent propose a memory write from this conversation? Conservative False when in doubt.
|
||||
|
||||
Requires the privacy card to answer (SO-27 header is passed through as a
|
||||
hint only); a conversation whose mode is ``private`` never writes (SO-28).
|
||||
The service still enforces forget and disable state on the write itself.
|
||||
"""
|
||||
try:
|
||||
client.get("/hux/v1/privacy/policy")
|
||||
except HuxServiceError:
|
||||
return False
|
||||
try:
|
||||
record = client.get(f"/hux/v1/conversations/{conversation_id}").body
|
||||
except HuxServiceError as error:
|
||||
return error.status == 404
|
||||
return not (isinstance(record, dict) and record.get("mode") == "private")
|
||||
@ -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,
|
||||
|
||||
91
docs/hux/ADR-0001-hux-v1-contract-freeze.md
Normal file
91
docs/hux/ADR-0001-hux-v1-contract-freeze.md
Normal file
@ -0,0 +1,91 @@
|
||||
# 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`).
|
||||
|
||||
Review follow-up (same revision, still additive): `cancellation_receipt`
|
||||
gains optional `revision`. Stored documents in every revisioned family carry
|
||||
the store's `revision`, and a `failed_to_cancel` receipt is the one
|
||||
non-terminal outcome: a later stop that the gateway (worker trust) confirms
|
||||
supersedes it with a revision bump; an identical repeat replays. Only a
|
||||
human actor (router/relay trust on chat, telegram or voice) may write a
|
||||
policy or hold an `allow` grant; `trust: worker` reaches only the routes in
|
||||
`hux.flags.WORKER_ROUTES` (SO-08). External side effects release only
|
||||
against an approval for the same run and argument hash. Cards HUX-06,
|
||||
HUX-09 and HUX-12 declare no routes until they ship.
|
||||
|
||||
## 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.
|
||||
103
docs/hux/HANDOFF.md
Normal file
103
docs/hux/HANDOFF.md
Normal file
@ -0,0 +1,103 @@
|
||||
# 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).
|
||||
|
||||
## Wave A adversarial review — CLOSED
|
||||
|
||||
A fresh reviewer attacked HEAD `124206b7` (tenant isolation, privacy, autonomy, rollback) and reported 13 findings; the repro harness lives outside the repo. Every finding is closed by a regression test that cites it:
|
||||
|
||||
| # | Severity | Finding | Fix commit |
|
||||
|---|---|---|---|
|
||||
| F1 | critical | worker/api trust could `PUT /policy` and self-approve | `dd80e4bd` — policy writes and allow grants are human-surface only |
|
||||
| F2 | high | worker trust could read every tenant record (SO-08) | `dd80e4bd` — `flags.WORKER_ROUTES` allowlist enforced in `Router.dispatch`; unexpected exceptions become audited 500 records |
|
||||
| F3 | high | memory `edit` skipped topic/sensitivity/private-mode gates | `4417475d` — one `_classify` path for proposals and edits |
|
||||
| F4 | high | a `session` approval for an external effect released unrelated later effects | `dd80e4bd` — external effects match only the same run + argument hash; gate conversation from the run, not the body |
|
||||
| F5 | high | seq duplicated after a crash between append and checkpoint | `4417475d` — seq = max(checkpoint, ledger tail + 1) under the lock |
|
||||
| F6 | medium | gate ignored budget exhaustion | `dd80e4bd` |
|
||||
| F7 | medium | stale unconditional write could resurrect a forgotten memory | `4417475d` — re-read under lock, always write with the loaded revision |
|
||||
| F8 | medium | any caller could assert `process_registry_empty`; failed receipts were sticky | `dd80e4bd` — gateway (worker trust) only; failed receipts supersedable |
|
||||
| F9 | medium | secrets stored verbatim in titles/passages/claims/notebooks; forget left the title | `4417475d` — scrub applied; forget blanks the document. Artifact bodies stay verbatim (user-owned) but are forced `restricted` and audited (`this commit`) |
|
||||
| F10 | medium | SO-46/48/53 claimed but absent | threat model amended (`5acf640d`): tracked as open, not claimed |
|
||||
| F11 | low | stored receipts carry `revision` the schema forbade | `dd80e4bd` — optional `revision` on the receipt |
|
||||
| F12 | low | capabilities advertised routes without handlers | `dd80e4bd` — HUX-06/09/12 routes empty until shipped |
|
||||
| F13 | low | misc (healthz version, ghost conversations from notices, idempotency outside the lock, artifact titles not searchable, unnormalised paths, multi-hash once approvals) | `dd80e4bd` + `4417475d` |
|
||||
|
||||
Suite after repairs: 377 tests, 99% line / 99% branch over `hermes-hux-foundation` and `hermes-worker-hux`. Full repo gate: same 13 pre-existing, unrelated failures as `main`.
|
||||
|
||||
## Worker hook library — DONE
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Commit | `532add3a` |
|
||||
| Files | `dockerfiles/hermes-worker-hux/hux_hook/{__init__,client,hooks}.py`, `NOTES.md` (wiring guide for the runtime patch, which is Codex's file) |
|
||||
| Tests | `test_hermes_hux_policy_hook.py` (11), `test_hermes_hux_contract_hook.py` (10) — real service in-process, end-to-end approval → human decision → gate released once, canary never persisted, unreachable service fails closed for side effects |
|
||||
| Codex needs | Agent container env `HUX_BASE_URL`, `HUX_TENANT_SLOT`, `HUX_SUBJECT`, `HUX_WORKER_KEY`; call order per `NOTES.md`: `before_tool` → execute only on `proceed` → `after_tool`; `record_spend` per turn; `on_stop` returning `None` means the stop is not done |
|
||||
|
||||
## Open items (not blockers for Codex integration)
|
||||
|
||||
- SO-46 hash-chained audit, SO-48 single-writer lock, SO-53 rate limits.
|
||||
- Retention scheduler: `privacy.run_retention` is on-demand; Codex decides cron vs sidecar.
|
||||
- `GET /hux/v1/conversations/{id}/privacy` (forgotten / memory_disabled state) would let the hook stop proposing memory earlier.
|
||||
- Search over `message_text`; artifact sharing (`shared_readonly`).
|
||||
- `testing/quality_contract.json` line-limit globs / managed modules for the two new package paths (Codex's file).
|
||||
269
docs/hux/THREAT-MODEL.md
Normal file
269
docs/hux/THREAT-MODEL.md
Normal file
@ -0,0 +1,269 @@
|
||||
# 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.
|
||||
|
||||
|
||||
## Wave A review outcome (2026-08-24)
|
||||
|
||||
A fresh adversarial review of HEAD `124206b7` produced thirteen findings
|
||||
(see `docs/hux/HANDOFF.md`, "Wave A review"). Corrections to the obligations
|
||||
above as a result:
|
||||
|
||||
- SO-08 is amended: `trust: worker` is the agent hook, not only the operator
|
||||
surface. It may call the hook allowlist in `hux/flags.py` (`WORKER_ROUTES`:
|
||||
capabilities, manifest, releases, approvals create, run gate/budget/stop,
|
||||
event emit, privacy policy read, memory proposal and retrieval, source/
|
||||
passage/citation and artifact creation). Everything else is 403. Policy
|
||||
writes and approval decisions are human-surface only (F1, F2).
|
||||
- SO-29 is satisfied by a source test (no `urllib.request`, `requests`,
|
||||
`httpx` or raw `socket` use in the service modules), not by a module-graph
|
||||
assertion: `http.server` legitimately loads `http.client`.
|
||||
- SO-46 (hash-chained audit), SO-48 (`v1/.lock` single-writer) and SO-53
|
||||
(rate limits) are **not implemented** in this increment and are tracked as
|
||||
open items, not claimed controls.
|
||||
- On-disk documents carry a `revision` field the 1.0.0 fixtures did not have;
|
||||
served bodies strip it where the record schema forbids it (F11).
|
||||
@ -320,7 +320,7 @@ spec:
|
||||
requests: {cpu: 250m, memory: 256Mi}
|
||||
limits: {cpu: "1", memory: 2Gi}
|
||||
- name: webui
|
||||
image: registry.bstein.dev/bstein/hermes-webui:git-d254931a14793a2e612bbea36642ee947c5355a9-build-15-release@sha256:440d59b8fb77b95c6bef4e39f73fd08dd2ee4d044bf95725b31da1441c173438 # {"$imagepolicy": "hermes:hermes-webui-release"}
|
||||
image: registry.bstein.dev/bstein/hermes-webui:git-e9a6eba30b5ec1b7cdd4aea816131e840d541353-build-16-release@sha256:2e241688a86ea6ef9cd5fc23bf75f97597552c015d1b68b98d471c8a2fe5c369 # {"$imagepolicy": "hermes:hermes-webui-release"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
|
||||
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,342 @@
|
||||
"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"
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Store revision; bumps when a failed_to_cancel receipt is superseded by a later stop."
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -388,7 +388,7 @@ spec:
|
||||
cpu: "2"
|
||||
memory: 4Gi
|
||||
- name: webui
|
||||
image: registry.bstein.dev/bstein/hermes-webui:git-d254931a14793a2e612bbea36642ee947c5355a9-build-15-release@sha256:440d59b8fb77b95c6bef4e39f73fd08dd2ee4d044bf95725b31da1441c173438 # {"$imagepolicy": "hermes:hermes-webui-release"}
|
||||
image: registry.bstein.dev/bstein/hermes-webui:git-e9a6eba30b5ec1b7cdd4aea816131e840d541353-build-16-release@sha256:2e241688a86ea6ef9cd5fc23bf75f97597552c015d1b68b98d471c8a2fe5c369 # {"$imagepolicy": "hermes:hermes-webui-release"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
|
||||
@ -31,7 +31,7 @@ spec:
|
||||
kubernetes.io/hostname: titan-21
|
||||
containers:
|
||||
- name: stt
|
||||
image: registry.bstein.dev/bstein/hermes-jetson-stt:git-d254931a14793a2e612bbea36642ee947c5355a9-build-15-release@sha256:6460847c21f41a3f2184bb4bac51ccb962bd8f79b5f597b60ff17e3e8859f5b2 # {"$imagepolicy": "hermes:hermes-stt-release"}
|
||||
image: registry.bstein.dev/bstein/hermes-jetson-stt:git-af5ee4697bf064958e6ca363678321e606ce3e61-build-17-release@sha256:fa6a185a6e81f7b4690c7abff350a14fa731fbb455e5ff6c46dc253120cfbd21 # {"$imagepolicy": "hermes:hermes-stt-release"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- {name: http, containerPort: 9000, protocol: TCP}
|
||||
|
||||
146
testing/tests/test_hermes_hux_artifact_auth.py
Normal file
146
testing/tests/test_hermes_hux_artifact_auth.py
Normal file
@ -0,0 +1,146 @@
|
||||
"""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)
|
||||
|
||||
|
||||
def test_secret_bearing_content_is_kept_but_forced_restricted(router):
|
||||
"""Review a2-C / SO-12: user artifacts are not rewritten, but a secret pattern forces restricted sensitivity and is audited."""
|
||||
token = "ghp_" + "A" * 36
|
||||
status, record = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "env", "content": f"TOKEN={token}", "sensitivity": "personal"})
|
||||
assert status == 201 and record["sensitivity"] == "restricted"
|
||||
status, version = call(router, "GET", f"/hux/v1/artifacts/{record['id']}/versions/1")
|
||||
assert status == 200 and token in version["content"]
|
||||
clean = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "clean", "content": "x = 1"})[1]
|
||||
assert clean["sensitivity"] == "personal"
|
||||
status, bumped = call(router, "POST", f"/hux/v1/artifacts/{clean['id']}/versions", {"content": f"key: {token}"}, {**OWNER, "If-Match": "1"})
|
||||
assert status == 201 and bumped["sensitivity"] == "restricted"
|
||||
from hux import audit, store
|
||||
reasons = [r.get("reason") for r in audit.recent(store.TenantStore(router.data_root, identity.resolve(OWNER, {}))) if r["action"].startswith("artifacts.")]
|
||||
assert reasons.count("secret_pattern_restricted") == 2
|
||||
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
|
||||
428
testing/tests/test_hermes_hux_contract_events.py
Normal file
428
testing/tests/test_hermes_hux_contract_events.py
Normal file
@ -0,0 +1,428 @@
|
||||
"""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_f5_crash_between_append_and_checkpoint_never_duplicates_a_seq(tmp_path, monkeypatch):
|
||||
"""F5 / SO-15: with the checkpoint left behind the ledger, the next emit takes max(checkpoint, tail + 1) so paging stays complete."""
|
||||
router = router_for(tmp_path)
|
||||
s = tenant(tmp_path)
|
||||
events.emit(s, ident(), CONV, "message.user", "one")
|
||||
original = store.TenantStore.put
|
||||
|
||||
def crash(self, family, record, expected_revision=None):
|
||||
if family == events.SEQ_FAMILY:
|
||||
raise RuntimeError("simulated crash after the ledger append")
|
||||
return original(self, family, record, expected_revision)
|
||||
|
||||
monkeypatch.setattr(store.TenantStore, "put", crash)
|
||||
with pytest.raises(RuntimeError):
|
||||
events.emit(s, ident(), CONV, "message.user", "two")
|
||||
monkeypatch.undo()
|
||||
assert s.get(events.SEQ_FAMILY, events._seq_id(CONV))["next_seq"] == 2 and events.last_seq(s, CONV) == 2
|
||||
third = events.emit(s, ident(), CONV, "message.user", "three")
|
||||
assert third["seq"] == 3 and s.get(events.SEQ_FAMILY, events._seq_id(CONV))["next_seq"] == 4
|
||||
items = call(router, "GET", f"/hux/v1/conversations/{CONV}/events")[1]["items"]
|
||||
assert [(e["seq"], e["summary"]) for e in items] == [(1, "one"), (2, "two"), (3, "three")]
|
||||
assert [e["seq"] for e in call(router, "GET", f"/hux/v1/conversations/{CONV}/events?after_seq=2")[1]["items"]] == [3]
|
||||
|
||||
|
||||
def test_f5_last_seq_reads_only_the_tail_and_skips_torn_lines(tmp_path):
|
||||
"""F5: a torn trailing line (crash mid-append) and a missing ledger are both handled."""
|
||||
s = tenant(tmp_path)
|
||||
assert events.last_seq(s, "conv_none0001") == 0
|
||||
for index in range(3):
|
||||
events.emit(s, ident(), CONV, "message.user", f"m{index}")
|
||||
path = s.root / events.FAMILY / f"{CONV}.jsonl"
|
||||
with open(path, "ab") as handle:
|
||||
handle.write(b'{"schema":"hux.event.v1","seq":9')
|
||||
assert events.last_seq(s, CONV) == 3
|
||||
with open(path, "ab") as handle:
|
||||
handle.write(b"\n[]\n\n")
|
||||
assert events.last_seq(s, CONV) == 3
|
||||
path.write_bytes(b"\n\n")
|
||||
assert events.last_seq(s, CONV) == 0
|
||||
|
||||
|
||||
def test_conversation_known_accepts_a_ledger_without_a_checkpoint(tmp_path):
|
||||
"""F5 / SO-18: a conversation whose checkpoint write was lost is still this subject's conversation."""
|
||||
s = tenant(tmp_path)
|
||||
events.emit(s, ident(), CONV, "message.user", "one")
|
||||
s.delete(events.SEQ_FAMILY, events._seq_id(CONV))
|
||||
assert events.conversation_known(s, CONV) is True
|
||||
assert events.conversation_known(s, "conv_other0001") is False
|
||||
assert events.emit(s, ident(), CONV, "message.user", "two")["seq"] == 2
|
||||
|
||||
|
||||
def test_f13d_target_path_is_normalised_before_the_workspace_check(tmp_path):
|
||||
"""F13d / SO-11: ``workspace/../.env`` is not a workspace path; a dotted path inside the workspace is stored normalised."""
|
||||
s = tenant(tmp_path)
|
||||
escaped = events.emit(s, ident(), CONV, "tool.call", "ran", {"tool": "bash", "target_path": "/opt/data/workspace/../.env"})
|
||||
assert "target_path" not in escaped["detail"]
|
||||
inside = events.emit(s, ident(), CONV, "tool.call", "ran", {"tool": "bash", "target_path": "/opt/data/workspace/a/../notes.md"})
|
||||
assert inside["detail"]["target_path"] == "/opt/data/workspace/notes.md"
|
||||
assert "target_path" not in events.emit(s, ident(), CONV, "tool.call", "ran", {"tool": "bash", "target_path": 7})["detail"]
|
||||
assert "target_path" not in events.emit(s, ident(), CONV, "tool.call", "ran", {"tool": "bash", "target_path": "/opt/data/workspace"})["detail"]
|
||||
|
||||
|
||||
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
|
||||
436
testing/tests/test_hermes_hux_contract_foundation.py
Normal file
436
testing/tests/test_hermes_hux_contract_foundation.py
Normal file
@ -0,0 +1,436 @@
|
||||
"""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}
|
||||
|
||||
|
||||
# --- F2: worker allowlist, internal errors, health -----------------------------------
|
||||
|
||||
WORKER = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
|
||||
|
||||
|
||||
def test_worker_trust_reaches_only_the_allowlisted_routes(tmp_path):
|
||||
"""F2 (high) / SO-08: ``trust: worker`` gets 403 on every route outside ``flags.WORKER_ROUTES``, before the flag check."""
|
||||
router = _router(tmp_path, environ={"HUX_WORKER_KEY": "wk"})
|
||||
denied = [
|
||||
("GET", "/hux/v1/memory/mem_0001aaaa"), ("GET", "/hux/v1/memory/export"), ("GET", "/hux/v1/conversations"),
|
||||
("GET", "/hux/v1/conversations/conv_0001abcd/events"), ("GET", "/hux/v1/approvals"), ("PUT", "/hux/v1/policy"),
|
||||
("GET", "/hux/v1/artifacts"), ("POST", "/hux/v1/conversations/conv_0001abcd/forget"), ("GET", "/hux/v1/privacy/audit"),
|
||||
]
|
||||
for method, path in denied:
|
||||
status, body = _call(router, method, path, WORKER, b"{}" if method != "GET" else b"")
|
||||
assert (status, body["code"]) == (403, "forbidden"), (method, path)
|
||||
for method, path in [("GET", "/hux/v1/capabilities"), ("GET", "/hux/v1/runs/run_1/budget"), ("GET", "/hux/v1/privacy/policy"), ("GET", "/hux/v1/memory")]:
|
||||
assert _call(router, method, path, WORKER)[0] == 200, (method, path)
|
||||
rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(WORKER, {"HUX_WORKER_KEY": "wk"})))
|
||||
assert [r["outcome"] for r in rows if r["action"] == "memory.read" or r["action"].startswith("policy")][:1] == ["deny"]
|
||||
assert all(r["outcome"] == "deny" for r in rows if r["action"] == "approvals.list")
|
||||
off = _router(tmp_path, flags_value="", environ={"HUX_WORKER_KEY": "wk"})
|
||||
assert _call(off, "GET", "/hux/v1/approvals", WORKER)[0] == 403, "the allowlist answers before the flag does"
|
||||
assert _call(off, "GET", "/hux/v1/capabilities", HEADERS)[0] == 404, "humans still see flag-off as not found"
|
||||
assert flags.worker_may_call("GET", "/hux/v1/releases") and not flags.worker_may_call("GET", "/hux/v1/policy")
|
||||
assert all(any(t == route.template for _, t in flags.WORKER_ROUTES if route.method == _) or (route.method, route.template) not in flags.WORKER_ROUTES for route in router.routes)
|
||||
|
||||
|
||||
def test_unshipped_cards_declare_no_routes():
|
||||
"""F12 (low): HUX-06, HUX-09 and HUX-12 own no routes until they ship, so capabilities never advertises a 404."""
|
||||
assert flags.CARD_ROUTES["HUX-06"] == flags.CARD_ROUTES["HUX-09"] == flags.CARD_ROUTES["HUX-12"] == []
|
||||
|
||||
|
||||
def test_unexpected_handler_exceptions_become_a_500_error_record(tmp_path):
|
||||
"""F2: a non-HuxError in a handler never propagates; the caller sees ``hux.error.v1`` 500 with no detail and the audit says deny."""
|
||||
router = _router(tmp_path)
|
||||
|
||||
def boom(request):
|
||||
raise RuntimeError("secret stack detail")
|
||||
|
||||
router.add("GET", "/hux/v1/boom", "HUX-11", "test.boom", boom)
|
||||
status, body = _call(router, "GET", "/hux/v1/boom")
|
||||
assert status == 500 and body == {"schema": "hux.error.v1", "status": 500, "code": "invalid", "message": "internal error"}
|
||||
assert contracts.validate_record(body, SCHEMAS) == []
|
||||
rows = [(r["action"], r["outcome"], r.get("reason")) for r in audit.recent(store.TenantStore(tmp_path, ident()))]
|
||||
assert rows[-1] == ("test.boom", "deny", "internal error")
|
||||
|
||||
|
||||
def test_healthz_reports_the_contract_version(tmp_path):
|
||||
"""F2: /healthz carries ``flags.CONTRACT_VERSION`` rather than a hard-coded string."""
|
||||
server = serve(_router(tmp_path), "127.0.0.1", 0)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).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", "contract_version": flags.CONTRACT_VERSION}
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def test_no_outbound_network_client_in_the_service(tmp_path):
|
||||
"""F13 / SO-29: no hux module imports urllib.request, requests, httpx or socket; http.server is the only http.* import."""
|
||||
import re
|
||||
banned = re.compile(r"^\s*(?:import|from)\s+(urllib\.request|requests|httpx|socket|http\.client)\b", re.M)
|
||||
for path in sorted((FOUNDATION / "hux").glob("*.py")):
|
||||
assert banned.search(path.read_text()) is None, path
|
||||
http_imports = re.findall(r"^\s*from\s+(http\.\w+)\s+import", path.read_text(), re.M)
|
||||
assert set(http_imports) <= {"http.server"}, (path, http_imports)
|
||||
254
testing/tests/test_hermes_hux_contract_hook.py
Normal file
254
testing/tests/test_hermes_hux_contract_hook.py
Normal file
@ -0,0 +1,254 @@
|
||||
"""HuxClient transport contract and the pure helpers of the agent hook.
|
||||
|
||||
Security obligations exercised: SO-04 (the client emits exactly the identity
|
||||
header vocabulary ``hux.identity`` accepts, with the worker key), SO-07 (errors
|
||||
carry status, code and message only; request bodies are never embedded),
|
||||
SO-27 and SO-28 (memory writes need the privacy card and are refused in a
|
||||
private conversation), SO-37 (the argument hash is stable across key order
|
||||
and whitespace), SO-50 (capabilities are read from the resolved flag chain).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import http.client
|
||||
import io
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
|
||||
HOOK_ROOT = ROOT / "dockerfiles" / "hermes-worker-hux"
|
||||
for entry in (FOUNDATION, HOOK_ROOT):
|
||||
if str(entry) not in sys.path:
|
||||
sys.path.insert(0, str(entry))
|
||||
|
||||
from hux import contracts, identity # noqa: E402
|
||||
from hux.http import serve # noqa: E402
|
||||
from hux.server import build_router # noqa: E402
|
||||
from hux_hook import HuxClient, HuxServiceError, HuxUnavailable, canonical_argument_hash, emit, memory_gate # noqa: E402
|
||||
from hux_hook import client as client_mod # noqa: E402
|
||||
from hux_hook import hooks # noqa: E402
|
||||
|
||||
SCHEMAS = contracts.load_all()
|
||||
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
||||
SUBJECT = "usr_0123456789abcdef"
|
||||
WORKER = {"tenant_slot": "slot-3", "subject": SUBJECT, "surface": "worker", "trust": "worker"}
|
||||
HUMAN = {"tenant_slot": "slot-3", "subject": SUBJECT, "surface": "chat", "trust": "router"}
|
||||
OTHER = {**HUMAN, "subject": "usr_fedcba9876543210"}
|
||||
CANARY = "CANARY-9c1d-SECRET"
|
||||
|
||||
|
||||
def start(tmp_path: Path, flags: str = ALL_ON):
|
||||
router = build_router(tmp_path, {"HUX_FLAGS": flags, "HUX_WORKER_KEY": "wk"})
|
||||
server = serve(router, "127.0.0.1", 0)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
return f"http://127.0.0.1:{server.server_address[1]}", server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live(tmp_path):
|
||||
base, server = start(tmp_path)
|
||||
yield base, tmp_path
|
||||
server.shutdown()
|
||||
|
||||
|
||||
# --- headers and identity ---------------------------------------------------------
|
||||
|
||||
def test_headers_match_the_service_vocabulary():
|
||||
"""SO-04: the exact header names identity.resolve reads, key only when present, extras only when non-empty."""
|
||||
worker = HuxClient("http://127.0.0.1:1/", WORKER, key="wk")
|
||||
sent = worker.headers({"Idempotency-Key": "run:approval:1", "If-Match": ""})
|
||||
assert identity.resolve(sent, {"HUX_WORKER_KEY": "wk"}) == identity.Identity("slot-3", SUBJECT, "worker", "worker")
|
||||
assert sent["Idempotency-Key"] == "run:approval:1" and "If-Match" not in sent
|
||||
assert worker.base_url == "http://127.0.0.1:1"
|
||||
plain = HuxClient(identity={"tenant_slot": "slot-3", "subject": SUBJECT}).headers()
|
||||
assert client_mod.HEADER_KEY not in plain and plain[client_mod.HEADER_TRUST] == "worker"
|
||||
assert HuxClient().identity == {"tenant_slot": "", "subject": "", "surface": "worker", "trust": "worker"}
|
||||
|
||||
|
||||
def test_error_mapping_and_no_body_leak(live):
|
||||
"""SO-07: a hux.error.v1 answer becomes HuxServiceError(status, code, message) and the body never appears in it."""
|
||||
base, _ = live
|
||||
human = HuxClient(base, HUMAN)
|
||||
with pytest.raises(HuxServiceError) as bad:
|
||||
human.post("/hux/v1/approvals", {"conversation_id": "conv_0001abcd", "capability": "nope", "secret": CANARY})
|
||||
assert (bad.value.status, bad.value.code) == (400, "invalid") and CANARY not in str(bad.value)
|
||||
with pytest.raises(HuxServiceError) as unauth:
|
||||
HuxClient(base, WORKER, key="wrong").get("/hux/v1/capabilities")
|
||||
assert unauth.value.code == "unauthorized"
|
||||
with pytest.raises(HuxServiceError) as missing:
|
||||
human.get("/hux/v1/no/such/route")
|
||||
assert (missing.value.status, missing.value.code) == (404, "not_found")
|
||||
assert HuxUnavailable().code == "unavailable" and HuxUnavailable().status == 0
|
||||
assert HuxServiceError(500, "x", "m" * 400).message == "m" * 280
|
||||
|
||||
|
||||
def test_transport_edge_cases(monkeypatch):
|
||||
"""Non-JSON failures, a 4xx delivered without an exception and socket errors map to typed errors."""
|
||||
client = HuxClient("http://127.0.0.1:1", WORKER, key="wk")
|
||||
|
||||
def raise_http(*args, **kwargs):
|
||||
raise urllib.error.HTTPError("u", 502, "bad gateway", {}, io.BytesIO(b"<html>"))
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", raise_http)
|
||||
with pytest.raises(HuxServiceError) as html:
|
||||
client.get("/hux/v1/capabilities")
|
||||
assert (html.value.status, html.value.code) == (502, "invalid")
|
||||
|
||||
class Raw:
|
||||
status = 418
|
||||
headers = {"X-Test": "1"}
|
||||
|
||||
def read(self):
|
||||
return b"{bad json"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **k: Raw())
|
||||
with pytest.raises(HuxServiceError) as teapot:
|
||||
client.put("/hux/v1/policy", {"x": 1}, if_match=3)
|
||||
assert teapot.value.status == 418
|
||||
|
||||
def raise_socket(*args, **kwargs):
|
||||
raise TimeoutError("slow")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", raise_socket)
|
||||
with pytest.raises(HuxUnavailable):
|
||||
client.get("/hux/v1/capabilities", query={"a": "b c"})
|
||||
|
||||
def raise_half_closed(*args, **kwargs):
|
||||
raise http.client.BadStatusLine("gone")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", raise_half_closed)
|
||||
with pytest.raises(HuxUnavailable):
|
||||
client.get("/hux/v1/capabilities")
|
||||
|
||||
|
||||
def test_put_with_if_match_and_get_with_query(live):
|
||||
"""Revisioned writes send If-Match; a stale revision is a conflict; queries reach the service."""
|
||||
base, _ = live
|
||||
human = HuxClient(base, HUMAN)
|
||||
first = human.put("/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe"})
|
||||
assert first.header("ETag") == "1" and first.header("Missing") == ""
|
||||
second = human.put("/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "autonomous"}, if_match=1)
|
||||
assert second.body["revision"] == 2
|
||||
with pytest.raises(HuxServiceError) as stale:
|
||||
human.put("/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe"}, if_match=1)
|
||||
assert stale.value.code == "conflict"
|
||||
assert human.get("/hux/v1/policy", {"scope": "global"}).body["autonomy"] == "autonomous"
|
||||
|
||||
|
||||
# --- capabilities ---------------------------------------------------------------------
|
||||
|
||||
def test_capabilities_cached_per_process(live, monkeypatch):
|
||||
"""SO-50: one capabilities read per process; refresh and forget re-read; failures are not cached."""
|
||||
base, _ = live
|
||||
worker = HuxClient(base, WORKER, key="wk")
|
||||
calls = []
|
||||
real_get = worker.get
|
||||
monkeypatch.setattr(worker, "get", lambda path, query=None: calls.append(path) or real_get(path, query))
|
||||
assert worker.card_enabled("HUX-05") and worker.card_enabled("HUX-01")
|
||||
assert worker.capabilities()["contract_version"] == contracts.load_flags()["contract_version"]
|
||||
assert len(calls) == 1
|
||||
worker.capabilities(refresh=True)
|
||||
worker.forget_capabilities()
|
||||
worker.capabilities()
|
||||
assert len(calls) == 3
|
||||
monkeypatch.setattr(worker, "get", lambda path, query=None: (_ for _ in ()).throw(HuxServiceError(404, "flag_off", "off")))
|
||||
worker.forget_capabilities()
|
||||
assert worker.capabilities() == {"reachable": True, "cards": {}, "contract_version": ""}
|
||||
dead = HuxClient("http://127.0.0.1:1", WORKER, key="wk", timeout=1)
|
||||
assert dead.capabilities()["reachable"] is False and not dead.card_enabled("HUX-11")
|
||||
|
||||
|
||||
# --- events ---------------------------------------------------------------------------
|
||||
|
||||
def test_emit_is_best_effort_and_redaction_safe(live):
|
||||
"""SO-11: detail outside the allowlist is dropped by the service; failures return None and never raise."""
|
||||
base, root = live
|
||||
human = HuxClient(base, HUMAN)
|
||||
conv = human.post("/hux/v1/conversations", {"title": "t"}).body["id"]
|
||||
worker = HuxClient(base, WORKER, key="wk")
|
||||
record = emit(worker, conv, "tool.call", "shell call", {"tool": "shell", "arguments": {"cmd": CANARY}, "argument_bytes": 7},
|
||||
evidence=[{"kind": "run", "id": "run_1"}], run_id="run_1", turn=2, correlation_id="corr-1", idempotency_key="run_1:call:0001")
|
||||
assert contracts.validate_record(record, SCHEMAS) == [] and record["detail"] == {"tool": "shell", "argument_bytes": 7}
|
||||
assert record["provenance"]["actor"] == {"type": "system", "id": "hux-worker"} and record["correlation_id"] == "corr-1"
|
||||
assert emit(worker, conv, "tool.call", "again", idempotency_key="run_1:call:0001")["id"] == record["id"]
|
||||
assert emit(worker, conv, "not.a.kind", "x") is None
|
||||
assert emit(worker, "conv_unknown0001", "tool.call", "x") is None
|
||||
assert emit(worker, conv, "tool.call", "x", {"tool": object()}) is None
|
||||
assert emit(HuxClient(base, OTHER), conv, "tool.call", "cross tenant") is None
|
||||
private = human.post("/hux/v1/conversations", {"title": "p", "mode": "private"}).body["id"]
|
||||
assert emit(worker, private, "tool.call", "private mode writes nothing") is None
|
||||
assert CANARY not in "\n".join(p.read_text(errors="ignore") for p in root.rglob("*") if p.is_file())
|
||||
|
||||
|
||||
# --- memory gate ------------------------------------------------------------------------
|
||||
|
||||
def test_memory_gate(live, tmp_path):
|
||||
"""SO-27, SO-28: allowed only when the privacy card answers and the conversation is not private."""
|
||||
base, _ = live
|
||||
human = HuxClient(base, HUMAN)
|
||||
worker = HuxClient(base, WORKER, key="wk")
|
||||
normal = human.post("/hux/v1/conversations", {"title": "n", "mode": "thoughtful"}).body["id"]
|
||||
private = human.post("/hux/v1/conversations", {"title": "p", "mode": "private"}).body["id"]
|
||||
assert memory_gate(worker, normal) is True
|
||||
assert memory_gate(worker, private) is False
|
||||
assert memory_gate(worker, "conv_notknown01") is True
|
||||
assert memory_gate(worker, "bad id") is False
|
||||
assert memory_gate(HuxClient("http://127.0.0.1:1", WORKER, key="wk", timeout=1), normal) is False
|
||||
off_base, off_server = start(tmp_path / "off", "hux.foundation,hux.projects")
|
||||
try:
|
||||
assert memory_gate(HuxClient(off_base, WORKER, key="wk"), normal) is False
|
||||
finally:
|
||||
off_server.shutdown()
|
||||
|
||||
|
||||
# --- pure helpers -------------------------------------------------------------------------
|
||||
|
||||
def test_hash_is_canonical():
|
||||
"""SO-37: key order, whitespace and nesting order of dict keys do not change the hash; values do."""
|
||||
a = canonical_argument_hash("write", {"path": "n.md", "opts": {"b": 1, "a": [1, 2]}})
|
||||
b = canonical_argument_hash("write", {"opts": {"a": [1, 2], "b": 1}, "path": "n.md"})
|
||||
assert a == b and a.startswith("sha256:") and len(a) == 71
|
||||
assert canonical_argument_hash("write", {"path": "n.md ", "opts": {"b": 1, "a": [1, 2]}}) != a
|
||||
assert canonical_argument_hash("other", {"path": "n.md", "opts": {"b": 1, "a": [1, 2]}}) != a
|
||||
assert canonical_argument_hash("w", "raw string") == canonical_argument_hash("w", "raw string")
|
||||
assert hooks.canonical_json({"z": "é", "a": None}) == b'{"a":null,"z":"\\u00e9"}'
|
||||
|
||||
|
||||
def test_key_and_ref_helpers():
|
||||
"""Idempotency keys always satisfy the contract pattern; call refs never contain arguments."""
|
||||
assert hooks.idempotency_key("r", "a", "") == "r:a:.pad"
|
||||
assert hooks.idempotency_key("r", "a", "x") == "r:a:x.pad"
|
||||
key = hooks.idempotency_key("run id/with spaces", "approval", "f" * 200)
|
||||
assert len(key) == 120 and " " not in key and "/" not in key
|
||||
assert hooks.call_ref("my tool/x", "sha256:" + "ab" * 32) == "my-tool-x:abababababababab"
|
||||
assert hooks._failure_reason(HuxServiceError(404, "flag_off", "")) == "flag_off"
|
||||
assert hooks._failure_reason(HuxServiceError(0, "unavailable", "")) == "hux_unavailable"
|
||||
assert hooks._failure_reason(HuxServiceError(409, "conflict", "")) == "service_error:conflict"
|
||||
|
||||
|
||||
def test_library_is_stdlib_only_small_and_documented():
|
||||
"""Every module ≤ 500 lines, every public function and module documented, no third-party imports."""
|
||||
for path in sorted((HOOK_ROOT / "hux_hook").glob("*.py")):
|
||||
source = path.read_text()
|
||||
assert len(source.splitlines()) <= 500, path
|
||||
tree = ast.parse(source)
|
||||
assert ast.get_docstring(tree), path
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef | ast.ClassDef) and not node.name.startswith("_"):
|
||||
assert ast.get_docstring(node), f"{path.name}:{node.name}"
|
||||
if isinstance(node, ast.Import | ast.ImportFrom):
|
||||
root = (node.names[0].name if isinstance(node, ast.Import) else node.module or "").split(".")[0]
|
||||
assert root in {"http", "json", "threading", "urllib", "collections", "typing", "hashlib", "re", "dataclasses", "hux_hook", "__future__"}, root
|
||||
254
testing/tests/test_hermes_hux_contract_organization.py
Normal file
254
testing/tests/test_hermes_hux_contract_organization.py
Normal file
@ -0,0 +1,254 @@
|
||||
"""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
|
||||
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_f13c_search_finds_conversations_by_artifact_title(router, monkeypatch):
|
||||
"""F13c: an artifact filed under a conversation makes that conversation searchable by the artifact title; the lane may be absent."""
|
||||
conversation = make_conversation(router, title="Plain")
|
||||
other = make_conversation(router, title="Other")
|
||||
status, artifact, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "markdown", "title": "Supplier comparison sheet", "content": "x", "conversation_id": conversation["id"]})
|
||||
assert status == 201, artifact
|
||||
status, body, _ = call(router, "GET", "/hux/v1/search?q=supplier")
|
||||
assert [c["id"] for c in body["items"]] == [conversation["id"]] and body["scores"][conversation["id"]] == 1
|
||||
s = store.TenantStore(router.data_root, identity.resolve(HEADERS, {}))
|
||||
assert organization.artifact_titles(s, conversation) == ["Supplier comparison sheet"] and organization.artifact_titles(s, other) == []
|
||||
# The conversation's own artifact_ids list is indexed as well, without duplicates.
|
||||
s.put(organization.CONVERSATIONS, {**s.get(organization.CONVERSATIONS, other["id"]), "artifact_ids": [artifact["id"], "art_missing0001"]}, 1)
|
||||
assert organization.artifact_titles(s, other) == ["Supplier comparison sheet"]
|
||||
assert sorted(c["id"] for c in call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"]) == sorted([conversation["id"], other["id"]])
|
||||
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_f9_titles_tags_names_and_descriptions_are_secret_scrubbed(router):
|
||||
"""F9 / SO-07: a token pasted into a title, tag, project name or description is scrubbed before it is stored or indexed."""
|
||||
token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
project = make_project(router, name=f"proj {token}", description="password: hunter2")
|
||||
assert token not in project["name"] and project["description"] == "[redacted:password]"
|
||||
# A tag can only be [a-z0-9-], so a scrubbed tag fails the contract instead of being stored.
|
||||
assert call(router, "POST", "/hux/v1/conversations", {"title": "t", "tags": [token]})[0] == 400
|
||||
conversation = make_conversation(router, title=f"token {token}", tags=["ok"])
|
||||
assert conversation["title"] == "token [redacted:github_token]" and conversation["tags"] == ["ok"]
|
||||
status, patched, _ = call(router, "PATCH", f"/hux/v1/conversations/{conversation['id']}", {"title": f"again {token}"}, {**HEADERS, "If-Match": "1"})
|
||||
assert status == 200 and token not in patched["title"]
|
||||
assert call(router, "GET", f"/hux/v1/search?q={token}")[1]["items"] == []
|
||||
leaked = [path for path in Path(router.data_root).rglob("*.json*") if token in path.read_text()]
|
||||
assert leaked == []
|
||||
|
||||
|
||||
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
|
||||
209
testing/tests/test_hermes_hux_memory_repairs.py
Normal file
209
testing/tests/test_hermes_hux_memory_repairs.py
Normal file
@ -0,0 +1,209 @@
|
||||
"""HUX-02 memory review repairs: edit shaping (F3), stale-write resurrection (F7), idempotency race (F13b).
|
||||
|
||||
Security obligations exercised: SO-21 (deny topics never reach memory, even
|
||||
through an edit), SO-22 (no_store and forget are content-free and final),
|
||||
SO-23 (a forgotten entry never retrieves again), SO-28 (private mode refuses
|
||||
memory writes), SO-44 (revision checks hold under concurrency).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
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, identity, memory, privacy, store # noqa: E402
|
||||
from hux.errors import Conflict # 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"])
|
||||
USER = {"proposed_by": "user", "approval_mode": "automatic"}
|
||||
|
||||
|
||||
def ident() -> identity.Identity:
|
||||
return identity.Identity(tenant_slot="slot-3", subject="usr_0123456789abcdef", surface="chat", trust="router")
|
||||
|
||||
|
||||
def router_for(tmp_path):
|
||||
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
|
||||
|
||||
|
||||
def call(router, method, path, body=None, headers=None):
|
||||
raw = json.dumps(body).encode() if body is not None else b""
|
||||
response = router.dispatch(method, path, {**HEADERS, **(headers or {})}, raw)
|
||||
return response.status, response.body
|
||||
|
||||
|
||||
def tenant(tmp_path) -> store.TenantStore:
|
||||
return store.TenantStore(tmp_path, ident())
|
||||
|
||||
|
||||
def conversation(router) -> str:
|
||||
status, body = call(router, "POST", "/hux/v1/conversations", {"title": "tea"})
|
||||
assert status == 201
|
||||
return body["id"]
|
||||
|
||||
|
||||
def active(router, conv, content="I like tea") -> dict:
|
||||
status, body = call(router, "POST", "/hux/v1/memory", {"content": content, "conversation_id": conv, **USER})
|
||||
assert (status, body["status"]) == (201, "active"), body
|
||||
return body
|
||||
|
||||
|
||||
def valid(record) -> dict:
|
||||
assert contracts.validate_record(record, SCHEMAS) == [], record
|
||||
return record
|
||||
|
||||
|
||||
# --- F3: edit goes through the same shaping as a proposal ---------------------------
|
||||
|
||||
def test_f3_edit_with_deny_topic_becomes_no_store_and_leaves_the_original(tmp_path):
|
||||
"""F3 / SO-21 / SO-22: a location + minors edit is refused content-free; the old entry is not superseded."""
|
||||
router = router_for(tmp_path)
|
||||
conv = conversation(router)
|
||||
original = active(router, conv)
|
||||
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "my home address is 12 Main Street; my daughter is 6"}, {"If-Match": "1"})
|
||||
assert status == 202 and valid(body)["status"] == "no_store" and body["content"] == "" and "topic" not in body
|
||||
assert body["sensitivity"] == "restricted" and body["retrievable"] is False and "supersedes" not in body
|
||||
assert body["audit"][0]["note"] == "restricted; topic=location" and "Main Street" not in json.dumps(body)
|
||||
assert body["id"] in memory.tombstoned(tenant(tmp_path))
|
||||
status, again = call(router, "GET", f"/hux/v1/memory/{original['id']}")
|
||||
assert status == 200 and again["status"] == "active" and again["revision"] == 1 and again["content"] == "I like tea"
|
||||
assert [hit["content"] for hit in memory.retrieve(tenant(tmp_path), ["address"])] == []
|
||||
assert [hit["id"] for hit in memory.retrieve(tenant(tmp_path), ["tea"])] == [original["id"]]
|
||||
kinds = [row["kind"] for row in tenant(tmp_path).read("events", conv)]
|
||||
assert kinds[-1] == "memory.suppressed"
|
||||
|
||||
|
||||
def test_f3_edit_with_sensitive_content_is_proposed_with_ask(tmp_path):
|
||||
"""F3 / SO-21: the sensitivity floor from detected topics applies to edits, so a health edit waits for approval."""
|
||||
router = router_for(tmp_path)
|
||||
conv = conversation(router)
|
||||
original = active(router, conv)
|
||||
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "I take lithium for my diagnosis"})
|
||||
assert status == 200 and valid(body)["status"] == "proposed" and body["approval_mode"] == "ask"
|
||||
assert body["sensitivity"] == "sensitive" and body["topic"] == "health" and body["retrievable"] is False
|
||||
assert body["supersedes"] == original["id"] and [row["action"] for row in body["audit"]] == ["edited"]
|
||||
assert call(router, "GET", f"/hux/v1/memory/{original['id']}")[1]["status"] == "forgotten"
|
||||
|
||||
|
||||
def test_f3_edit_scrubs_secrets_and_floors_credentials(tmp_path):
|
||||
"""F3 / SO-07: a secret in edited content is scrubbed and the credentials topic makes the edit no_store."""
|
||||
router = router_for(tmp_path)
|
||||
conv = conversation(router)
|
||||
original = active(router, conv)
|
||||
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"})
|
||||
assert status == 202 and body["status"] == "no_store" and body["sensitivity"] == "restricted"
|
||||
leaked = [path for path in tmp_path.rglob("*.json*") if "ghp_ABCDEFGHIJ" in path.read_text()]
|
||||
assert leaked == []
|
||||
|
||||
|
||||
def test_f3_edit_refuses_private_mode_and_declines_when_memory_disabled(tmp_path):
|
||||
"""F3 / SO-28: private mode is a 403 for edits too; disable_memory_here turns the edit into no_store."""
|
||||
router = router_for(tmp_path)
|
||||
conv = conversation(router)
|
||||
original = active(router, conv)
|
||||
privacy.set_flag(tenant(tmp_path), conv, "memory_disabled", True)
|
||||
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "I like coffee"})
|
||||
assert status == 202 and body["status"] == "no_store" and body["audit"][0]["note"].startswith("memory_disabled")
|
||||
assert call(router, "GET", f"/hux/v1/memory/{original['id']}")[1]["status"] == "active"
|
||||
privacy.set_flag(tenant(tmp_path), conv, "memory_disabled", False)
|
||||
assert call(router, "PATCH", f"/hux/v1/conversations/{conv}", {"mode": "private"})[0] == 200
|
||||
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "I like coffee"})
|
||||
assert status == 403 and body["code"] == "forbidden"
|
||||
assert call(router, "GET", f"/hux/v1/memory/{original['id']}")[1]["status"] == "active"
|
||||
|
||||
|
||||
def test_f3_edit_still_needs_content(tmp_path):
|
||||
router = router_for(tmp_path)
|
||||
original = active(router, conversation(router))
|
||||
assert call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": " "})[0] == 400
|
||||
assert call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "x" * 2001})[0] == 400
|
||||
|
||||
|
||||
# --- F7: a stale record cannot resurrect a forgotten entry ------------------------
|
||||
|
||||
def test_f7_stale_approve_after_forget_is_a_conflict(tmp_path, monkeypatch):
|
||||
"""F7 / SO-22 / SO-44: approve holding a pre-reject snapshot (the a2-B interleaving) is refused and the entry stays rejected."""
|
||||
router = router_for(tmp_path)
|
||||
conv = conversation(router)
|
||||
status, proposed = call(router, "POST", "/hux/v1/memory", {"content": "I take lithium daily", "conversation_id": conv, "proposed_by": "user", "approval_mode": "ask"})
|
||||
assert (status, proposed["status"]) == (201, "proposed")
|
||||
stale = dict(proposed)
|
||||
assert call(router, "POST", f"/hux/v1/memory/{proposed['id']}/reject")[1]["status"] == "rejected"
|
||||
monkeypatch.setattr(memory, "load", lambda store, memory_id, now=None: stale)
|
||||
status, body = call(router, "POST", f"/hux/v1/memory/{proposed['id']}/approve")
|
||||
assert status == 409 and body["code"] == "conflict"
|
||||
monkeypatch.undo()
|
||||
status, after = call(router, "GET", f"/hux/v1/memory/{proposed['id']}")
|
||||
assert after["status"] == "rejected" and after["content"] == "" and after["revision"] == 2
|
||||
assert memory.retrieve(tenant(tmp_path), ["lithium"]) == []
|
||||
assert call(router, "GET", "/hux/v1/memory/export")[1]["items"] == []
|
||||
|
||||
|
||||
def test_f7_stale_remove_retrieval_cannot_overwrite_a_forget(tmp_path, monkeypatch):
|
||||
"""F7: the unconditional retrieval branch re-reads the entry under the lock, so the a5 race ends in 409 and no restore works."""
|
||||
router = router_for(tmp_path)
|
||||
conv = conversation(router)
|
||||
entry = active(router, conv, "I take lithium daily")
|
||||
stale = dict(entry)
|
||||
assert call(router, "POST", f"/hux/v1/memory/{entry['id']}/forget")[1]["status"] == "forgotten"
|
||||
monkeypatch.setattr(memory, "load", lambda store, memory_id, now=None: stale)
|
||||
assert call(router, "POST", f"/hux/v1/memory/{entry['id']}/remove_retrieval")[0] == 409
|
||||
monkeypatch.undo()
|
||||
assert call(router, "POST", f"/hux/v1/memory/{entry['id']}/restore_retrieval")[0] == 409
|
||||
assert call(router, "GET", f"/hux/v1/memory/{entry['id']}")[1]["status"] == "forgotten"
|
||||
assert memory.retrieve(tenant(tmp_path), ["lithium"]) == []
|
||||
|
||||
|
||||
def test_f7_transition_checks_if_match_against_the_fresh_copy(tmp_path):
|
||||
"""F7 / SO-44: If-Match is compared with the stored revision, not the caller's stale snapshot."""
|
||||
router = router_for(tmp_path)
|
||||
entry = active(router, conversation(router))
|
||||
s = tenant(tmp_path)
|
||||
memory._set_retrievable(s, entry, False, {"type": "user", "id": "usr_0123456789abcdef"}, 1)
|
||||
try:
|
||||
memory._transition(s, ident(), entry, "forgotten", "forgotten", {"type": "user", "id": "usr_0123456789abcdef"}, expected=1)
|
||||
except Conflict as error:
|
||||
assert "revision 1 does not match current revision 2" in str(error)
|
||||
else:
|
||||
raise AssertionError("stale If-Match must conflict")
|
||||
assert memory.load(s, entry["id"])["status"] == "active"
|
||||
stored = memory._transition(s, ident(), entry, "forgotten", "forgotten", {"type": "user", "id": "usr_0123456789abcdef"})
|
||||
assert stored["status"] == "forgotten" and stored["revision"] == 3
|
||||
|
||||
|
||||
# --- F13b: Idempotency-Key is checked under the lock ------------------------------
|
||||
|
||||
def test_f13b_concurrent_proposals_with_one_key_create_exactly_one_record(tmp_path):
|
||||
"""F13b: eight threads racing one Idempotency-Key produce one memory entry; seven are 200 replays."""
|
||||
router = router_for(tmp_path)
|
||||
results: list[tuple[int, str]] = []
|
||||
barrier = threading.Barrier(8)
|
||||
|
||||
def propose() -> None:
|
||||
barrier.wait(5)
|
||||
status, body = call(router, "POST", "/hux/v1/memory", {"content": "I like tea", **USER}, {"Idempotency-Key": "same-key-0001"})
|
||||
results.append((status, body["id"]))
|
||||
|
||||
threads = [threading.Thread(target=propose) for _ in range(8)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(10)
|
||||
assert sorted(status for status, _ in results) == [200] * 7 + [201]
|
||||
assert len({memory_id for _, memory_id in results}) == 1
|
||||
assert len(call(router, "GET", "/hux/v1/memory")[1]["items"]) == 1
|
||||
assert len(tenant(tmp_path).read(memory.IDEM_FAMILY, "keys")) == 1
|
||||
|
||||
|
||||
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"
|
||||
359
testing/tests/test_hermes_hux_policy_approvals.py
Normal file
359
testing/tests/test_hermes_hux_policy_approvals.py
Normal file
@ -0,0 +1,359 @@
|
||||
"""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")[1]["proceed"] is True, "unknown run: the approval's conversation applies (F4)"
|
||||
call(router, "POST", "/hux/v1/runs/run_elsewhere/budget", {"conversation_id": "conv_elsewhere01", "tokens": 1}, WORKER)
|
||||
status, body = gate(router, run_id="run_elsewhere", capability="shell", conversation_id=CONV)
|
||||
assert body["proceed"] is False, "the run's own conversation wins over the body's claim (F4)"
|
||||
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] == 200, "the body's conversation_id is ignored, never validated (F4)"
|
||||
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()
|
||||
|
||||
|
||||
# --- F4 / F6: external effects, run conversation and budgets at the gate --------------
|
||||
|
||||
def test_external_effects_release_only_for_the_same_run_and_arguments(router):
|
||||
"""F4 (high) / SO-39: a session or always approval for an external effect never releases another run or other arguments."""
|
||||
record = pending(router, "send_message", external=True)
|
||||
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
|
||||
assert gate(router, capability="send_message", external=True)[1]["proceed"] is True
|
||||
assert gate(router, capability="send_message", external=True)[1]["proceed"] is True, "session is reusable for the same run and hash"
|
||||
status, body = gate(router, capability="send_message", external=True, argument_hash=OTHER_HASH)
|
||||
assert body["proceed"] is False and "different arguments" in body["reason"]
|
||||
status, body = gate(router, run_id="run_z", capability="send_message", external=True, conversation_id=CONV)
|
||||
assert body["proceed"] is False and body["reason"] == "no approval for this run and capability"
|
||||
assert gate(router, run_id="run_z", capability="send_message", external=False)[1]["proceed"] is False, "an external approval is not a general one"
|
||||
always = pending(router, "send_message", external=True, run_id="run_y")
|
||||
call(router, "POST", f"/hux/v1/approvals/{always['id']}", {"choice": "always"})
|
||||
assert gate(router, run_id="run_y", capability="send_message", external=True)[1]["proceed"] is True
|
||||
assert gate(router, run_id="run_w", capability="send_message", external=True)[1]["proceed"] is False, "always never spans runs for external effects"
|
||||
|
||||
|
||||
def test_once_approvals_name_exactly_one_tool_call(router):
|
||||
"""F4 / SO-37: a request carrying two tool_call hashes is refused so a once approval designates one hash."""
|
||||
body = request_body("shell")
|
||||
body["request"]["evidence"] = [{"kind": "tool_call", "id": "a", "hash": HASH}, {"kind": "tool_call", "id": "b", "hash": OTHER_HASH}]
|
||||
status, error = call(router, "POST", "/hux/v1/approvals", body, WORKER)
|
||||
assert (status, error["code"]) == (400, "invalid") and "exactly one tool_call" in error["message"]
|
||||
body["request"]["evidence"] = [{"kind": "tool_call", "id": "a", "hash": HASH}, {"kind": "file", "id": "notes.md"}]
|
||||
assert call(router, "POST", "/hux/v1/approvals", body, WORKER)[0] == 201, "other evidence kinds do not count"
|
||||
body["request"]["evidence"] = "not-a-list"
|
||||
assert call(router, "POST", "/hux/v1/approvals", body, WORKER)[0] == 400, "the contract still rejects it, without a 500"
|
||||
|
||||
|
||||
def test_gate_refuses_when_the_run_budget_is_exhausted(router, events):
|
||||
"""F6 (medium) / SO-40: an approved effect still does not proceed once any limit is spent."""
|
||||
record = pending(router, "shell")
|
||||
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
|
||||
assert gate(router, capability="shell")[1]["proceed"] is True
|
||||
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "conversation", "scope_id": CONV}, "autonomy": "safe", "budgets": {"tool_calls_per_run": 2}})
|
||||
call(router, "POST", "/hux/v1/runs/run_9f/budget", {"conversation_id": CONV, "tool_calls": 2}, WORKER)
|
||||
status, body = gate(router, capability="shell")
|
||||
assert body == {"proceed": False, "reason": "budget_exhausted", "exhausted": ["tool_calls_per_run"]}
|
||||
assert events[-1]["kind"] == "budget.exhausted" and events[-1]["run_id"] == "run_9f" and events[-1]["conversation_id"] == CONV
|
||||
assert gate(router, run_id="run_fresh", capability="shell")[1]["proceed"] is True, "the budget is per run"
|
||||
assert budgets.run_conversation(router_store(router), "run_nobody") is None
|
||||
|
||||
|
||||
def router_store(router):
|
||||
return store.TenantStore(router.data_root, identity.resolve(HEADERS, {}))
|
||||
236
testing/tests/test_hermes_hux_policy_hook.py
Normal file
236
testing/tests/test_hermes_hux_policy_hook.py
Normal file
@ -0,0 +1,236 @@
|
||||
"""Agent-side HUX hook driven end-to-end against the real service over HTTP.
|
||||
|
||||
Security obligations exercised: SO-35 (only the human surface decides; the
|
||||
hook's worker identity cannot), SO-36 (a ``once`` approval releases the gate
|
||||
exactly once), SO-37 (the gate compares the canonical argument hash the hook
|
||||
recorded at request time), SO-39 (an external side effect under
|
||||
``autonomous`` still waits for a human), SO-40 (an exhausted budget blocks new
|
||||
approvals), SO-41 (a stop is done only when the receipt exists), SO-11 and
|
||||
SO-07 (raw arguments and tool output never reach the tenant ledger).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
|
||||
HOOK_ROOT = ROOT / "dockerfiles" / "hermes-worker-hux"
|
||||
for entry in (FOUNDATION, HOOK_ROOT):
|
||||
if str(entry) not in sys.path:
|
||||
sys.path.insert(0, str(entry))
|
||||
|
||||
from hux import contracts # noqa: E402
|
||||
from hux.http import serve # noqa: E402
|
||||
from hux.server import build_router # noqa: E402
|
||||
from hux_hook import HuxClient, HuxServiceError, after_tool, before_tool, canonical_argument_hash, on_stop, record_spend # noqa: E402
|
||||
|
||||
SCHEMAS = contracts.load_all()
|
||||
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
||||
SUBJECT = "usr_0123456789abcdef"
|
||||
IDENTITY = {"tenant_slot": "slot-3", "subject": SUBJECT, "surface": "worker", "trust": "worker"}
|
||||
HUMAN = {"tenant_slot": "slot-3", "subject": SUBJECT, "surface": "chat", "trust": "router"}
|
||||
CANARY = "CANARY-7f3a-SECRET-VALUE"
|
||||
ARGS = {"path": "notes.md", "content": CANARY}
|
||||
RUN = "run_hook_1"
|
||||
|
||||
|
||||
def start(tmp_path: Path, flags: str = ALL_ON) -> tuple[str, object]:
|
||||
"""Run the real service on an ephemeral loopback port for one test."""
|
||||
router = build_router(tmp_path, {"HUX_FLAGS": flags, "HUX_WORKER_KEY": "wk"})
|
||||
server = serve(router, "127.0.0.1", 0)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
return f"http://127.0.0.1:{server.server_address[1]}", server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(tmp_path):
|
||||
base, server = start(tmp_path)
|
||||
human = HuxClient(base, HUMAN)
|
||||
conversation = human.post("/hux/v1/conversations", {"title": "hook test"}).body["id"]
|
||||
yield {"base": base, "root": tmp_path, "agent": HuxClient(base, IDENTITY, key="wk"), "human": human, "conv": conversation}
|
||||
server.shutdown()
|
||||
|
||||
|
||||
def set_policy(human: HuxClient, autonomy: str, budgets: dict | None = None) -> None:
|
||||
body = {"scope": {"level": "global"}, "autonomy": autonomy}
|
||||
if budgets:
|
||||
body["budgets"] = budgets
|
||||
human.put("/hux/v1/policy", body)
|
||||
|
||||
|
||||
def ledger_text(root: Path) -> str:
|
||||
return "\n".join(p.read_text(errors="ignore") for p in root.rglob("*") if p.is_file())
|
||||
|
||||
|
||||
def events_of(human: HuxClient, conversation: str) -> list[dict]:
|
||||
items = human.get(f"/hux/v1/conversations/{conversation}/events").body["items"]
|
||||
for item in items:
|
||||
assert contracts.validate_record(item, SCHEMAS) == []
|
||||
return items
|
||||
|
||||
|
||||
# --- approval -> pending -> human decision -> gate once ------------------------------
|
||||
|
||||
def test_once_approval_releases_exactly_once(service):
|
||||
"""SO-35, SO-36, SO-37: pending until a human decides, then one release; the second gate is blocked."""
|
||||
agent, human, conv = service["agent"], service["human"], service["conv"]
|
||||
first = before_tool(agent, RUN, conv, "write_file", ARGS, "write_files", risk="low", turn=1)
|
||||
assert (first.proceed, first.reason) == (False, "approval_required")
|
||||
assert first.approval_id and first.approval_id.startswith("apr_")
|
||||
record = human.get(f"/hux/v1/approvals/{first.approval_id}").body
|
||||
assert contracts.validate_record(record, SCHEMAS) == []
|
||||
assert record["request"]["evidence"][0]["hash"] == canonical_argument_hash("write_file", ARGS)
|
||||
assert CANARY not in json.dumps(record)
|
||||
with pytest.raises(HuxServiceError) as denied:
|
||||
agent.post(f"/hux/v1/approvals/{first.approval_id}", {"choice": "once"})
|
||||
assert denied.value.status == 403
|
||||
human.post(f"/hux/v1/approvals/{first.approval_id}", {"choice": "once"})
|
||||
second = before_tool(agent, RUN, conv, "write_file", {"content": CANARY, "path": "notes.md"}, "write_files", risk="low")
|
||||
assert second == second.__class__(True, first.approval_id, "released")
|
||||
third = before_tool(agent, RUN, conv, "write_file", ARGS, "write_files", risk="low")
|
||||
assert third.proceed is False and "consumed" in third.reason
|
||||
kinds = [e["kind"] for e in events_of(human, conv)]
|
||||
assert kinds.count("side_effect.released") == 1 and "side_effect.blocked" in kinds and "approval.requested" in kinds
|
||||
assert CANARY not in ledger_text(service["root"])
|
||||
|
||||
|
||||
def test_different_arguments_after_approval_are_blocked(service):
|
||||
"""SO-37: an approval for one argument hash never releases another."""
|
||||
agent, human, conv = service["agent"], service["human"], service["conv"]
|
||||
pending = before_tool(agent, RUN, conv, "shell", {"cmd": "ls"}, "shell")
|
||||
human.post(f"/hux/v1/approvals/{pending.approval_id}", {"choice": "once"})
|
||||
other = before_tool(agent, RUN, conv, "shell", {"cmd": "rm -rf /"}, "shell")
|
||||
assert other.proceed is False and other.reason == "approval_required"
|
||||
|
||||
|
||||
def test_denied_and_session_choices(service):
|
||||
"""A denial fails closed; a session grant lets a later call in the same conversation auto-approve."""
|
||||
agent, human, conv = service["agent"], service["human"], service["conv"]
|
||||
pending = before_tool(agent, RUN, conv, "shell", {"cmd": "ls"}, "shell")
|
||||
human.post(f"/hux/v1/approvals/{pending.approval_id}", {"choice": "deny"})
|
||||
assert before_tool(agent, RUN, conv, "shell", {"cmd": "ls"}, "shell") == pending.__class__(False, pending.approval_id, "approval_denied")
|
||||
pending2 = before_tool(agent, RUN, conv, "shell", {"cmd": "pwd"}, "shell")
|
||||
human.post(f"/hux/v1/approvals/{pending2.approval_id}", {"choice": "session"})
|
||||
assert before_tool(agent, "run_hook_2", conv, "shell", {"cmd": "whoami"}, "shell").proceed is True
|
||||
|
||||
|
||||
def test_external_side_effect_under_autonomous_still_asks(service):
|
||||
"""SO-39: autonomy never auto-allows an external side effect."""
|
||||
agent, human, conv = service["agent"], service["human"], service["conv"]
|
||||
set_policy(human, "autonomous")
|
||||
decision = before_tool(agent, RUN, conv, "send_email", {"to": "x@example.com"}, "send_message", external=True, risk="high")
|
||||
assert (decision.proceed, decision.reason) == (False, "approval_required")
|
||||
local = before_tool(agent, RUN, conv, "write_file", ARGS, "write_files")
|
||||
assert local.proceed is True and local.reason == "released"
|
||||
assert before_tool(agent, RUN, conv, "write_file", ARGS, "write_files").proceed is False
|
||||
|
||||
|
||||
def test_budget_exhaustion_blocks_new_approvals(service):
|
||||
"""SO-40: once the run budget is spent the hook reports budget_exhausted and never proceeds."""
|
||||
agent, human, conv = service["agent"], service["human"], service["conv"]
|
||||
set_policy(human, "autonomous", {"tool_calls_per_run": 2})
|
||||
state = record_spend(agent, RUN, conv, tool_calls=2, tokens=10, bogus=5)
|
||||
assert state is not None and contracts.validate_record(state, SCHEMAS) == []
|
||||
assert state["exhausted"] == ["tool_calls_per_run"]
|
||||
decision = before_tool(agent, RUN, conv, "write_file", ARGS, "write_files")
|
||||
assert decision == decision.__class__(False, None, "budget_exhausted")
|
||||
assert record_spend(agent, RUN, conv, tokens=-3)["spent"]["tokens"] == 10
|
||||
assert record_spend(agent, RUN, tool_calls=1)["spent"]["tool_calls"] == 3
|
||||
|
||||
|
||||
def test_stop_receipt_is_written_once(service):
|
||||
"""SO-41: the receipt reports what really happened and a repeat returns the same record."""
|
||||
agent, human, conv = service["agent"], service["human"], service["conv"]
|
||||
effects = [{"description": "partial notes.md", "reverted": True}]
|
||||
receipt = on_stop(agent, RUN, conv, process_registry_empty=True, side_effects=effects)
|
||||
assert contracts.validate_record(receipt, SCHEMAS) == []
|
||||
assert receipt["outcome"] == "cancelled" and receipt["side_effects"] == effects
|
||||
assert on_stop(agent, RUN, conv, process_registry_empty=False)["id"] == receipt["id"]
|
||||
failed = on_stop(agent, "run_hook_3", None, process_registry_empty=False)
|
||||
assert failed["outcome"] == "failed_to_cancel" and "conversation_id" not in failed
|
||||
assert on_stop(agent, "run_hook_4", conv, True, already_complete=True)["outcome"] == "already_complete"
|
||||
assert [e["kind"] for e in events_of(human, conv)].count("run.cancelled") == 2
|
||||
|
||||
|
||||
def test_after_tool_records_status_only(service):
|
||||
"""SO-11: tool.result carries sizes and status; the output and arguments never land."""
|
||||
agent, conv = service["agent"], service["conv"]
|
||||
digest = canonical_argument_hash("shell", {"cmd": CANARY})
|
||||
event = after_tool(agent, RUN, conv, "shell", True, 512, turn=3, argument_hash=digest, duration_ms=40, exit_code=0)
|
||||
assert event["kind"] == "tool.result" and event["detail"] == {"tool": "shell", "ok": True, "bytes": 512, "duration_ms": 40, "exit_code": 0}
|
||||
assert event["evidence"][0]["hash"] == digest and event["turn"] == 3
|
||||
replay = after_tool(agent, RUN, conv, "shell", True, 512, turn=3, argument_hash=digest)
|
||||
assert replay["id"] == event["id"]
|
||||
plain = after_tool(agent, RUN, conv, "shell", False, -1)
|
||||
assert plain["detail"] == {"tool": "shell", "ok": False, "bytes": 0} and "evidence" not in plain
|
||||
assert CANARY not in ledger_text(service["root"])
|
||||
|
||||
|
||||
def test_bad_ids_and_service_errors_fail_closed(service):
|
||||
"""Malformed run or conversation ids never reach a tool execution."""
|
||||
agent, conv = service["agent"], service["conv"]
|
||||
assert before_tool(agent, "run/../x", conv, "shell", {}, "shell").reason == "invalid_run_id"
|
||||
assert before_tool(agent, RUN, "not an id", "shell", {}, "shell").reason == "service_error:invalid"
|
||||
assert before_tool(agent, RUN, conv, "shell", {}, "teleport").reason == "service_error:invalid"
|
||||
|
||||
|
||||
def test_gate_failures_fail_closed(service, monkeypatch):
|
||||
"""A gate that errors, answers nonsense or says no keeps the tool from running."""
|
||||
agent, human, conv = service["agent"], service["human"], service["conv"]
|
||||
set_policy(human, "autonomous")
|
||||
real_post = agent.post
|
||||
|
||||
def broken_gate(path, body, idempotency_key=None):
|
||||
if path.endswith("/gate"):
|
||||
raise HuxServiceError(500, "invalid", "boom")
|
||||
return real_post(path, body, idempotency_key)
|
||||
|
||||
monkeypatch.setattr(agent, "post", broken_gate)
|
||||
decision = before_tool(agent, RUN, conv, "write_file", ARGS, "write_files")
|
||||
assert decision.proceed is False and decision.reason == "service_error:invalid" and decision.approval_id
|
||||
|
||||
class Odd:
|
||||
body = "not a dict"
|
||||
|
||||
monkeypatch.setattr(agent, "post", lambda path, body, idempotency_key=None: Odd() if path.endswith("/gate") else real_post(path, body, idempotency_key))
|
||||
assert before_tool(agent, RUN, conv, "write_file", ARGS, "write_files").reason == "gate_invalid"
|
||||
|
||||
class NoProceed:
|
||||
body = {"proceed": False}
|
||||
|
||||
monkeypatch.setattr(agent, "post", lambda path, body, idempotency_key=None: NoProceed() if path.endswith("/gate") else real_post(path, body, idempotency_key))
|
||||
assert before_tool(agent, RUN, conv, "write_file", ARGS, "write_files").reason == "gate_blocked"
|
||||
monkeypatch.setattr(agent, "post", lambda path, body, idempotency_key=None: Odd())
|
||||
assert before_tool(agent, RUN, conv, "write_file", ARGS, "write_files").reason == "approval_invalid"
|
||||
|
||||
|
||||
def test_unreachable_service_fails_closed_for_gate_and_open_for_telemetry(tmp_path):
|
||||
"""No service: the gate says no, telemetry returns None, nothing raises into the loop."""
|
||||
base, server = start(tmp_path)
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
agent = HuxClient(base, IDENTITY, key="wk", timeout=1)
|
||||
decision = before_tool(agent, RUN, "conv_0001abcd", "shell", {"cmd": "ls"}, "shell")
|
||||
assert decision == decision.__class__(False, None, "hux_unavailable")
|
||||
assert after_tool(agent, RUN, "conv_0001abcd", "shell", True, 1) is None
|
||||
assert record_spend(agent, RUN, "conv_0001abcd", tool_calls=1) is None
|
||||
assert on_stop(agent, RUN, "conv_0001abcd", True) is None
|
||||
|
||||
|
||||
def test_autonomy_flag_off_is_reported_and_fails_closed(tmp_path):
|
||||
"""SO-50: with hux.autonomy off, capabilities says so and the hook refuses every side effect."""
|
||||
base, server = start(tmp_path, "hux.foundation,hux.activity_timeline,hux.projects")
|
||||
try:
|
||||
agent = HuxClient(base, IDENTITY, key="wk")
|
||||
caps = agent.capabilities()
|
||||
assert caps["reachable"] and caps["cards"]["HUX-11"] and caps["cards"]["HUX-01"] and not caps["cards"]["HUX-05"]
|
||||
decision = before_tool(agent, RUN, "conv_0001abcd", "shell", {"cmd": "ls"}, "shell")
|
||||
assert decision == decision.__class__(False, None, "autonomy_off")
|
||||
finally:
|
||||
server.shutdown()
|
||||
222
testing/tests/test_hermes_hux_policy_matrix.py
Normal file
222
testing/tests/test_hermes_hux_policy_matrix.py
Normal file
@ -0,0 +1,222 @@
|
||||
"""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, errors, 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}
|
||||
|
||||
|
||||
# --- F1: only humans write policy ---------------------------------------------------
|
||||
|
||||
def test_only_a_human_actor_may_write_policy_or_hold_allow_grants(tmp_path):
|
||||
"""F1 (critical): worker and api surfaces cannot rewrite the policy, escalate autonomy or plant allow grants."""
|
||||
router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk", "HUX_RELAY_KEY": "rk"})
|
||||
worker = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
|
||||
api = {**HEADERS, "X-Hux-Surface": "api"}
|
||||
relay = {**HEADERS, "X-Hux-Surface": "telegram", "X-Hux-Trust": "relay", "X-Hux-Relay-Key": "rk"}
|
||||
escalate = {"scope": {"level": "global"}, "autonomy": "autonomous", "grants": [{"capability": "network", "decision": "allow"}]}
|
||||
for headers in (worker, api):
|
||||
status, body, _ = call(router, "PUT", "/hux/v1/policy", escalate, headers)
|
||||
assert (status, body["code"]) == (403, "forbidden"), headers
|
||||
status, body, _ = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe"}, headers)
|
||||
assert status == 403, "even a harmless-looking write is a policy write"
|
||||
status, body, _ = call(router, "GET", "/hux/v1/policy", headers=api)
|
||||
assert status == 200 and body["autonomy"] == "safe" and body["grants"] == [], "nothing leaked through"
|
||||
status, body, _ = call(router, "PUT", "/hux/v1/policy", escalate, relay)
|
||||
assert status == 200 and body["autonomy"] == "autonomous" and body["grants"][0]["granted_by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]}
|
||||
rows = [(r["action"], r["outcome"]) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))) if r["action"] == "policy.write"]
|
||||
assert rows == [("policy.write", "deny")] * 4 + [("policy.write", "allow")]
|
||||
# The helpers assert the invariant even when a caller reaches them without the route.
|
||||
system = identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "worker", "worker")
|
||||
with pytest.raises(errors.Forbidden):
|
||||
policy.normalise_grants([{"capability": "shell", "decision": "allow"}], system)
|
||||
assert policy.normalise_grants([{"capability": "shell", "decision": "deny"}], system)[0]["granted_by"] == {"type": "system", "id": "worker"}
|
||||
with pytest.raises(errors.Forbidden):
|
||||
policy.add_grant(store.TenantStore(tmp_path, system), system, "global", None, "shell", timedelta(hours=1))
|
||||
assert policy.is_human(identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "chat", "router"))
|
||||
assert not policy.is_human(identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "api", "router"))
|
||||
160
testing/tests/test_hermes_hux_policy_receipts.py
Normal file
160
testing/tests/test_hermes_hux_policy_receipts.py
Normal file
@ -0,0 +1,160 @@
|
||||
"""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})
|
||||
assert status == 201 and valid(body)["outcome"] == "cancelled"
|
||||
assert body["requested_by"] == {"type": "system", "id": "worker"}
|
||||
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_only_the_gateway_may_vouch_for_an_empty_registry_and_a_failed_receipt_can_be_superseded(router, events, tmp_path):
|
||||
"""F8 / SO-41: a human surface asserting ``process_registry_empty`` gets ``failed_to_cancel``; the worker's later real cancel supersedes it."""
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "conversation_id": CONV}, HEADERS)
|
||||
assert status == 201 and valid(body)["outcome"] == "failed_to_cancel" and "completed_at" not in body
|
||||
first_requested = body["requested_at"]
|
||||
status, again = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True}, HEADERS)
|
||||
assert (status, again) == (200, body), "an identical failed stop replays"
|
||||
status, done = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "side_effects": [{"description": "x", "reverted": True}]})
|
||||
assert status == 201 and valid(done)["outcome"] == "cancelled" and done["requested_at"] == first_requested and "completed_at" in done
|
||||
assert done["conversation_id"] == CONV and done["requested_by"] == {"type": "system", "id": "worker"}
|
||||
tenant = store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))
|
||||
stored = tenant.get("receipts", done["id"])
|
||||
assert stored["revision"] == 2 and contracts.validate_record(stored, SCHEMAS) == [], "stored receipts carry revision (F11)"
|
||||
status, third = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False})
|
||||
assert (status, third) == (200, done), "a terminal receipt never changes again"
|
||||
rows = [r.get("reason") for r in audit.recent(tenant) if r["action"] == "runs.stop"]
|
||||
assert rows == ["registry_state_not_from_gateway", "replayed", "superseded:cancelled", "replayed"]
|
||||
assert [e["summary"] for e in events] == ["Run stopped: failed_to_cancel (registry_state_not_from_gateway)", "Run stopped: cancelled (cancelled)"]
|
||||
status, body = call(router, "POST", "/hux/v1/runs/run_h/stop", {"already_complete": True}, HEADERS)
|
||||
assert valid(body)["outcome"] == "already_complete", "already_complete needs no registry claim"
|
||||
|
||||
|
||||
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))
|
||||
208
testing/tests/test_hermes_hux_privacy_topics.py
Normal file
208
testing/tests/test_hermes_hux_privacy_topics.py
Normal file
@ -0,0 +1,208 @@
|
||||
"""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)
|
||||
events.emit(tenant(tmp_path), ident(), CONV, "message.user", "hello")
|
||||
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)[1:]
|
||||
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, "document_blanked": False}
|
||||
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
|
||||
|
||||
|
||||
def test_f13a_notice_for_an_unknown_conversation_does_not_create_a_ghost_ledger(tmp_path):
|
||||
"""F13a / SO-18: the notice is stored and scoped, but no event ledger or checkpoint is conjured for an unknown conversation."""
|
||||
router = router_for(tmp_path)
|
||||
s = tenant(tmp_path)
|
||||
status, body, _ = call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "health", "conversation_id": "conv_ghost0001"})
|
||||
assert status == 201 and s.read(privacy.FAMILY, "notices") == [body]
|
||||
assert privacy.conversation_state(s, "conv_ghost0001")["topics"] == ["health"]
|
||||
assert events.conversation_known(s, "conv_ghost0001") is False and s.read(events.FAMILY, "conv_ghost0001") == []
|
||||
assert [(r["action"], r["reason"]) for r in audit.recent(s) if r["action"] == "privacy.notice"] == [("privacy.notice", "unknown_conversation")]
|
||||
assert call(router, "GET", "/hux/v1/conversations/conv_ghost0001/events", HEADERS)[0] == 404
|
||||
|
||||
|
||||
def test_f9_forget_blanks_the_conversation_document(tmp_path):
|
||||
"""F9 / SO-22: forgetting a HUX-03 conversation blanks its title and tags and archives it, so nothing of it is searchable."""
|
||||
router = router_for(tmp_path)
|
||||
status, doc, _ = call(router, "POST", "/hux/v1/conversations", HEADERS, {"title": "Doctor visit notes", "tags": ["health", "private"]})
|
||||
assert status == 201
|
||||
status, body, _ = call(router, "POST", f"/hux/v1/conversations/{doc['id']}/forget", HEADERS)
|
||||
assert status == 200 and body["document_blanked"] is True
|
||||
status, after, _ = call(router, "GET", f"/hux/v1/conversations/{doc['id']}", HEADERS)
|
||||
assert after["title"] == "[forgotten]" and after["tags"] == [] and after["archived"] is True and after["revision"] == 2
|
||||
assert contracts.validate_record(after, SCHEMAS) == []
|
||||
assert call(router, "GET", "/hux/v1/search?q=doctor", HEADERS)[1]["items"] == []
|
||||
assert privacy._blank_document(tenant(tmp_path), "conv_none000001") is False
|
||||
|
||||
|
||||
def test_f9_forget_tolerates_a_missing_organization_lane(tmp_path, monkeypatch):
|
||||
"""F9: without the HUX-03 module there is no document to blank and forget still succeeds."""
|
||||
import hux
|
||||
|
||||
monkeypatch.setitem(sys.modules, "hux.organization", None)
|
||||
monkeypatch.delattr(hux, "organization", raising=False)
|
||||
assert privacy._blank_document(tenant(tmp_path), CONV) is False
|
||||
252
testing/tests/test_hermes_hux_research_citations.py
Normal file
252
testing/tests/test_hermes_hux_research_citations.py
Normal file
@ -0,0 +1,252 @@
|
||||
"""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_f9_source_title_passage_text_and_citation_claim_are_scrubbed(router):
|
||||
"""F9 / SO-07: secrets in free-text research fields never persist; the passage hash covers the scrubbed text."""
|
||||
token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
src = source(router, title=f"notes {token}")
|
||||
assert src["title"] == "notes [redacted:github_token]"
|
||||
psg = passage(router, src["id"], f"password: hunter2 and {token}")
|
||||
assert psg["text"] == "[redacted:password] and [redacted:github_token]"
|
||||
assert psg["hash"] == "sha256:" + hashlib.sha256(psg["text"].encode()).hexdigest()
|
||||
cit = cite(router, [psg["id"]], claim=f"claim {token}", note=f"note {token}")
|
||||
assert token not in cit["claim"] and token not in cit["note"]
|
||||
leaked = [path for path in Path(router.data_root).rglob("*.json*") if token in path.read_text()]
|
||||
assert leaked == []
|
||||
|
||||
|
||||
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"]}
|
||||
174
testing/tests/test_hermes_hux_research_notebook.py
Normal file
174
testing/tests/test_hermes_hux_research_notebook.py
Normal file
@ -0,0 +1,174 @@
|
||||
"""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
|
||||
|
||||
|
||||
def test_f9_notebook_free_text_is_scrubbed(router, evidence):
|
||||
"""F9 / SO-07: question, assumptions, unresolved questions and notes are secret-scrubbed on create and patch."""
|
||||
token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
record = notebook(router, question=f"q {token}", assumptions=[f"a {token}"], unresolved_questions=[f"u {token}"])
|
||||
assert record["question"] == "q [redacted:github_token]" and record["assumptions"] == ["a [redacted:github_token]"]
|
||||
assert record["unresolved_questions"] == ["u [redacted:github_token]"]
|
||||
status, patched, _ = call(router, "PATCH", f"/hux/v1/notebooks/{record['id']}", {"add_notes": [{"text": f"n {token}"}], "assumptions": [f"b {token}"]}, {"If-Match": "1"})
|
||||
assert status == 200 and patched["notes"][0]["text"] == "n [redacted:github_token]" and patched["assumptions"] == ["b [redacted:github_token]"]
|
||||
leaked = [path for path in Path(router.data_root).rglob("*.json*") if token in path.read_text()]
|
||||
assert leaked == []
|
||||
198
testing/tests/test_hermes_stt_rolling_model.py
Normal file
198
testing/tests/test_hermes_stt_rolling_model.py
Normal file
@ -0,0 +1,198 @@
|
||||
"""Release gates for Hermes' disposable tiny rolling transcription model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SERVER = ROOT / "dockerfiles" / "hermes-jetson-stt-server.py"
|
||||
DOCKERFILE = ROOT / "dockerfiles" / "Dockerfile.hermes-jetson-stt"
|
||||
PIPELINE = ROOT / "ci" / "Jenkinsfile.hermes-voice-image"
|
||||
|
||||
|
||||
def _load_server(monkeypatch):
|
||||
"""Import the server without requiring image-only CUDA dependencies."""
|
||||
spec = importlib.util.spec_from_file_location("hermes_stt_dual_model", SERVER)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, "cgi", SimpleNamespace())
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"torch",
|
||||
SimpleNamespace(
|
||||
cuda=SimpleNamespace(is_available=lambda: False, synchronize=lambda: None)
|
||||
),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "whisper", SimpleNamespace())
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class _Connection:
|
||||
"""Capture asynchronous stream responses."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.messages: list[dict] = []
|
||||
self.condition = threading.Condition()
|
||||
|
||||
def send_json(self, payload: dict) -> None:
|
||||
with self.condition:
|
||||
self.messages.append(payload)
|
||||
self.condition.notify_all()
|
||||
|
||||
def wait_for(self, message_type: str, timeout: float = 1.0) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
with self.condition:
|
||||
while time.monotonic() < deadline:
|
||||
for message in self.messages:
|
||||
if message.get("type") == message_type:
|
||||
return message
|
||||
self.condition.wait(deadline - time.monotonic())
|
||||
raise AssertionError(f"missing {message_type}: {self.messages}")
|
||||
|
||||
|
||||
class _Model:
|
||||
"""Return deterministic Whisper output while recording model ownership."""
|
||||
|
||||
def __init__(self, text: str) -> None:
|
||||
self.text = text
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def transcribe(self, path: str, **options: object) -> dict:
|
||||
self.calls.append({"path": path, "options": options})
|
||||
return {
|
||||
"text": self.text,
|
||||
"language": "en",
|
||||
"segments": [
|
||||
{
|
||||
"text": self.text,
|
||||
"no_speech_prob": 0.01,
|
||||
"avg_logprob": -0.1,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _start(session) -> None:
|
||||
session.start(
|
||||
{
|
||||
"type": "start",
|
||||
"turn_id": "dual-model-turn",
|
||||
"format": "pcm_s16le",
|
||||
"sample_rate": 16_000,
|
||||
"language": "auto",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _speech(samples: int = 1_600) -> bytes:
|
||||
return struct.pack(f"<{samples}h", *([12_000] * samples))
|
||||
|
||||
|
||||
def _wait_idle(session) -> None:
|
||||
deadline = time.monotonic() + 1.0
|
||||
with session._lock:
|
||||
while session._rolling_inflight and time.monotonic() < deadline:
|
||||
session._lock.wait(deadline - time.monotonic())
|
||||
assert not session._rolling_inflight
|
||||
|
||||
|
||||
def test_rolling_uses_tiny_once_while_final_remains_small(monkeypatch):
|
||||
module = _load_server(monkeypatch)
|
||||
monkeypatch.setattr(module, "ROLLING_MIN_AUDIO_MS", 100)
|
||||
monkeypatch.setattr(module, "ROLLING_MAX_RESULT_LAG_MS", 5_000)
|
||||
final_model = _Model("authoritative small")
|
||||
rolling_model = _Model("provisional tiny")
|
||||
connection = _Connection()
|
||||
session = module.StreamingTranscription(connection, final_model, rolling_model)
|
||||
_start(session)
|
||||
|
||||
session.append(_speech())
|
||||
partial = connection.wait_for("partial")
|
||||
assert partial["transcript"] == "provisional tiny"
|
||||
assert partial["stable_transcript"] == ""
|
||||
assert len(rolling_model.calls) == 1
|
||||
assert final_model.calls == []
|
||||
|
||||
session.append(_speech())
|
||||
_wait_idle(session)
|
||||
assert len(rolling_model.calls) == 1
|
||||
session.commit()
|
||||
assert connection.wait_for("final")["transcript"] == "authoritative small"
|
||||
assert len(final_model.calls) == 1
|
||||
|
||||
|
||||
def test_eos_does_not_rearm_but_resumed_speech_does(monkeypatch):
|
||||
module = _load_server(monkeypatch)
|
||||
monkeypatch.setattr(module, "ROLLING_MIN_AUDIO_MS", 100)
|
||||
monkeypatch.setattr(module, "ROLLING_MAX_RESULT_LAG_MS", 5_000)
|
||||
final_model = _Model("final")
|
||||
rolling_model = _Model("rolling")
|
||||
session = module.StreamingTranscription(_Connection(), final_model, rolling_model)
|
||||
_start(session)
|
||||
|
||||
session.append(_speech())
|
||||
_wait_idle(session)
|
||||
assert session._rolling_attempted is True
|
||||
session.append(bytes(24_000))
|
||||
assert session._at_eos is True
|
||||
assert session._rolling_attempted is True
|
||||
|
||||
session.resume()
|
||||
assert session._rolling_attempted is False
|
||||
session.append(_speech())
|
||||
_wait_idle(session)
|
||||
assert len(rolling_model.calls) == 2
|
||||
|
||||
|
||||
def test_dual_warmup_and_separate_timing_are_declared(monkeypatch):
|
||||
module = _load_server(monkeypatch)
|
||||
model = _Model("warm")
|
||||
synchronized: list[bool] = []
|
||||
monkeypatch.setattr(
|
||||
module.torch.cuda, "synchronize", lambda: synchronized.append(True)
|
||||
)
|
||||
|
||||
elapsed = module._warm_model(model)
|
||||
|
||||
assert elapsed >= 0
|
||||
assert module.ROLLING_MIN_AUDIO_MS == 1_800
|
||||
assert len(model.calls) == 1
|
||||
assert model.calls[0]["options"]["language"] is None
|
||||
assert synchronized == [True]
|
||||
assert not Path(model.calls[0]["path"]).exists()
|
||||
source = SERVER.read_text()
|
||||
rolling_warm = "STARTUP_ROLLING_WARMUP_MS = _warm_model(rolling_model)"
|
||||
final_warm = "STARTUP_WARMUP_MS = _warm_model(model)"
|
||||
assert source.index(rolling_warm) < source.index(final_warm)
|
||||
assert source.index(final_warm) < source.index("server = ThreadingHTTPServer")
|
||||
assert 'kind="rolling"' in source
|
||||
assert 'kind="speculative"' in source
|
||||
assert '"last_rolling_decode_ms"' in source
|
||||
assert '"last_speculative_decode_ms"' in source
|
||||
|
||||
|
||||
def test_tiny_model_is_checksum_pinned_read_only_in_the_runtime_image():
|
||||
dockerfile = DOCKERFILE.read_text()
|
||||
digest = "65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9"
|
||||
assert f"--checksum=sha256:{digest} --chmod=0444" in dockerfile
|
||||
assert f"/models/{digest}/tiny.pt" in dockerfile
|
||||
assert "HERMES_STT_ROLLING_MODEL=tiny" in dockerfile
|
||||
assert "('large-v3-turbo.pt','small.pt','tiny.pt')" in dockerfile
|
||||
|
||||
|
||||
def test_dual_model_contract_is_an_exact_voice_release_gate():
|
||||
pipeline = PIPELINE.read_text()
|
||||
assert "testing/tests/test_hermes_stt_rolling_model.py" in pipeline
|
||||
source = SERVER.read_text()
|
||||
assert '"path": "/v1/audio/transcriptions/stream"' in source
|
||||
assert '"inference": INFERENCE_GATE.snapshot()' in source
|
||||
assert '"rolling_model": ROLLING_MODEL_NAME' in source
|
||||
assert "STARTUP_ROLLING_WARMUP_MS is not None" in source
|
||||
@ -239,7 +239,7 @@ def test_stream_speculation_is_reused_for_unchanged_commit(monkeypatch):
|
||||
assert model.calls[0][1]["condition_on_previous_text"] is False
|
||||
|
||||
|
||||
def test_active_speech_emits_rate_limited_stable_rolling_partials(monkeypatch):
|
||||
def test_active_speech_emits_only_one_disposable_rolling_partial(monkeypatch):
|
||||
module = _load_server(monkeypatch)
|
||||
connection = _Connection()
|
||||
model = _SequencedModel(["please open", "please open my calendar"])
|
||||
@ -261,13 +261,12 @@ def test_active_speech_emits_rate_limited_stable_rolling_partials(monkeypatch):
|
||||
"window_start_ms": 0,
|
||||
}
|
||||
|
||||
# Audio progress is required in addition to wall-clock rate limiting.
|
||||
# More PCM in the same epoch must not launch disposable GPU work again.
|
||||
session._rolling_last_started_at = 0.0
|
||||
session.append(_speech(samples=12_000))
|
||||
second = connection.wait_for_revision(2)
|
||||
assert second["transcript"] == "please open my calendar"
|
||||
assert second["stable_transcript"] == "please open"
|
||||
assert len(model.calls) == 2
|
||||
time.sleep(0.02)
|
||||
assert len(model.calls) == 1
|
||||
assert not any(message.get("revision") == 2 for message in connection.messages)
|
||||
|
||||
|
||||
def test_rolling_window_is_bounded_but_final_decode_uses_full_utterance(monkeypatch):
|
||||
@ -338,7 +337,8 @@ def test_busy_gpu_skips_partial_instead_of_queuing_work(monkeypatch):
|
||||
|
||||
def occupy_gpu():
|
||||
module.INFERENCE_GATE.try_background(
|
||||
lambda: (entered.set(), release.wait(2.0))
|
||||
lambda: (entered.set(), release.wait(2.0)),
|
||||
kind="rolling",
|
||||
)
|
||||
|
||||
worker = threading.Thread(target=occupy_gpu)
|
||||
@ -353,12 +353,12 @@ def test_busy_gpu_skips_partial_instead_of_queuing_work(monkeypatch):
|
||||
release.set()
|
||||
worker.join(timeout=1.0)
|
||||
|
||||
# A later audio interval gets another opportunity once the final-priority
|
||||
# GPU lock is free; the skipped snapshot did not leave a queued worker.
|
||||
# A skipped epoch is never retried, so it cannot race the final later.
|
||||
session._rolling_last_started_at = 0.0
|
||||
session.append(_speech())
|
||||
assert connection.wait_for_revision(1)["transcript"] == "hello"
|
||||
assert len(model.calls) == 1
|
||||
time.sleep(0.02)
|
||||
assert connection.messages == []
|
||||
assert model.calls == []
|
||||
|
||||
|
||||
def test_waiting_final_prevents_new_rolling_work_from_stealing_gpu(monkeypatch):
|
||||
@ -369,7 +369,8 @@ def test_waiting_final_prevents_new_rolling_work_from_stealing_gpu(monkeypatch):
|
||||
|
||||
first = threading.Thread(
|
||||
target=lambda: module.INFERENCE_GATE.try_background(
|
||||
lambda: (first_entered.set(), release_first.wait(2.0))
|
||||
lambda: (first_entered.set(), release_first.wait(2.0)),
|
||||
kind="rolling",
|
||||
)
|
||||
)
|
||||
first.start()
|
||||
@ -382,7 +383,7 @@ def test_waiting_final_prevents_new_rolling_work_from_stealing_gpu(monkeypatch):
|
||||
while module.INFERENCE_GATE._final_waiters < 1 and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
assert module.INFERENCE_GATE._final_waiters == 1
|
||||
assert module.INFERENCE_GATE.try_background(lambda: "ran") is None
|
||||
assert module.INFERENCE_GATE.try_background(lambda: "ran", kind="rolling") is None
|
||||
release_first.set()
|
||||
first.join(timeout=1.0)
|
||||
final.join(timeout=1.0)
|
||||
@ -464,36 +465,3 @@ def test_stream_contract_rejects_wrong_format_and_oversize(monkeypatch):
|
||||
monkeypatch.setattr(module, "MAX_STREAM_AUDIO_BYTES", 4)
|
||||
with pytest.raises(module.WebSocketError, match="too long"):
|
||||
session.append(bytes(6))
|
||||
|
||||
|
||||
def test_stream_health_contract_is_declared_in_source():
|
||||
source = SERVER.read_text()
|
||||
|
||||
assert '"path": "/v1/audio/transcriptions/stream"' in source
|
||||
assert '"format": "pcm_s16le"' in source
|
||||
assert '"server_vad": True' in source
|
||||
assert '"speculative": True' in source
|
||||
assert '"inference": INFERENCE_GATE.snapshot()' in source
|
||||
assert '"minimum_audio_ms": ROLLING_MIN_AUDIO_MS' in source
|
||||
assert '"warmed": STARTUP_WARMUP_MS is not None' in source
|
||||
assert "MAX_STREAM_AUDIO_BYTES" in source
|
||||
|
||||
|
||||
def test_startup_warms_canonical_decode_before_serving(monkeypatch):
|
||||
module = _load_server(monkeypatch)
|
||||
model = _Model()
|
||||
synchronized: list[bool] = []
|
||||
monkeypatch.setattr(module.torch.cuda, "synchronize", lambda: synchronized.append(True))
|
||||
|
||||
elapsed = module._warm_model(model)
|
||||
|
||||
assert elapsed >= 0
|
||||
assert module.ROLLING_MIN_AUDIO_MS == 1_800
|
||||
assert len(model.calls) == 1
|
||||
assert model.calls[0][1]["language"] is None
|
||||
assert synchronized == [True]
|
||||
assert not Path(model.calls[0][0]).exists()
|
||||
source = SERVER.read_text()
|
||||
assert source.index("STARTUP_WARMUP_MS = _warm_model(model)") < source.index(
|
||||
"server = ThreadingHTTPServer"
|
||||
)
|
||||
|
||||
@ -42,7 +42,10 @@ def test_barge_in_cancels_owned_turn_and_reuses_pcm_lookback():
|
||||
assert "reusedCapture.lookback.forEach" in source
|
||||
assert "const bytes=new Uint8Array(16)" in source
|
||||
assert "window.crypto.getRandomValues(bytes)" in source
|
||||
assert "captureTurnId=(voiceTabNonce?voiceTabNonce+'-':'')+String(token)+'-'+String(++turnSequence)" in source
|
||||
assert (
|
||||
"captureTurnId=(voiceTabNonce?voiceTabNonce+'-':'')+String(token)+'-'+String(++turnSequence)"
|
||||
in source
|
||||
)
|
||||
|
||||
|
||||
def test_streamed_chunks_share_one_audio_timeline_until_final_drain():
|
||||
@ -71,7 +74,10 @@ def test_barge_cancel_settles_before_new_turn_baseline_and_observer():
|
||||
)
|
||||
assert region.index("rememberAssistantBaseline()") < region.index("window.send()")
|
||||
assert region.index("window.send()") < region.index("startResponseObserver(token)")
|
||||
assert "suppressAutoRead||bargeCancelPromise||state==='listening'||state==='transcribing'" in source
|
||||
assert (
|
||||
"suppressAutoRead||bargeCancelPromise||state==='listening'||state==='transcribing'"
|
||||
in source
|
||||
)
|
||||
|
||||
|
||||
def test_barge_handoff_never_uses_a_truncated_container_fallback():
|
||||
@ -96,7 +102,14 @@ def test_rolling_partials_are_feedback_only_and_wav_fallback_is_owned():
|
||||
)[0]
|
||||
|
||||
assert "payload.stable_transcript" in partial
|
||||
assert "label.textContent='Listening · '+preview" in partial
|
||||
assert "const provisional=String(payload.transcript||'').trim()" in partial
|
||||
assert "const visible=stable||provisional" in partial
|
||||
assert (
|
||||
"label.textContent='Listening · '+preview+(stable?'':' · provisional')"
|
||||
in partial
|
||||
)
|
||||
assert "if(stable){" in partial
|
||||
assert "scheduleVoicePreflight(turnId,revision,stable)" in partial
|
||||
assert "composer.value" not in partial
|
||||
assert "window.send" not in partial
|
||||
assert "signal:controller.signal" in fallback
|
||||
@ -113,7 +126,10 @@ def test_stt_client_queue_and_canonical_archive_are_strictly_bounded():
|
||||
assert "queuedBytes+bytes.byteLength>STT_MAX_QUEUED_BYTES" in source
|
||||
assert "Streaming transcription backpressure limit exceeded" in source
|
||||
assert "if(flushTimer||settled||!queue.length" in source
|
||||
assert "flushTimer=window.setTimeout(function(){flushTimer=null;flush();},20)" in source
|
||||
assert (
|
||||
"flushTimer=window.setTimeout(function(){flushTimer=null;flush();},20)"
|
||||
in source
|
||||
)
|
||||
assert "function clearQueue(){queue=[];queuedBytes=0;clearFlushTimer();}" in source
|
||||
assert "takeFallbackBlob:function()" in source
|
||||
assert "pcm16WavBlob(archive,16000)" in source
|
||||
|
||||
@ -359,8 +359,12 @@ def test_brand_css_is_accessible_dark_and_reduced_motion_aware() -> None:
|
||||
assert "--hermes-grid: rgba(72, 207, 204, 0.025)" in dark
|
||||
assert ".empty-logo .hermes-agent-portrait" in css
|
||||
assert "width: 112px" in css
|
||||
assert ":root.dark .messages" in css
|
||||
assert ":root.dark .messages-shell" in css
|
||||
assert "background-size: 32px 32px, 32px 32px, auto, auto" in css
|
||||
assert "background-repeat: repeat, repeat, no-repeat, no-repeat" in css
|
||||
messages = css.rsplit(":root.dark .messages {", 1)[1].split("}", 1)[0]
|
||||
assert "background-image: none" in messages
|
||||
assert "radial-gradient" not in messages
|
||||
assert ":root.dark .session-item.active" in css
|
||||
assert ":root.dark .suggestion:focus-visible" in css
|
||||
assert "@media (prefers-reduced-motion: reduce)" in css
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user