atlas-iac/docs/hux/THREAT-MODEL.md
jenkins b3d4538b4f docs(hux): describe the integrated staged topology and trust reality
HANDOFF.md and THREAT-MODEL.md now document the actual HMAC subject/
context identity, the shared RWX PVC with kubelet subPathExpr per-pod
isolation and its exact boundary, the uid-10000 no-store-mount agent
model, tmpfs transport key rotation, router X-Hux-* stripping, HUX-12
file-only evidence trust, staged-not-deployed status, and the verified
SO-46/48 (open) and SO-53 (shipped) states.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:23:24 -03:00

31 KiB

hux-foundation threat model

Scope: the per-tenant hux-foundation service (Python, stdlib ThreadingHTTPServer) that runs as a sidecar in each hermes-chat-tenant-N pod, keeps HUX records on that pod's home PVC under /opt/data/hux/v1/ (layout in DATA-MODEL.md), and is reached only through the chat router, the Worker, and the Telegram relay. It implements the frozen 1.0.0 contracts in services/hermes/contracts/hux/ (ADR-0001) with the rules in dockerfiles/hermes-hux-foundation/hux/rules.py. Where a NetworkPolicy or pod setting is needed it is written as a requirement for Codex, not a manifest. Every mitigation cites a contract field, a code obligation, or an SO id from the checklist at the end.

Assets, most important first:

  1. Tenant isolation: one Keycloak subject, one pod, one PVC (chat-statefulset.yaml, ai.bstein.dev/isolation). Everything rests on it.
  2. Secrets that transit the pod: the relay key at /runtime-access/chat-relay-key, provider keys in /opt/data/.env, and the OAuth headers the router already strips (main.go Director).
  3. User content: memory, artifacts, sources, events. Sensitive and restricted topics have hard rules in PRIVACY_TOPICS and memory_policy_violations.
  4. Honesty of the record: seq, approval decisions, cancel receipts, budget state, release evidence. If these can be forged the UI lies to the user.

Attackers, in the order we expect to meet them:

  • A: another tenant's Hermes process or a tool it ran. Shell, Python, Playwright, and a same-namespace address.
  • B: this tenant's own agent gone wrong (prompt injection via web or files). Tenant privileges, plus tool arguments and logs full of things the user never meant to keep.
  • C: the tenant's browser session, possibly a stolen hermes_chat_session cookie, driving /hux/v1 through the router.
  • D: a linked Telegram peer, or a leaked relay key.
  • E: operator mistakes: a flag on without its chain, an old binary reading new records, a purge with a bad glob.

1. Tenant and surface identity

The router is the only identity authority. slotFor maps subject to slot, the Director strips Authorization, Cookie, X-Auth-Request-* and X-Forwarded-*, and sets X-Hermes-Tenant-Identity: slot-N. Nothing in the pod ever sees a raw subject. ADR-0001 adds X-Hux-Subject: usr_<hash> (identityHash("keycloak", subject)) and X-Hux-Surface. The service turns those into common.identity (tenant_slot, subject, surface, trust) and stamps it on every record a caller can create.

