Compare commits

..

18 Commits

Author SHA1 Message Date
jenkins
e1ef110c8a docs(hux): close the Wave A review in the handoff ledger 2026-08-24 00:48:20 -03:00
jenkins
a75ca29934 hermes(hux): force restricted sensitivity on secret-bearing artifact content
User artifacts are never rewritten, but a secret pattern in a version marks
the artifact restricted and audits the reason (review a2-C, SO-12).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
681b040885 hermes(hux): close Wave A review findings in events, memory, privacy and organization
F3 memory edits go through the same privacy shaping as proposals; F5 seq is
derived from the ledger tail so a crash between append and checkpoint never
duplicates; F7 transitions re-read under the lock and always write with the
loaded revision; F9 secret scrub on titles, passages, claims and notebooks
and forget blanks the conversation document; F13 no ghost conversations
from notices, idempotency under the lock, artifact titles searchable,
normalised paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
6964a9d8c8 hermes(hux): close Wave A review findings in autonomy and the HTTP pipeline
F1 policy writes and allow grants are human-surface only; F2 worker trust is
confined to the hook allowlist and unexpected exceptions become audited 500
error records; F4 external side effects release only for the same run and
argument hash; F6 the gate honours budget exhaustion; F8 only the gateway
can vouch for an empty process registry and failed receipts can be
superseded; F11/F12 receipt revision and unshipped card routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
74c6549965 docs(hux): record the Wave A review outcome in the threat model 2026-08-24 00:48:20 -03:00
jenkins
aeef0f8484 hermes(hux): worker-side hook library for approvals, gates, budgets, stop receipts and events
Stdlib client the agent runtime calls around its tool loop; fails closed for
side effects, fails open for telemetry, never carries raw arguments or outputs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
1b1a14e972 hermes(hux): contract 1.1.0 additive revision and Wave A consolidation
Adds receipt evidence kind, 422 unprocessable, optional revision on research
records, audit_stale on the privacy policy, per-route body caps (25 MiB
artifact uploads), promotion checks the project exists, memory rules skip
content-free statuses. Handoff ledger covers every Wave A card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
b8fb72fbcc hermes(hux): HUX-01 redacted activity events, HUX-02 memory ledger, HUX-10 privacy behaviour
Per-conversation monotonic event ledger with idempotent emit, SSE replay from
Last-Event-ID, per-kind detail allowlists and secret scrubbing, surface-aware
serve-time redaction; memory as an append-only ledger with no-store,
supersede, forget and retrieval tombstones so 'do not remember' blocks both
persistence and retrieval; sensitive-topic scoping, notices, conversation
forget and a retention job that never touches audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
3ebee2cc15 hermes(hux): HUX-05 autonomy engine and minimal HUX-03 organization API
Scoped policies resolved through the single capability matrix, grants with
server-set expiry, approval queue with once/session/always/deny, pre-side-effect
gate that releases a once approval exactly once against the argument hash,
run budgets with exhaustion events, honest cancellation receipts; projects,
conversations, branch lineage and search over the indexed fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
9f0ffe031c hermes(hux): HUX-04 artifact workspace and HUX-08 research backends
Immutable content-addressed artifact versions with If-Match, lineage that must
resolve under the caller, unified diffs, promotion; sources/passages/citations
with server-side hashing and dedupe, citation integrity checks and revisioned
research notebooks. Cross-tenant access is 404 and audited.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
01abf89270 docs(hux): record draft PR #55 in the handoff ledger 2026-08-24 00:48:20 -03:00
jenkins
b9b7126ee1 docs(hux): start the hermes-next handoff ledger with HUX-11 2026-08-24 00:48:20 -03:00
jenkins
6a5e0d872b hermes(hux): HUX-11 foundation service core with threat and data models
Stdlib per-tenant service: trusted-header identity (router/relay/worker,
constant-time keys, slot pinned to the pod), fail-closed card flags with
capability negotiation, tenant-scoped store (atomic writes, revisions,
append-only ledgers, content-addressed blobs, manifest), audit outcome for
every request, and the /hux/v1 pipeline that maps errors to hux.error.v1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
jenkins
b4145861d2 hermes(hux): freeze hux.v1 contract 1.0.0 and move reference modules into the foundation package
HUX-11 contract freeze: identity/capabilities/manifest/error records, event turn
and idempotency and delegation/side-effect kinds, memory no-store/supersedes/
retrieval removal, revisions for optimistic concurrency, artifact access,
budget scope/spend/subagents and external side-effect gating, notebook notes and
dedupe keys. ADR-0001 records the wire and compatibility rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00
flux-bot
1a586d9523 chore(hermes): promote validated image release 2026-08-24 03:43:01 +00:00
jenkins
91eb4f92b7 perf(hermes): isolate rolling speech inference 2026-08-24 00:36:28 -03:00
flux-bot
c7aa7f462b chore(hermes): promote validated image release 2026-08-24 03:11:53 +00:00
jenkins
e9a6eba30b fix(hermes): stabilize chat canvas gradients 2026-08-24 00:01:52 -03:00
44 changed files with 2326 additions and 233 deletions

