Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/docs/security/plugin-threat-model.md
T

56 KiB
Raw Blame History

Plugin Runtime Threat Model

Phase: Phase 06 — Plugin System (#21 WASM Sandbox, #22 Hooks/Lifecycle, #23 Audit/Docs) Author: Claude Opus 4.7 (1M context) Last revised: 2026-05-19 Status: Internal draft — feeds the external audit-package (B-006). Scope: Everything a third-party WASM plugin can touch — from manifest parsing through the worker invocation path, the audit pipeline, the lifecycle DB rows, and the tenant-admin UI surface.

This document is the operator-facing input to the external Cure53 / SySS / secfault audit. It enumerates the assets the runtime protects, applies STRIDE per asset, and maps each mitigation to a concrete file:line in the codebase. Every claim a mitigation makes must be verifiable by reading the linked source. If you find a mitigation cited here that no longer exists, file the gap in docs/security/plugin-internal-findings.md and patch the model in the same commit.

The model intentionally treats the plugin code as adversarial — a malicious plugin author, a compromised supply chain, or a buggy plugin that happens to do the wrong thing under load are all in scope. Threats from outside the boilerplate (compromised CI worker, stolen DB credentials, malicious host operator) are out of scope and handled by docs/security/threat-model.md (the broader platform model).


1. Asset catalogue

ID Asset Where it lives Why it matters
A-01 Plugin WASM binary Filesystem on worker-plugin pod, loaded via loadBinary seam in apps/worker/src/jobs/plugin-invoke.ts:83 Untrusted code that executes in-process. Sandbox-escape = host RCE.
A-02 Plugin manifest (Plugin.manifest JSON) prisma/schema/plugins.prisma:19 (Plugin.manifest) Declares which capabilities the plugin is allowed to request. Tampering = privilege escalation.
A-03 Ed25519 trusted-key allowlist Env var PLUGIN_TRUSTED_PUBLIC_KEYS, parsed at packages/plugin-runtime/src/signature.ts:95 (parseTrustedPublicKeys) Trust root for every binary. Compromise = arbitrary code execution under any plugin id.
A-04 Capability gateway invocation state packages/plugin-runtime/src/capability-gateway.ts:78 (createCapabilityGateway) The single chokepoint between WASM and host I/O. Bypass = arbitrary host calls.
A-05 PluginCapabilityUse audit rows prisma/schema/plugins.prisma:77 (model PluginCapabilityUse) Tamper-evidence for "what did this plugin do?". Loss = no forensics; manipulation = false alibi.
A-06 Plugin-owned SQL migrations applyPluginMigration in packages/plugin-runtime/src/migrations.ts:200 DDL run as the host DB role. Bypass = cross-tenant DROP/GRANT.
A-07 Plugin DB rows (Plugin, PluginUserConfig) prisma/schema/plugins.prisma:14, :48 Lifecycle state. Tampering = silent auto-enable / wrong key/sha pinning.
A-08 Tenant install API apps/web/app/routes/api.tenants.$tenant.plugins.tsx:43 (action) Entry-point a tenant admin uses to install plugins. CSRF / permission failure = takeover.
A-09 Hook outbox events (plugin.hook.*) packages/plugin-runtime/src/hooks/emit.ts:91 (emitPluginHook) Carrier between host business writes and plugin invocations. Cross-tenant fan-out is the catastrophic failure.
A-10 Crash tracker counters packages/plugin-runtime/src/crash-tracker.ts:90 (createCrashTracker) + SharedStore keys plugin:crash:<pluginId>:<tenantId> Wrong increment = unwarranted auto-disable (availability) or missed disable (uptime + cost).
A-11 Sandbox memory (WebAssembly.Memory cap) packages/plugin-runtime/src/runtime.ts:117 A plugin that grows past the cap can OOM the worker.
A-12 Plugin output buffer apps/worker/src/jobs/plugin-invoke.ts:248 (sanitization site) Plugin-controlled bytes flowing into audit/log/Sentry. Risk: PII exfiltration into operator-visible surfaces.
A-13 Rate-limit counters Deps seam consumeRateLimit in apps/worker/src/jobs/plugin-invoke.ts:91 Without enforcement, a single plugin can starve all other tenants.
A-14 Plugin worker emergency stop Dedicated worker-plugin service (WORKER_QUEUE_GROUP=plugin) plus queue pause/drain and lifecycle disable controls Last line of defence. Failure to execute = no emergency-stop.

2. STRIDE matrix per asset

Each row is Asset → Threat → Concrete attack scenario → Code-mapped mitigations. Mitigation links use file:line for the primary anchor. Every linked line must exist; if a refactor moves it, update this table in the same commit. The CI hook in .gitea/workflows/plugin-threat-model-check.yml (to be added by the security-findings cleanup) will grep for stale anchors.

A-01 — Plugin WASM binary

STRIDE Attack scenario Mitigation(s) Residual risk
Spoofing Attacker uploads a binary that pretends to come from a trusted publisher. (1) Ed25519 verify at invoke time: apps/worker/src/jobs/plugin-invoke.ts:193 calls verifyPluginSignature from packages/plugin-runtime/src/signature.ts:48. (2) Trusted-key allowlist enforced at signature.ts:59-61. (3) SHA-256 of binary must match manifest.wasmSha256 (signature.ts:53-56). A leaked publisher key. Mitigation: rotate via env update + redeploy worker. Documented in docs/architecture/plugin-system.md.
Tampering Binary on disk is replaced after install. SHA-256 is recomputed on every invocation (signature.ts:53); a tampered binary fails binary_hash_mismatch and the plugin-invoke job returns signature_invalid (plugin-invoke.ts:198-205). None significant — verification is per-invocation, not per-install.
Repudiation Operator/tenant denies running the binary. Per-invocation audit row is fail-closed in plugin-invoke.ts: audit write failure logs plugin.invoke.audit_failed and throws so the queue retries/dead-letters. Per-capability-use rows go through PluginCapabilityUse outbox; the production plugin worker enables recorder fail-closed mode. Local/dev callers can still use best-effort capability telemetry when they intentionally leave failClosed disabled.
Info disclosure Binary embeds sensitive identifiers (publisher secret accidentally bundled). Out of scope for the runtime. Publisher CI lint can catch (packages/cli/src/commands/plugins/lint.ts). Publisher-side hygiene; not enforceable here.
DoS Binary contains an infinite loop or memory-bomb. (1) Plugin invocation runs in a worker-thread sandbox and PLUGIN_MAX_WALL_CLOCK_MS terminates non-yielding workers (apps/worker/src/jobs/plugin-sandbox-isolated.ts). (2) Fuel counter is charged per host-fn call (runtime.ts:170, gateway capability-gateway.ts:120). (3) Memory cap enforced by WebAssembly.Memory({ maximum }) at runtime.ts:117-118. (4) Trap detection branches to memory_limit_exceeded / trap outcomes (runtime.ts:176-183). (5) Per-tenant sliding-window rate limit consumeRateLimit (plugin-invoke.ts:208-212). Per-instruction fuel is still not available in V8. Pure-WASM loops are bounded by coarse wall-clock termination, not instruction accounting; Wasmtime remains the richer audit target.
Elevation Binary uses host functions it did not declare. Capability gateway rejects undeclared invocations: capability-gateway.ts:96-105 (manifestDeclaresCapability check) throws CapabilityDeniedError. None — closed enum + manifest-declared subset is necessary AND sufficient.

A-02 — Plugin manifest

STRIDE Attack scenario Mitigation(s) Residual risk
Spoofing Manifest claims signedBy of a trusted publisher but the binary is unsigned. Manifest's signedBy is compared against the trusted-key allowlist before signature verification runs (signature.ts:58-61). Verification uses the manifest-declared key, then confirms the key is trusted. None — trust check + verify are atomic in verifyPluginSignature.
Tampering Manifest is mutated post-install to silently grant new capabilities. (1) Manifest is canonicalised + included in the signed payload (signature.ts:54-56 for binary hash; signature scope per docs/architecture/plugin-system.md). (2) Install-time F-005 checks reject untrusted publisher keys and run verifyPluginSignature against the pre-positioned binary before creating the Plugin row. (3) Stored manifest is loaded fresh at invocation (plugin-invoke.ts:173-181) — no caching means a mutation requires a row update. App-level plugin lifecycle mutations are gated by app.plugins.manage; direct row mutation requires DB access. None for the signed-binary/install path; direct DB writes remain an operator/DB-access boundary.
Repudiation Tenant claims they never approved capability X. Lifecycle service writes an audit event on install (PluginLifecycleService.ts:81-100 — see installPlugin). The audit-events store is hash-chained. Tenant settings requires explicit list-level capability review plus a server-generated manifest digest before writing missing grants. Grant audit metadata records plugin version, review digest, approved capability names, approved scope JSON and the matching manifest scope snapshot. The plugin audit host binding itself emits structured worker log/capability telemetry, not a durable audit-events DB row. None for the install/grant events themselves. Residual UX work remains around a fuller guided tenant-admin consent dialog — F-001.
Info disclosure Manifest contains sensitive metadata. Description field max 2048 chars (manifest.ts:61), strict Zod schema (.strict() at manifest.ts:38, 46, 53, 75). None — manifest is operator-visible by design.
DoS Manifest declares thousands of capabilities or impossibly long strings. Closed-enum PLUGIN_CAPABILITY_NAMES (capabilities.ts:13-23) caps the capability vocabulary at 9 entries. String fields have explicit max lengths in manifest.ts:60-67. None significant.
Elevation Manifest invents a new capability name. z.enum(PLUGIN_CAPABILITY_NAMES) (manifest.ts:43) rejects anything not in the closed enum. parsePluginManifest returns issues; installPlugin throws manifest_invalid at PluginLifecycleService.ts:74. None — the enum is the trust boundary.

A-03 — Ed25519 trusted-key allowlist

STRIDE Attack scenario Mitigation(s) Residual risk
Spoofing Adversary tries to add a key to the allowlist via runtime path. Allowlist is read from env only at process start (signature.ts:95-101); no runtime mutation path exists. Adding requires a pod restart. Operator must restrict who can edit the worker env (Coolify role). Out of runtime scope.
Tampering Env value modified mid-run. parseTrustedPublicKeys returns a fresh ReadonlyArray per call. Each worker invocation snapshots trustedPublicKeysHex (plugin-invoke.ts:109) — mutation requires worker restart. None.
Repudiation Operator denies adding a key. Coolify env-change history. Not the runtime's job. Out of scope.
Info disclosure Public keys leak. Public keys are public by definition. No risk. None.
DoS Allowlist set to empty / invalid CSV → no plugins can run. Fail-closed is intentional: invalid hex entries are filtered at signature.ts:99-100; empty list → verifyPluginSignature returns public_key_not_trusted (signature.ts:59); invocation returns signature_invalid. Operator must intentionally seed the env. None — fail-closed by design.
Elevation Wrong key added (typo) effectively trusts a malicious publisher. Operator process: keys are added via PR review per the documented runbook in docs/architecture/plugin-system.md. Operator-side; mitigated by review.

A-04 — Capability gateway

STRIDE Attack scenario Mitigation(s) Residual risk
Spoofing Plugin calls a host-fn under a capability it doesn't have. manifestDeclaresCapability check on every invoke (capability-gateway.ts:96); throws CapabilityDeniedError (:104). None — deny by default.
Tampering Plugin tries to mutate the hostFns map after gateway creation. Map is captured by closure at capability-gateway.ts:79; no exposed mutator. None.
Repudiation Denied call leaves no audit trace. onCall notifier fires for denied outcome (capability-gateway.ts:98-103); the recorder buffers it for the batch audit-write (audit.ts:108-121). Audit sink can be down — see A-05 residual.
Info disclosure Error message leaks host internals. CapabilityDeniedError.message is "capability denied: <name> (<reason>)" only (capability-gateway.ts:56). No stack/host internals exposed to the plugin. None.
DoS Plugin spams capability calls to drain CPU. Fuel charged per call (:120); fuelCharged() records host-fn fuel for audit/limits planning. Default cost 100 fuel/call (:71). Invocation wall-clock is bounded by PLUGIN_MAX_WALL_CLOCK_MS. Fuel cap is host-fn-based, not per-instruction. F-002 remains the Wasmtime upgrade track.
Elevation notifyCall throws and tears down the host before the capability result is returned. Try/catch isolates the synchronous notifier (capability-gateway.ts:85-90), then production flush handles durable-sink failure at sandbox close with failClosed: true. The immediate host-fn result is not interrupted by notifier bugs; production still fails closed before acking the invocation.

A-05 — PluginCapabilityUse audit rows

STRIDE Attack scenario Mitigation(s) Residual risk
Spoofing Attacker writes rows with another tenant's id. Recorder is invocation-scoped — tenantId set once at construction (audit.ts:114-119) and copied into every record. Insertion goes through PluginCapabilityUseStore.insertMany (apps/worker/src/jobs/plugin-capability-use.ts:39), itself called only from the worker handler. None at the runtime layer. The outbox queue must remain operator-only writeable.
Tampering Rows modified after-write to hide an exfiltration. Postgres-level: only the schema-owner role can UPDATE directly. App-level: there is no UPDATE PluginCapabilityUse code path. Rows are hash-chained per tenant through prevHash / rowHash, and operators verify integrity with hivegrid-cli plugins verify-audit-chain <tenantId>. Keep the verifier in the production runbook; a web endpoint remains deferred.
Repudiation Operator/tenant denies an action. Two-tier evidence: per-invocation row (Plugin.installedAt + audit-events table for install) and per-capability-use row. None.
Info disclosure Body content stored in audit → DSGVO violation. Bodies are NEVER stored. payloadHash is SHA-256 of an arg-shape descriptor only (audit.ts:173-175). No URL bodies, no DB row payloads. None when used as designed. Plugin authors writing custom descriptor strings that contain PII are a separate problem — hashCapabilityPayload should be the only path.
DoS Plugin spams the recorder until heap exhausts. maxEntries cap (default 1024) per invocation (audit.ts:91, 109); excess entries are dropped with a warn log. None.
Elevation A failed audit write surfaces as a plugin error. Recorder callers choose the failure mode. The production plugin worker passes failClosed: true, so sink failures throw during sandbox finalization and the invocation is recorded as failed instead of silently succeeding. Local/dev callers can intentionally leave best-effort telemetry enabled; production bootstrap must not.

A-06 — Plugin-owned SQL migrations

STRIDE Attack scenario Mitigation(s) Residual risk
Spoofing Plugin migration claims to create plugin_acme_* but actually creates users. Regex extraction of the table name + prefix check (migrations.ts:99-104). Reject = prefix_violation. The regex requires the simple CREATE TABLE name ( ... ) shape; nested CTEs / CREATE TABLE LIKE are rejected outright by the splitter at migrations.ts:174-180.
Tampering Migration uses pg_* system functions to mutate operator data. FORBIDDEN_TOKENS list at migrations.ts:69-81 catches GRANT, REVOKE, CREATE USER, ALTER USER, DROP USER, DROP DATABASE, DROP SCHEMA, COPY, TRUNCATE, SET ROLE, PG_. Comments stripped before scan (migrations.ts:61-67). Token-list approach; a clever encoding could in principle smuggle. F-006 — additionally compare via pg_query_emscripten AST for defence in depth (deferred).
Repudiation Plugin author claims they didn't intend the bad migration. Migration SQL is part of the manifest (in #22 follow-up — stored alongside Plugin row JSON). Migration apply is audited via applyPluginMigration logger (migrations.ts:218-222). Gap: no per-statement audit row. F-007 — per-statement audit write before execution.
Info disclosure Migration SELECTs from another tenant's table. Only CREATE TABLE and CREATE INDEX are allowed (migrations.ts:171-181). SELECT is rejected as forbidden_statement. None.
DoS Migration creates 1000 tables. Statement count is not capped — a manifest can list arbitrarily many statements. F-008 — add MAX_STATEMENTS_PER_MIGRATION cap (default 32). Currently bounded by manifest size limits only.
Elevation Migration creates a table without tenantId, then plugin reads all rows. Body must contain \btenantId\b (migrations.ts:106-110); rejection is missing_tenant_id_column. Regex matches in any context (comment, default value). Acceptable: false positives are operator-visible failures; false negatives are blocked because the runtime never disables this check.

A-07 — Plugin DB rows (Plugin / PluginUserConfig)

STRIDE Attack scenario Mitigation(s) Residual risk
Spoofing Tenant A's row claims to belong to tenant B. @@unique([tenantId, pluginId]) at plugins.prisma:31; install path captures tenantId from URL through getTenantIdFromUrl (api.tenants.$tenant.plugins.tsx:37). None at the row level.
Tampering Tenant admin disables auto-disable by editing the row. Direct row mutation requires DB access. App-level mutators go through PluginLifecycleService.enable/disable which audit. No update enabled endpoint exists outside `op=enable disable (api.tenants.$tenant.plugins.tsx:54-61`). None at the app layer.
Repudiation Who installed the plugin? installPlugin writes an audit event with actor (PluginLifecycleService.ts:81-100). None.
Info disclosure Listing plugins across tenants. listPlugins scoped by tenantId (PluginLifecycleService.ts:listPlugins). Loader path verifies app.plugins.use for the URL's tenant before returning rows (api.tenants.$tenant.plugins.tsx:65-69). None.
DoS Tenant installs 10k plugins → DB bloat + worker thrashing. @@unique([tenantId, pluginId]) prevents duplicate-id installs. F-009 is closed: installPlugin enforces PLUGIN_MAX_INSTALLS_PER_TENANT with default cap 50 and returns tenant_install_limit_exceeded as HTTP 429. None at the default cap; operators can set a non-positive override to disable the cap in controlled environments.
Elevation Admin of tenant A enables a plugin owned by tenant B. enable/disablePlugin queries by pluginRowId AND tenantId (PluginLifecycleService.ts:142+). None.