Threat STRIDE Who Mitigation
Pod-network client sends X-Hermes-Tenant-Identity: slot-2 straight to slot 2's hux port S A Bind 127.0.0.1 only; the WebUI sidecar is the sole in-pod caller and forwards the router's headers verbatim. No cross-pod path exists to spoof. (SO-01)
Someone reaches 8787/8642/8788 from a pod that is not the router S A hermes-chat-tenant-isolation already admits those ports only from app: hermes-chat-router. Requirement: no hux port is ever added to that ingress list; the sandbox pods keep their single-source ingress. (SO-02)
Browser sends X-Hux-Subject, router forwards it S C Router deletes inbound X-Hux-* and X-Hermes-Tenant-Identity before setting its own, exactly as it deletes Authorization. Service refuses a slot header that differs from its own ordinal (HERMES_TENANT_SLOT). (SO-03)
Two header vocabularies (ADR: X-Hux-Subject; DATA-MODEL §6: X-Hermes-User) and one hop honours the wrong one S, E E One header set, defined once in identity.py, with the other names rejected as 401 unauthorized. (SO-04)
Telegram relay carries the shared relay key and no subject S, E D Authorization: Bearer <relay_key> compared with hmac.compare_digest (as telegram_media_server.py does); X-Hux-Surface: telegram, trust: relay. The owner is the slot's single assignment. A relay request that names X-Hux-Subject is 401. (SO-05, SO-06)
Relay key ends up in a record or log I B Key lives only under /runtime-access (read-only mount, not the PVC). It, and every value in /opt/data/.env, is a literal canary in the scrub of section 2. (SO-07)
Worker reads a tenant's user data through trust: worker E, I E trust: worker may call GET /hux/v1/releases, GET /hux/v1/capabilities and POST /hux/v1/admin/retention only; any other route is 403 forbidden. No worker bypass port. (SO-08)
Slot handed to a new subject; old subtree leaks I E Paths are users/<usr_hash>/...; reads join on identity.subject. A different hash sees an empty tree, never another's. (SO-09)
Body claims provenance.surface: worker or actor.type: user R, T B, C Server overwrites identity, provenance.surface, provenance.actor (when type: user) and recorded_at from the trusted hop and its own clock. (SO-10)

NetworkPolicy requirements for Codex: router-only ingress to every listening port in the tenant pod; no egress from hux-foundation (it fetches nothing); sandbox pods gain no route to the tenant's loopback services.

2. Activity events

detail is schema-open ("kind-specific payload"). Tool arguments carry file paths, shell lines with tokens, URLs with query strings, pasted secrets. The pipeline, in order, before any byte is written: drop keys outside the per-kind allowlist; scrub every remaining string; cap sizes; raise redaction.level if anything fired; then and only then allocate seq.