View File

@ -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 \

View File

@ -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

View File

@ -130,6 +130,17 @@ def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -
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")
@ -243,6 +254,9 @@ def create(request: Request) -> Response:
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)):
@ -273,7 +287,7 @@ def create(request: Request) -> Response:
_check(artifact)
stored = request.store.put(FAMILY, artifact)
remember(request.store, FAMILY, key, stored["id"])
request.audit("artifacts.create", 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"])})
@ -318,6 +332,9 @@ def add_version(request: Request) -> Response:
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"]
@ -329,7 +346,7 @@ def add_version(request: Request) -> Response:
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}")
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"])})

View File

@ -97,17 +97,36 @@ def hashes_of(approval: dict[str, Any]) -> set[str]:
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 run's conversation when an approval names it."""
"""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
if same_run:
conversation_id = conversation_id or record["conversation_id"]
same_conv = bool(conversation_id) and record["conversation_id"] == conversation_id
choice = record.get("decision", {}).get("choice")
if record["capability"] != capability or not (same_run or (choice == "session" and same_conv)):
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']}"
@ -118,13 +137,13 @@ def matching_approval(store: TenantStore, run_id: str, capability: str, argument
if external and not record["request"]["external"]:
reason = f"approval {record['id']} was not requested as external"
continue
if choice == "once" and argument_hash not in hashes_of(record):
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
return record, "released", conversation_id or record["conversation_id"]
return None, reason, conversation_id
@ -139,9 +158,12 @@ def gate(request: Request) -> Response:
if not isinstance(argument_hash, str) or not argument_hash.startswith("sha256:"):
raise Invalid("argument_hash must be sha256:<hex>")
external = bool(body.get("external", False))
conversation_id = body.get("conversation_id")
if conversation_id is not None:
conversation_id = check_id(conversation_id)
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":
@ -158,38 +180,49 @@ def gate(request: Request) -> Response:
# -- 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."""
"""``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):
if request.store.exists(RECEIPTS, receipt_id):
existing = request.store.get(RECEIPTS, receipt_id)
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))
side_effects = body.get("side_effects", [])
if not isinstance(side_effects, list):
raise Invalid("side_effects must be a list")
if body.get("already_complete"):
outcome = "already_complete"
elif body.get("process_registry_empty") is True:
outcome = "cancelled"
else:
outcome = "failed_to_cancel"
stamp = policy.iso(policy.now())
record: dict[str, Any] = {
"schema": "hux.cancel_receipt.v1", "id": receipt_id, "run_id": run_id, "requested_by": policy.actor_for(request.identity),
"requested_at": stamp, "acknowledged_at": stamp, "outcome": outcome, "side_effects": side_effects,
"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")
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))
request.audit("runs.stop", receipt_id, reason=outcome)
policy.emit(request.store, request.identity, record.get("conversation_id"), "run.cancelled", f"Run stopped: {outcome}",
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))

View File

@ -12,6 +12,7 @@ from __future__ import annotations
import json
import threading
import time
from pathlib import Path
from typing import Any
from collections.abc import Iterator
@ -47,14 +48,45 @@ def is_private(store: TenantStore, conversation_id: str) -> bool:
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 exists somewhere in this subject's tree."""
"""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}
@ -127,7 +159,7 @@ def emit_with_status(store: TenantStore, identity: Identity, conversation_id: st
return existing, True
seq_id = _seq_id(conversation_id)
checkpoint = store.get(SEQ_FAMILY, seq_id) if store.exists(SEQ_FAMILY, seq_id) else {"id": seq_id, "next_seq": 1, "last_event_id": ""}
record["seq"] = int(checkpoint["next_seq"])
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)

View File

@ -3,7 +3,9 @@
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.
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
@ -23,13 +25,37 @@ CARD_ROUTES: dict[str, list[str]] = {
"HUX-03": ["/hux/v1/projects", "/hux/v1/projects/{id}", "/hux/v1/conversations", "/hux/v1/conversations/{id}", "/hux/v1/conversations/{id}/branch", "/hux/v1/conversations/{id}/lineage", "/hux/v1/search"],
"HUX-04": ["/hux/v1/artifacts", "/hux/v1/artifacts/{id}", "/hux/v1/artifacts/{id}/versions", "/hux/v1/artifacts/{id}/versions/{n}", "/hux/v1/artifacts/{id}/versions/{n}/diff", "/hux/v1/artifacts/{id}/promote"],
"HUX-05": ["/hux/v1/policy", "/hux/v1/approvals", "/hux/v1/approvals/{id}", "/hux/v1/runs/{id}/stop", "/hux/v1/runs/{id}/budget", "/hux/v1/runs/{id}/gate"],
"HUX-06": ["/hux/v1/modes", "/hux/v1/conversations/{id}/mode"],
"HUX-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/v1/suggestions", "/hux/v1/suggestions/{id}/{action}"],
"HUX-09": [],
"HUX-10": ["/hux/v1/privacy/policy", "/hux/v1/privacy/notices", "/hux/v1/conversations/{id}/forget", "/hux/v1/privacy/audit"],
"HUX-12": ["/hux/v1/releases"],
"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:

View File

@ -18,8 +18,8 @@ from collections.abc import Mapping
from urllib.parse import parse_qs, urlsplit
from hux import audit
from hux.errors import HuxError, Invalid, NotFound, TooLarge
from hux.flags import Flags, build_from_environ
from hux.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
@ -137,6 +137,10 @@ class Router:
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)
@ -145,6 +149,10 @@ class Router:
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
@ -175,7 +183,7 @@ def make_handler(router: Router) -> type[BaseHTTPRequestHandler]:
def _run(self) -> None:
if self.path == "/healthz":
self._send(Response(200, {"status": "ok", "contract_version": "1.0.0"}))
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""

View File

@ -76,20 +76,37 @@ def _persist(store: TenantStore, record: dict[str, Any], expected: int | None) -
def _transition(store: TenantStore, identity: Identity, record: dict[str, Any], target: str, action: str, actor: dict[str, str],
note: str = "", expected: int | None = None, **changes: Any) -> dict[str, Any]:
if not rules.transition_allowed(rules.MEMORY_TRANSITIONS, record["status"], target):
raise Conflict(f"{record['status']} -> {target} is not a legal memory transition")
stamp = now_iso()
updated = {**record, **changes, "status": target, "updated_at": stamp, "audit": [*record["audit"], {"at": stamp, "action": action, "actor": actor, **({"note": note[:200]} if note else {})}]}
if target in {"forgotten", "rejected", "expired"}:
updated["content"], updated["retrievable"] = "", False
if target == "active":
updated["retrievable"] = changes.get("retrievable", True)
stored = _persist(store, updated, expected)
if target == "forgotten":
_tombstone(store, stored["id"], action)
"""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)
@ -175,17 +192,12 @@ def _actor(request: Request) -> dict[str, str]:
return {"type": "assistant", "id": "hermes"}
def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""Build the entry from a proposal body and decide its approval mode from policy; returns (entry, gate_reason)."""
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
if "id" in body or "revision" in body or "status" in body:
raise Invalid("id, revision and status are server-assigned")
content = body.get("content")
if not isinstance(content, str) or not content.strip() or len(content) > 2000:
raise Invalid("content must be a non-empty string of at most 2000 chars")
hits: list[str] = []
content = redaction.scrub_value(content.strip(), hits)
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")
@ -195,6 +207,35 @@ def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]
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
@ -203,7 +244,6 @@ def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]
ttl = {"policy": "decay", "decay_days": days}
actor = _actor(request)
stamp = now_iso()
conversation_id = body.get("conversation_id") if isinstance(body.get("conversation_id"), str) else None
provenance = {"surface": request.identity.surface, "actor": actor, "recorded_at": stamp}
if conversation_id:
provenance["conversation_id"] = conversation_id
@ -211,7 +251,7 @@ def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]
provenance["run_id"] = body["run_id"][:120]
entry: dict[str, Any] = {
"schema": "hux.memory.v1", "id": new_id("mem"), "owner": request.identity.subject, "scope": scope,
"kind": body.get("kind") if body.get("kind") in KINDS else "fact", "content": content, "status": "proposed",
"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],
@ -219,19 +259,12 @@ def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]
}
if isinstance(body.get("supersedes"), str):
entry["supersedes"] = body["supersedes"]
allowed, why = privacy.memory_write_allowed(request.store, conversation_id, entry)
if why == "private_mode":
raise Forbidden("memory writes are refused in private mode")
references_forgotten = entry.get("supersedes") in tombstoned(request.store) or source.get("id") in tombstoned(request.store)
wants = body.get("approval_mode", "ask" if actor["type"] == "assistant" else "automatic")
if not allowed or wants == "no_store":
# Content-free decision record. The topic moves into the note because
# rules.memory_policy_violations reads a deny topic on any non-rejected
# status as a write, and a sensitive entry must say approval_mode=ask.
why = why if not allowed else "declined"
entry.pop("topic")
entry.update(status="no_store", approval_mode="ask" if sensitivity == "sensitive" else "no_store", content="", retrievable=False,
audit=[{"at": stamp, "action": "no_store", "actor": actor, "note": f"{why}; topic={topic}"[:200]}])
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:
@ -240,26 +273,37 @@ def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]
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)."""
"""``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()
if key:
for row in request.store.read(IDEM_FAMILY, "keys"):
if row["idempotency_key"] == key:
request.audit("memory.propose", row["memory_id"], "allow", "replayed")
return Response(200, load(request.store, row["memory_id"]), {"HUX-Replayed": "true"})
entry, why = _shape(request, body)
try:
stored = _persist(request.store, entry, None)
except Invalid:
request.store.append(FAMILY, TOMBSTONES, {"memory_id": entry["id"], "at": now_iso(), "reason": "policy_violation", "purged": True})
_emit(request.store, request.identity, entry, "memory.suppressed", "Memory proposal suppressed by policy")
raise
if key:
request.store.append(IDEM_FAMILY, "keys", {"idempotency_key": key, "memory_id": stored["id"], "at": stored["created_at"]})
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})")
@ -299,26 +343,53 @@ def get_memory(request: Request) -> Response:
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():
raise Invalid("edit needs new 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"])
stamp = now_iso()
fresh = {
**{k: v for k, v in record.items() if k not in {"revision", "supersedes"}}, "id": new_id("mem"), "content": redaction.scrub_value(content.strip(), [])[:2000],
"status": "active", "approval_mode": "automatic", "retrievable": True, "supersedes": old["id"], "created_at": stamp, "updated_at": stamp,
"audit": [{"at": stamp, "action": "edited", "actor": actor, "note": f"supersedes {old['id']}"}, {"at": stamp, "action": "approved", "actor": actor}],
}
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)
fresh["audit"] = fresh["audit"][:1]
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"]
@ -349,13 +420,14 @@ def act_memory(request: Request) -> Response:
_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:
if record["status"] != "active":
raise Conflict("retrieval can only change on active entries")
retrievable = action == "restore_retrieval"
stamp = now_iso()
stored = _persist(store, {**record, "retrievable": retrievable, "updated_at": stamp, "audit": [*record["audit"], {"at": stamp, "action": "retrieval_removed" if not retrievable else "approved", "actor": actor, "note": "retrieval restored" if retrievable else ""}]}, expected)
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"])})

View File

@ -12,7 +12,7 @@ from __future__ import annotations
import re
from typing import Any
from hux import contracts
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
@ -63,8 +63,8 @@ def body_dict(request: Request) -> dict[str, Any]:
def pick(body: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
"""Only the client-settable fields; ids, owner, timestamps and revision are server-set."""
return {k: body[k] for k in fields if k in body}
"""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:
@ -233,14 +233,39 @@ def lineage(request: Request) -> Response:
# -- search --------------------------------------------------------------------
def artifact_titles(store: TenantStore, conversation: dict[str, Any]) -> list[str]:
"""Artifact titles for a conversation via the artifacts lane, or nothing when it is absent."""
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 []
helper = getattr(artifacts, "artifact_titles", None)
return list(helper(store, conversation["id"])) if helper else []
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]:
@ -269,11 +294,12 @@ def search(request: Request) -> Response:
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))
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"]))