A-08 — Tenant install API

STRIDE Attack scenario Mitigation(s) Residual risk
Spoofing CSRF: tricked admin POSTs install on their own tenant. validateCSRFToken(request) (api.tenants.$tenant.plugins.tsx:46). JSON clients use the x-csrf-token header path so the body remains available for request.json(). F-010 is closed; keep the header-token contract for JSON install clients.
Tampering Modified body bypasses validation. Body parsed as JSON; required fields explicitly checked before install. The lifecycle service then enforces the production runtime gate, Zod manifest validation, inclusive host-version range, trusted-key match, disk binary verification, submitted SHA match, duplicate pluginId rejection, and tenant install cap. None for declared fields.
Repudiation Admin denies install. resolveActor captures user id + IP into the audit (api.tenants.$tenant.plugins.tsx:88-100). None.
Info disclosure Error message leaks internal state. PluginLifecycleError codes are enumerated (PluginLifecycleService.ts:35-44); messages are operator-controlled. None significant.
DoS Repeated installs DOS the DB. Permission gate app.plugins.manage + tenant scoping limit blast radius. F-011 is closed: the install route uses a SharedStore sliding-window limiter with pseudonymous tenant/client keys and default PLUGIN_INSTALL_RATE_LIMIT_PER_MIN=5; over limit returns 429 + Retry-After: 60. Infrastructure failures return 503 in every environment. Edge throttling remains useful against distributed traffic before application authentication.
Elevation Non-admin user calls install. verifyUserHasPermission(request, "app.plugins.manage", tenantId) (api.tenants.$tenant.plugins.tsx:72-76). None.