Threat STRIDE Who Mitigation
tool.call detail persists raw arguments I B Allowlist below; unknown keys dropped before write, never filtered on read only. (SO-11)
Secret inside an allowed string (summary, detail.target_path) I B Pattern scrub: sk-[A-Za-z0-9]{20,}, `ghp_
Oversized detail fills the PVC or an SSE buffer D B detail > 32 KiB (DATA-MODEL cap) is replaced by {"truncated": true, "bytes": N}; line cap 64 KiB; evidence ≤ 64 and summary ≤ 280 per schema. (SO-13)
Reader ignores redaction.level I C, D Enforced at serve time too: none as stored; partial strips detail; full serves kind, seq, ts, sensitivity, redaction only. surface: telegram and voice never receive none. (SO-14)
Client supplies seq or id T B, C Server-assigned under the conversation lock; a body carrying either is 400 invalid. (SO-15)
Replay through Idempotency-Key returns someone else's event I C Idempotency keys are scoped per conversation file; a replay is served only after the ownership check. (SO-16)
Reconnect storm, after_seq=0 on a long log D C Pages ≤ 200; after_seq before the retention window resumes from the oldest kept event; one SSE stream per (subject, conversation), a second open closes the first; idle timeout 15 min. (SO-17)
GET /conversations/{guess}/events I C Ids are time-prefixed plus 30 random bits, so ownership is the real gate: conversation_id document loaded first, mismatch is 404 not_found, not 403. (SO-18)
evidence[].uri of file:///opt/data/.env expanded by a UI I B Service never dereferences evidence_ref.uri; stored URIs must be https://, hux:// or artifact://, others dropped. (SO-19)
Restricted event leaks via search I C sensitivity: restricted or redaction.level: full events never enter search/<conv>.json. (SO-20)

Detail allowlist (anything else is dropped):

  • message.*: message_id, chars, has_attachments
  • decision.route: requested, resolved_target, provider, effort, reason
  • decision.plan: steps (≤ 20 strings ≤ 200 chars)
  • tool.call: tool, capability, argument_names, argument_hash, target_path (kept only under /opt/data/workspace, else dropped)
  • tool.result: tool, ok, duration_ms, bytes, exit_code
  • approval.*, side_effect.*: approval_id, capability, choice, external
  • memory.*: memory_id, kind, sensitivity, topic
  • artifact.*: artifact_id, version, type, bytes, hash
  • run.*, delegation.*, budget.exhausted: run_id, outcome, receipt_id, spent, limits, child_run_id
  • privacy.notice: the hux.privacy_notice.v1 fields only
  • citation.attached, mode.changed, suggestion.*, release.transition: ids and enum fields of their own schemas only

3. Memory

Defaults: approval_mode: ask for anything above personal; no_store is a first-class status (memory.status, memory.approval_mode) so "do not remember" is recorded as a decision rather than as absence. A proposal unanswered for 7 days expires.

Threat STRIDE Who Mitigation
Restricted content written because the topic detector missed it I B memory_policy_violations on every write and transition; any problem is 422/invalid with a memory.suppressed event. The service never lowers sensitivity; its own scrub may raise it. (SO-21)
"Do not remember" blocks the write but the entry surfaces later I B, C no_store writes a content-free ledger line and a tombstone; forget writes status: forgotten, content: "", retrievable: false, tombstone. Retrieval, /search, /export and the agent read hook check the tombstone set before the index and return only status: active with retrievable: true. (SO-22, SO-23)
Forgotten content lingers in earlier events or ledger lines I B memory.* events carry ids only. forget re-redacts events referencing the id to full and queues purge_forgotten_content to rewrite older ledger snapshots. (SO-24)
Correction resurrects forgotten text T B An entry whose supersedes or source points at a forgotten id needs approval_mode: ask answered by a user actor. (SO-25)
Export leaks another owner or forgotten entries I C Export is owner-scoped, excludes forgotten, rejected, expired, no_store and retrievable: false, appends exported to audit[], is served as an attachment, and the snapshot file is pruned after 7 days. (SO-26)
TTL never enforced because the audit job died I E Expiry checked lazily at read (an expired entry is never served as active) and eagerly by the retention job; a missing hux.retention_audit.v1 older than 48 h makes /privacy/policy report audit_stale: true. (SO-27)
Private mode writes memory I B MODE_CATALOG["private"].memory.write == False: POST /memory is refused while the conversation's mode is private, and its events are not written at all (204). (SO-28)
disable_memory_here ignored on the read side I B privacy/conversation_topics.json memory_disabled is checked by retrieval: that conversation contributes and receives nothing. (SO-23)

4. Artifacts and research

Sources are records about where evidence came from. The service stores and serves them; it never fetches a URI.

Threat STRIDE Who Mitigation
SSRF via source.uri or evidence_ref.uri S, I B No outbound HTTP client in the service; a test asserts urllib.request, http.client and socket.create_connection are absent from the module graph. (SO-29)
Wrong hash on upload; UI dedupes on it T B sha256 recomputed server-side; mismatch with content_ref.hash is 422; blobs stored at blobs/sha256/<aa>/<hash>, mode 0400, never rewritten. (SO-30)
Oversized artifact D B, C 25 MiB per version, 200 versions, 2 000 artifacts, 2 GiB blob store per tenant (DATA-MODEL caps); over limit is 413 too_large or 409 conflict. (SO-31)
HTML/SVG served executable into the chat origin I, E B Blobs served as application/octet-stream, Content-Disposition: attachment, X-Content-Type-Options: nosniff. mime is metadata for the UI's sandboxed preview, never a response header. (SO-32)
Lineage or promotion names a record the caller does not own T, I B, C lineage.artifact_id, promotion.project_id, passage.source_id, citation.passage_ids, notebook id lists all resolved under the caller's subtree at write time; unknown or foreign is 422. There is no cross-tenant store, and the check stays explicit so a future shared cache cannot widen it. (SO-33)
access.mode: shared_readonly used as a cross-tenant channel I C Share tokens are minted by the owner, read-only, expire at share_expires_at ≤ 7 days, and resolve only inside the same pod; the router does not route them to another slot. (SO-34)
Passage text used to smuggle secrets into the citation strip I B Passage text passes the SO-12 scrub; 4 000-char cap is the schema's. (SO-12)

5. Autonomy

The service is the policy store and approval queue; the gateway executes tools. The gate is only as strong as the gateway's call to effective_decision and its presentation of the approval id at execution.

Threat STRIDE Who Mitigation
Agent decides its own approval S, E B POST /approvals/{id} needs identity.surface in {chat, telegram, voice} and trust in {router, relay}; the stored decision.by is the asserted user, not the body's actor. (SO-35)
once reused for two side effects T B Approvals are terminal after one transition (APPROVAL_TRANSITIONS); consume marks _meta.consumed_at and a second consume of once is 409 conflict. (SO-36)
TOCTOU: approved "write notes.md", executed "write ~/.ssh/authorized_keys" T B request.evidence carries a tool_call ref whose hash is the sha256 of the canonical argument JSON; consume must present the same hash or it is denied with a side_effect.blocked event. (SO-37)
always grant never expires E C always writes a grant with server-set expires_at ≤ 30 days and granted_by; the client cannot extend it. (SO-38)
deploy, external_side_effect or network slip through under safe E B, C effective_decision is the only resolver; _ALWAYS_ASK ends ask, unexpired deny wins. request.external: true always requires an approval record. (SO-39)
Budget exhausted by delegation fan-out D B hux.budget_state.v1 is updated per tool.result/delegation.*; once exhausted the service refuses new approvals with 402-style budget_exhausted and emits budget.exhausted. (SO-40)
Receipt says cancelled while a shell still runs R B outcome: cancelled only after the gateway reports the run's process registry empty; else failed_to_cancel. side_effects must include every tool.result with ok: true after requested_at, each marked reverted. Stop is not done until the receipt exists. (SO-41)
Approval sits open forever D B expires_at ≤ 24 h from requested_at; expiry is a terminal transition written by the retention thread. (SO-42)

6. Storage

Layout per DATA-MODEL §1: /opt/data/hux/v1/users/<usr_hash>/<family>/.... No table, index or cache is shared between tenants because no two tenants share a PVC.

Threat STRIDE Who Mitigation
Path traversal via id I, T B, C store.path_for validates the user hash and every id against the common.schema.json patterns (/ impossible), then os.path.commonpath against the user subtree, as normalizeTenantMediaPath does. No caller string reaches a path any other way. (SO-43)
Lost update between chat and Telegram T C, D revision served as ETag; PATCH/PUT need If-Match, mismatch 409 conflict; writes are temp + os.replace + fsync under the family lock. Unconditional writes are audited why: unconditional_write. (SO-44)
Agent edits the audit log; it has write on the PVC R, T B hux-foundation runs as its own uid (not 10000); /opt/data/hux is 0700 to that uid; the agent reaches records only through the API. Requirement for Codex: separate runAsUser, readOnlyRootFilesystem, drop: [ALL]. (SO-45)
Audit line removed or altered R B, E audit/outcomes/*.jsonl lines carry prev = sha256 of the previous line; a break is reported in the next retention audit as report. Audit lines never contain record content. (SO-46)
Purge deletes the wrong thing D E purge_forgotten_content touches only tombstoned ids and blobs absent from refs.json for 7 days, writes the dry-run count into the retention record before the delete pass, and never touches audit/. (SO-47)
Second replica interleaves appends T E v1/.lock taken with flock(LOCK_EX|LOCK_NB) at start-up; failure is fatal. (SO-48)
PVC full stops the WebUI too D B Per-family bounds from DATA-MODEL §4; a write over cap is 413 too_large, and total /opt/data/hux above 4 GiB puts /healthz at degraded. (SO-49)

7. Feature flags

Threat STRIDE Who Mitigation
hux.memory_control on, hux.privacy off E E flag_enabled walks depends_on; a route serves only when the chain is on; /hux/v1 is 404 flag_off without hux.foundation. GET /capabilities reports the resolved chain, not the raw env. (SO-50)
Old binary reads new records D E Additive-only within 1.x; _meta stripped on serve; unknown schema skipped on list, 409 on fetch; MANIFEST.json min_reader_layout refuses a tree the binary cannot read. (SO-51)
Rollback leaves data the disabled UI cannot show I E Disabling hides routes, not data; TTL, forget and purge keep running. (SO-52)

8. Abuse and DoS bounds

Sized for 4-8 tenants, one human each, on a 10 Gi PVC shared with the WebUI.

  • Bodies: 64 KiB JSON, 25 MiB blob, rejected before parsing.
  • Rate: 30 writes/min and 300 reads/min per subject, 429 rate_limited with Retry-After. Relay and worker get the same limits per slot.
  • Events: 50 000 per conversation then archived; 1 GiB total.
  • Memory: 5 000 live entries; proposals expire in 7 days; ledger compacts at 64 MiB.
  • Artifacts: 2 000, 200 versions each, 2 GiB blobs.
  • Research: 10 000 sources, 50 000 passages, 512 MiB.
  • Approvals: 50 pending per run; expiry ≤ 24 h.
  • SSE: one per (subject, conversation); idle timeout 15 min; 8 per subject.
  • Threads: ThreadingHTTPServer capped at 32 concurrent requests; beyond that 503.

Security obligations checklist

  • SO-01 The service binds 127.0.0.1 only; any other bind address fails at startup.
  • SO-02 Tenant pod ingress admits app: hermes-chat-router only; no hux port is added to any NetworkPolicy.
  • SO-03 The router deletes inbound X-Hux-* and X-Hermes-Tenant-Identity before setting its own; the service rejects a slot header not equal to HERMES_TENANT_SLOT.
  • SO-04 Exactly one identity header set is accepted (X-Hermes-Tenant-Identity, X-Hux-Subject, X-Hux-Surface); any other vocabulary is 401.
  • SO-05 Relay requests are checked with hmac.compare_digest against the relay key and carry trust: relay.
  • SO-06 A relay request carrying X-Hux-Subject is 401; the owner is derived from the slot.
  • SO-07 The relay key and every /opt/data/.env value never appear in a record, event detail, audit line or log.
  • SO-08 trust: worker may call only /releases, /capabilities and /admin/retention; everything else is 403.
  • SO-09 Every path contains the asserted usr_ hash; a different subject on the same pod sees an empty tree.
  • SO-10 identity, provenance.surface, user-type provenance.actor and recorded_at are server-set and client values ignored.
  • SO-11 Event detail keys outside the per-kind allowlist are dropped before persistence.
  • SO-12 Every stored string passes the secret scrub; a hit becomes [redacted:<class>] and raises redaction.level to at least partial.
  • SO-13 detail over 32 KiB is replaced by a truncation marker; a line over 64 KiB is rejected.
  • SO-14 redaction.level is enforced at serve time; telegram and voice never receive none.
  • SO-15 seq and id are server-assigned; a body containing either is 400.
  • SO-16 Idempotent replays are served only after the ownership check passes.
  • SO-17 Event pages are ≤ 200; one SSE stream per (subject, conversation); idle timeout 15 min.
  • SO-18 A conversation the caller does not own returns 404, never 403.
  • SO-19 The service never dereferences evidence_ref.uri or source.uri; stored URIs use an allowlisted scheme.
  • SO-20 Restricted or fully redacted events never enter the search index.
  • SO-21 memory_policy_violations runs on every memory write and transition; a violation is 422 plus a memory.suppressed event.
  • SO-22 no_store and forget write a content-free ledger line and a tombstone before returning.
  • SO-23 Retrieval, search, export and the agent read hook consult tombstones and memory_disabled first and return only active entries with retrievable: true.
  • SO-24 forget re-redacts events referencing the memory id to full and queues the ledger rewrite.
  • SO-25 An entry that supersedes or is sourced from a forgotten id requires a user-actor approval.
  • SO-26 Export is owner-scoped, excludes non-active entries, appends exported to audit[], and its snapshot is pruned after 7 days.
  • SO-27 Expired entries are never served as active; a retention audit older than 48 h is reported as audit_stale.
  • SO-28 In private mode POST /memory is refused and event appends return 204 without writing.
  • SO-29 A test asserts no outbound HTTP or raw socket client is importable from the service's module graph.
  • SO-30 Blob hashes are recomputed on upload; a mismatch is 422; blobs are 0400 and never rewritten.
  • SO-31 Artifact caps: 25 MiB per version, 200 versions, 2 000 artifacts, 2 GiB blobs.
  • SO-32 Blobs are served as application/octet-stream attachments with nosniff.
  • SO-33 Every referenced id (lineage, promotion, source, passage, citation, notebook) must resolve under the caller's subtree.
  • SO-34 Share tokens are owner-minted, read-only, expire within 7 days, and resolve only on the owning pod.
  • SO-35 Approvals are decided only from a human surface with trust router or relay; decision.by is the asserted user.
  • SO-36 A once approval can be consumed exactly once.
  • SO-37 Consume must present the argument hash recorded in request.evidence at request time.
  • SO-38 always grants carry a server-set expires_at of at most 30 days.
  • SO-39 effective_decision is the sole resolver; deploy and external_side_effect always resolve to ask; request.external: true always needs an approval record.
  • SO-40 An exhausted budget_state blocks new approvals with budget_exhausted and emits budget.exhausted.
  • SO-41 A receipt says cancelled only after the run's process registry is empty, and lists every successful side effect after requested_at.
  • SO-42 Pending approvals expire within 24 h of requested_at.
  • SO-43 All paths go through path_for: pattern-validated ids plus commonpath containment in the user subtree.
  • SO-44 Revisioned writes require If-Match; mismatch is 409; unconditional writes are audited.
  • SO-45 The service runs as a distinct uid with /opt/data/hux mode 0700; the agent has no filesystem path to records.
  • SO-46 Audit outcome lines are hash-chained and a broken chain is reported in the next retention audit.
  • SO-47 Purge touches only tombstoned ids and unreferenced blobs older than 7 days, records a dry-run count first, and never touches audit/.
  • SO-48 A second service process against the same tree fails at startup on v1/.lock.
  • SO-49 Writes beyond family caps are 413; total store above 4 GiB marks /healthz degraded.
  • SO-50 A route serves only when its flag and every dependency flag are on; /capabilities reports the resolved chain.
  • SO-51 Responses strip _meta, unknown schema values are skipped on list and 409 on fetch, and a tree above min_reader_layout is refused.
  • SO-52 Disabling a flag hides routes; TTL, forget and purge continue.
  • SO-53 Rate limits (30 writes/min, 300 reads/min per subject) return 429 with Retry-After.
  • SO-54 Bodies over 64 KiB (JSON) or 25 MiB (blob) are rejected with 413 before parsing.

Wave A review outcome (2026-08-24)

A fresh adversarial review of HEAD 1b1a14e9 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).

Integration amendments (2026-08-24)

The staged integration (services/hermes/chat-statefulset.yaml, chat-pvcs.yaml, local chain up to 13359769; staged only — nothing is deployed, origin/main is still 5558c24f) changes the following. Where this section conflicts with the body above, this section is current.

SO-45 superseded: the boundary is mount scoping, not a uid split

The service does not run as a distinct uid and there is no /opt/data/hux. Records live on a standalone ReadWriteMany PVC hermes-chat-hux-data shared by all tenant pods; each pod receives only its own subtree through kubelet subPathExpr: $(POD_NAME) mounts, and an init container sets each subtree root to 0700 owned 10000:10000. All pod containers run uid 10000. The isolation boundary now is, exactly:

  1. kubelet subPath scoping — no container in pod N has any mount of pod M's subtree, so another tenant's records are simply absent from the mount namespace;
  2. the agent container mounts none of the store: it gets read-only $(POD_NAME)/context and $(POD_NAME)/binding only and reaches records solely through the loopback API — SO-45's threat ("the agent edits the audit log; it has write on the PVC") has no filesystem path;
  3. the hux sidecar keeps readOnlyRootFilesystem, drop-ALL and the 127.0.0.1:8790 bind (SO-01), with port 8790 absent from every Service and NetworkPolicy (SO-02 holds unchanged).

Not defended: an attacker who can mount the whole claim (node or cluster compromise) sees every tenant's records — the same trust in kubelet the per-tenant home PVC model already rested on. Within a pod, same-uid means an agent-container escape that can rearrange mounts is equivalent to kubelet compromise; accepted for this increment and recorded here rather than claimed away.

SO-01..SO-04 status

  • SO-01 satisfied: HUX_BIND=127.0.0.1, HUX_PORT=8790, asserted by the delivery gate (test_hermes_hux_delivery.py) against the manifest.
  • SO-02 satisfied: no hux port in any Service or NetworkPolicy.
  • SO-03/SO-04: the router (services/hermes/router/main.go) deletes every inbound header matching prefix x-hux- (case-insensitive) at the authenticated boundary before asserting X-Hermes-Tenant-Identity; regression-tested in main_test.go. The router itself sets no X-Hux-*: the in-pod WebUI BFF asserts them from trusted context, and the subject is pinned by the pod-local binding file below.

Persistent subject and context identity

The init-hux-runtime init container (root; capabilities CHOWN/DAC_OVERRIDE/FOWNER only) provisions per pod a persistent random 32-byte key context/context-key (0600) and an immutable binding binding/subject (0440, created O_EXCL, verified byte-for-byte, mode, owner and link count on every restart — a mismatch fails the pod):

usr_<HMAC-SHA256(context-key, "hux.subject.id.v1\0slot-N")>

Properties: stable across restarts (the key persists on the claim); derived from a per-pod key, so subjects are unlinkable across tenants; and never derived from the Keycloak subject, so no OAuth identifier can be recovered from a stored usr_ hash (strictly stronger than ADR-0001's identityHash("keycloak", subject) plan). A redaction canary context/redaction-canary (0400) feeds HUX_CANARY_FILE.

Relay and worker key lifecycle

HUX_RELAY_KEY_FILE and HUX_WORKER_KEY_FILE point into Memory-medium emptyDirs written by the init container: 0400, regenerated on every pod restart, never stored on the PVC. A leaked relay or worker key therefore expires at the next restart. SO-07's canary obligation extends to these files.

Evidence trust class (HUX-12)

A fourth trust class evidence (hux/release_security.py) with these obligations:

  • file-only key: HUX_RELEASE_EVIDENCE_KEY_FILE must be a 0400 regular file, symlinks refused, key ≥ 32 chars; inline environment secrets are ignored;
  • strict policy allowlist: HUX_RELEASE_EVIDENCE_POLICY_FILE (hux.release_evidence_policy.v1), exact field sets, ≤ 16 workloads, credential-free HTTPS URLs, bounded evidence age;
  • fail-closed capability: without both files healthy the HUX-12 capability is off (flags.enabled), not degraded;
  • separation: router trust alone creates reviewed proposals; evidence trust alone transitions and may call nothing else (route allowlist in hux/http.py, api surface required); SO-08 narrows again — worker trust is read-only on releases (list/read only).

SO-46 / SO-48 / SO-53 actual status (verified in code, 2026-08-24)

  • SO-53 rate limits: IMPLEMENTED. hux/http.py RateLimiter keeps per-subject one-minute read and write buckets (defaults 300/30, overridden by HUX_READS_PER_MINUTE/HUX_WRITES_PER_MINUTE; the staged manifest sets 600/120), returns 429 rate_limited with Retry-After, caps the bucket table at 4096, and runs immediately after identity resolution, before route matching and flag checks.
  • SO-46 hash-chained audit: STILL OPEN for the general audit ledger. hux/audit.py appends plain JSONL outcome rows with no prev hash. The HUX-12 release ledger is hash-chained end to end (previous_hash/entry_hash, verified on every read in hux/releases.py) — that covers release honesty, not the audit trail.
  • SO-48 single-writer lock: STILL OPEN. No flock anywhere in the package; hux/store.py uses in-process threading.RLock per family only. The per-pod $(POD_NAME) subtree makes a second writer against the same tree unlikely (it would need a second pod with the same name), but the obligation as written — fail at startup on v1/.lock — is not implemented.