View File

@ -61,9 +61,20 @@ def run_key(run_id: str) -> str:
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 identity.trust in HUMAN_TRUSTS and identity.surface in HUMAN_SURFACES:
if is_human(identity):
return {"type": "user", "id": identity.subject}
return {"type": "system", "id": identity.surface}
@ -163,6 +174,8 @@ def normalise_grants(grants: Any, identity: Identity) -> list[dict[str, Any]]:
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)
@ -181,7 +194,8 @@ def get_policy(request: Request) -> Response:
def put_policy(request: Request) -> Response:
"""``PUT /hux/v1/policy``: replace the policy for the scope named in the body."""
"""``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):
@ -189,6 +203,8 @@ def put_policy(request: Request) -> Response:
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,
@ -205,7 +221,8 @@ def put_policy(request: Request) -> Response:
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."""
"""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):
@ -273,6 +290,9 @@ def create_approval(request: Request) -> Response:
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] = {

View File

@ -186,11 +186,20 @@ def forget_conversation(store: TenantStore, identity: Identity, conversation_id:
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}
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]:
@ -225,8 +234,12 @@ def post_notice(request: Request) -> Response:
set_flag(request.store, conversation_id, "memory_disabled", True)
elif chosen == "forget_this_conversation":
forget_conversation(request.store, request.identity, conversation_id)
events.emit(request.store, request.identity, conversation_id, "privacy.notice", text, notice, sensitivity=rules.PRIVACY_TOPICS[topic]["sensitivity"])
request.audit("privacy.notice", conversation_id)
# 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)

View File

@ -136,8 +136,12 @@ def filter_detail(kind: str, detail: dict[str, Any] | None) -> dict[str, Any]:
kept["steps"] = [str(step)[:200] for step in steps[:20]]
if kind == "tool.call":
# Raw arguments never persist: only their names, a hash and a byte count.
path = kept.get("target_path")
if not (isinstance(path, str) and path.startswith(WORKSPACE_PREFIX + "/")):
# 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 []

View File

@ -16,6 +16,7 @@ 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
@ -62,6 +63,11 @@ def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -
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()
@ -127,7 +133,7 @@ def create_source(request: Request) -> Response:
classification = body.get("classification", "unknown")
if classification not in CLASSIFICATIONS:
raise Invalid("unknown classification")
title = _string(body, "title", 300)
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()
@ -180,7 +186,7 @@ def create_passage(request: Request) -> Response:
body = _body(request)
key = request.idempotency_key()
source = _resolve(request.store, SOURCES, body.get("source_id"))
text = _string(body, "text", 4000)
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):
@ -215,7 +221,7 @@ def attach_citation(request: Request) -> Response:
message_id = request.params["id"]
if len(message_id) > 120:
raise Invalid("message id is too long")
claim = _string(body, "claim", 1000)
claim = _clean(_string(body, "claim", 1000))
support = body.get("support", "unverified")
if support not in SUPPORT:
raise Invalid("unknown support verdict")
@ -231,7 +237,7 @@ def attach_citation(request: Request) -> Response:
return Response(200, _resolve(request.store, CITATIONS, existing), {"HUX-Replayed": "true"})
record: dict[str, Any] = {"schema": "hux.citation.v1", "id": new_id("cit"), "message_id": message_id, "claim": claim, "passage_ids": passage_ids, "support": support, "dedupe_key": dedupe}
if _string(body, "note", 500, required=False) is not None:
record["note"] = body["note"]
record["note"] = _clean(body["note"])
_check(record)
stored = record | {}
request.store.put(CITATIONS, record)
@ -287,7 +293,7 @@ def _text_list(body: dict[str, Any], key: str, current: list[str]) -> list[str]:
return current
if not isinstance(values, list) or not all(isinstance(v, str) and v.strip() for v in values):
raise Invalid(f"{key} must be a list of non-empty strings")
return values
return _clean(values)
def create_notebook(request: Request) -> Response:
@ -301,7 +307,7 @@ def create_notebook(request: Request) -> Response:
return Response(200, request.store.get(NOTEBOOKS, existing), {"HUX-Replayed": "true"})
record: dict[str, Any] = {
"schema": "hux.research_notebook.v1", "id": new_id("nb"), "conversation_id": check_id(body.get("conversation_id")),
"question": _string(body, "question", 1000), "status": "open", "source_ids": [], "passage_ids": [], "citation_ids": [],
"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,
}
@ -350,7 +356,7 @@ def patch_notebook(request: Request) -> Response:
if not isinstance(notes, list) or not all(isinstance(n, dict) for n in notes):
raise Invalid("add_notes must be a list of objects")
for note in notes:
entry = {"at": stamp, "text": _string(note, "text", 2000)}
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]

View File

@ -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)

View File

@ -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);
}
}
}
}

View File

@ -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 {

View 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}`.

View 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",
]

View 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

View 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")

View File

@ -71,6 +71,17 @@ argument hash a `once` approval is released against travels as
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.

View File

@ -62,3 +62,42 @@ Full suite: `testing/tests/test_hermes_hux_*.py` = 325 tests, 99% line / 99% bra
| 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).

View File

@ -245,3 +245,25 @@ Sized for 4-8 tenants, one human each, on a 10 Gi PVC shared with the WebUI.
- 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).

View File

@ -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:

View File

@ -359,6 +359,11 @@
},
"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."
}
}
},

View File

@ -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:

View File

@ -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}

View File

@ -128,3 +128,19 @@ def test_audit_rows_name_family_verbs(router, artifact):
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

View File

@ -361,6 +361,68 @@ def test_flag_off_hides_event_routes(tmp_path):
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

View File

@ -363,3 +363,74 @@ def test_server_main_wires_environment(tmp_path, monkeypatch):
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)

View 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

View File

@ -10,7 +10,6 @@ from __future__ import annotations
import json
import sys
import types
from pathlib import Path
import pytest
@ -213,23 +212,42 @@ def test_search_ranks_title_over_tag_over_project_and_says_what_is_indexed(route
assert call(router, "GET", "/hux/v1/search?q=cabinet", headers=OTHER)[1]["items"] == []
def test_search_uses_artifact_titles_when_the_artifacts_lane_offers_them(router, monkeypatch):
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")
fake = types.ModuleType("hux.artifacts")
fake.artifact_titles = lambda store, conversation_id: ["Supplier comparison sheet"] if conversation_id == conversation["id"] else []
monkeypatch.setitem(sys.modules, "hux.artifacts", fake)
monkeypatch.setattr(hux, "artifacts", fake, raising=False)
assert [c["id"] for c in call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"]] == [conversation["id"]]
bare = types.ModuleType("hux.artifacts")
monkeypatch.setitem(sys.modules, "hux.artifacts", bare)
monkeypatch.setattr(hux, "artifacts", bare, raising=False)
assert call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"] == []
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")

View 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

View File

@ -269,9 +269,10 @@ def test_gate_session_is_reusable_within_the_conversation(router):
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
assert gate(router, capability="shell")[1]["proceed"] is True
assert gate(router, capability="shell", argument_hash=OTHER_HASH)[1]["proceed"] is True
assert gate(router, run_id="run_later", capability="shell", conversation_id=CONV)[1]["proceed"] is True
assert gate(router, run_id="run_later", capability="shell")[1]["proceed"] is False
assert gate(router, run_id="run_later", capability="shell", conversation_id="conv_elsewhere01")[1]["proceed"] is False
assert gate(router, 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
@ -290,7 +291,7 @@ def test_gate_requires_external_approvals_for_external_effects_and_respects_expi
def test_gate_rejects_bad_bodies_and_other_tenants(router):
assert gate(router, capability="teleport")[0] == 400
assert gate(router, argument_hash="md5:zz")[0] == 400
assert gate(router, conversation_id="nope")[0] == 400
assert 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)
@ -307,3 +308,52 @@ def test_events_module_absence_is_tolerated(router, monkeypatch):
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, {}))

View 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()

View File

@ -20,7 +20,7 @@ FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import audit, contracts, identity, policy, rules, store # noqa: E402
from hux import audit, contracts, errors, identity, policy, rules, store # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
@ -189,3 +189,34 @@ def test_helpers_round_trip_time_and_actors():
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"))

View File

@ -103,9 +103,9 @@ def test_budget_rejects_bad_bodies(router, body):
def test_stop_writes_a_receipt_and_a_second_stop_returns_it(router, events, tmp_path):
side_effects = [{"description": "Partial file left in workspace", "reverted": True, "evidence": {"kind": "file", "id": "notes.md"}}]
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "side_effects": side_effects, "conversation_id": CONV}, HEADERS)
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": "user", "id": HEADERS["X-Hux-Subject"]}
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})
@ -116,6 +116,28 @@ def test_stop_writes_a_receipt_and_a_second_stop_returns_it(router, events, tmp_
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

View File

@ -90,6 +90,7 @@ def test_policy_route_serves_rules_and_reports_stale_audit(tmp_path):
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")
@ -98,7 +99,7 @@ def test_notice_is_recorded_scoped_and_emitted(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)
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})
@ -142,7 +143,7 @@ def test_forget_route_redacts_events_and_marks_state(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}
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)
@ -170,3 +171,38 @@ def test_flag_off_hides_privacy_routes(tmp_path):
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

View File

@ -227,6 +227,20 @@ def test_validate_citations_reports_integrity_problems(router, monkeypatch):
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

View File

@ -160,3 +160,15 @@ def test_notebook_paths_are_tenant_scoped(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 == []

View 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

View File

@ -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"
)

View File

@ -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

View File

@ -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