A-09 — Hook outbox events

STRIDE Attack scenario Mitigation(s) Residual risk
Spoofing Producer writes an event for a tenant it doesn't own. emitPluginHook is called only from .server.ts host code paths that already have tenantId from a permission-gated loader/action. Producer trust = host code trust. Out of scope; trust is at the host-code call site.
Tampering Outbox row mutated before dispatch. Outbox dispatcher is operator-only; no app-level mutate path. Payload is Zod-validated before publish (emit.ts:98-101). None.
Repudiation "We didn't fire that hook." Outbox row + per-invocation audit at the worker side give two independent timelines. None.
Info disclosure Hook payload over-shares (e.g. user PII). Each hook has a strict Zod schema with .strict() (registry.ts:36, 46, 57). Adding a field requires editing the registry. None at runtime; schema design discipline at the host-team level.
DoS Hook fan-out explodes (one host write → N plugins → N×M jobs). Single outbox row per hook write (foundation deviation #3 in hooks-lifecycle.md); F-012 is closed: plugin.hook.* outbox events route to outbox.plugin-hook, worker-side fan-out filters manifest-declared subscribers by tenantId, and plugin invocation remains bounded by consumeRateLimit per plugin. Residual accepted: a tenant with many enabled subscribers can still create bursty plugin-invoke traffic within configured per-plugin limits.
Elevation Plugin in tenant A receives an event for tenant B. Each plugin.invoke job carries tenantId from the source event (plugin-invoke.ts:48); worker handler scopes loadPlugin by tenantId (:173-181). Plugin only runs for its own tenant rows. None.

A-10 — Crash tracker counters

STRIDE Attack scenario Mitigation(s) Residual risk
Spoofing Attacker increments another tenant's counter to force their auto-disable. Counter key is plugin:crash:<pluginId>:<tenantId> (crash-tracker.ts:82). Only recordFailure writes; only invoked from plugin-invoke.ts:157-161 with payload.tenantId from the job (not user input on the request path). Outbox payload trust — see A-09 elevation.
Tampering Direct SharedStore writes manipulate the counter. SharedStore credentials are operator-only. Out of scope.
Repudiation Why was my plugin auto-disabled? recordFailure returns { failureCount, autoDisabled }; logger emits plugin.crash.auto_disabled with full context (crash-tracker.ts:128-133). Auto-disable also writes a notification (notify seam). None significant.
Info disclosure Counter exposes invocation rate. Counter is per (plugin, tenant) — knowing the counter requires SharedStore access. None.
DoS Tenant intentionally triggers 10+ failures to disable a plugin they don't like. F-013 — the tracker counts only sandbox_failed / signature_invalid / binary_load_failed (configured at plugin-invoke.ts:150-155). A tenant cannot trigger signature_invalid from a user-facing path, but they can trigger sandbox_failed if they control the plugin input. Acceptable: a tenant disabling their own plugin is not a privilege escalation.
Elevation Bypass auto-disable by ensuring the counter resets. recordSuccess decrements (crash-tracker.ts:138-152) but only by 1 per success. Threshold-1 successes still leave threshold-1 failures. Auto-disable resets the counter (crash-tracker.ts:127) — re-enable starts at zero, which is intentional. None.

A-11 — Sandbox memory

STRIDE Attack scenario Mitigation(s) Residual risk
Spoofing/Tampering Plugin reads/writes outside its memory region. WebAssembly's memory-safety guarantees + Node's V8 enforce bounds. Trap → memory_limit_exceeded (runtime.ts:176-179). V8-implementation bugs are out of plugin's control; tracked by Chrome security advisories. Operator must keep Node.js patched.
DoS Memory-bomb grows past the cap. maximum page count enforced by WebAssembly.Memory({ initial, maximum }) (runtime.ts:117-118). None.
Elevation Memory escape to host. V8 sandbox boundary. This is the audit's principal Q. Mitigated by all the layers above plus operator hardening (seccomp/AppArmor — see Security Notes §worker-isolation in wasm-sandbox.md).

A-12 — Plugin output buffer

STRIDE Attack scenario Mitigation(s) Residual risk
Info disclosure Plugin reads tenant DB rows, packs them into its output, output lands in Sentry. (1) sanitizePluginOutput (packages/plugin-runtime/src/sanitize.ts) redacts PANs/IBANs/JWTs/API-keys; field-name awareness catches password/secret. (2) Applied at apps/worker/src/jobs/plugin-invoke.ts:248 before persistence. (3) Strictness from env PLUGIN_OUTPUT_SANITIZATION_STRICTNESS (default moderate). Regex-based sanitization is best-effort: a plugin that base64-encodes secrets evades the regex. Sanitization is defence-in-depth — the primary trust boundary is the capability gateway (no db.read → can't read tenant data).
DoS Plugin returns a 10 MB string into the audit. F-014 is closed: sanitizePluginOutput truncates large strings and carries a total output budget across the walk (PLUGIN_SANITIZE_TOTAL_BUDGET_BYTES, default 1 MiB); once exhausted, further string leaves become [SIZE_LIMIT]. Output-size exhaustion is bounded. Regex sanitization remains best-effort for encoded secrets; see the Info disclosure residual above.

A-13 — Rate-limit counters

STRIDE Attack scenario Mitigation(s) Residual risk
DoS Single tenant exhausts worker capacity. consumeRateLimit seam (plugin-invoke.ts:208-212) — when allowed=false the handler returns rate_limit_exceeded before invoking the sandbox. F-015 is mostly closed: the production plugin worker bootstrap wires a SharedStore-backed limiter, and missing production deps fail loudly rather than silently acknowledging plugin jobs. Remaining external signoff is the broader production WASM runtime review, not the rate-limiter wiring itself.

A-14 — Plugin worker emergency stop

STRIDE Attack scenario Mitigation(s) Residual risk
DoS / availability After a sandbox-escape report, runtime must be killable quickly. Stop/scale the dedicated worker-plugin service to zero (or redeploy it with WORKER_ENABLED=false), pause/drain hivegrid.plugin-invoke, and disable affected plugins through lifecycle controls. Residual operator risk: this is an operational stop path, not a runtime env flag. Runbooks and deploy tooling must keep worker-plugin separately controllable from core workers.

3. Cross-cutting invariants

These invariants apply to every asset above. Any code change that weakens an invariant is a security-relevant change and must be flagged in PR description.

  1. tenantId is host-injected, never plugin-supplied. Every plugin-runtime function that takes a tenant id reads it from a host-side source (URL param, job payload from outbox event, etc.). The plugin SDK gives plugins no API to set their own tenant id.
  2. Capabilities are deny-by-default and closed-enum. PLUGIN_CAPABILITY_NAMES is the trust boundary. Adding a capability requires (a) extending the enum, (b) implementing a host-fn, (c) reflecting in the manifest schema, (d) reflecting in this threat model.
  3. Audit sink failure is fail-closed in the production plugin worker. Invocation audit writes throw on persistence failure, and capability-use telemetry is configured with failClosed: true in plugin-invoke-bootstrap.ts. Local/dev recorder callers may still keep best-effort telemetry by omitting failClosed.
  4. Plugin output is sanitized before it leaves the worker process. Any new code path that emits plugin-output bytes (OTel spans, Sentry breadcrumbs, additional log statements) must funnel through sanitizePluginOutput. Re-verify on every PR touching plugin-invoke.ts or future observability wiring.
  5. No live mutation of the trust root. PLUGIN_TRUSTED_PUBLIC_KEYS is read once at boot. Restart-to-rotate is intentional.
  6. Plugin migrations are CREATE-only. No UPDATE/DELETE/ALTER/DROP allowed in the foundation grammar.
  7. Production plugin operations are explicitly gated. In production, lifecycle/fanout/runtime operations require PLUGIN_RUNTIME_PRODUCTION_ENABLED=true; the older runtime-enable flag is not the current gate.

4. Out-of-scope (handled elsewhere)

  • Container hardening (seccomp/AppArmor, user/group, capability(7) drops on the worker-plugin pod). Tracked in Plugin Lifecycle Operations and the external runtime signoff package.
  • CI worker integrity. The pipeline that produces these binaries is covered by docs/security/threat-model.md.
  • DB row encryption at rest. Postgres-level — operator responsibility.
  • Marketplace UX, plugin-discovery, plugin-store-rating. Not built; tracked in Phase 11 backlog.

5. Change log

Date Change Author
2026-05-19 Initial draft for B-006 external audit package. Claude Opus 4.7 (1M context)