Skip to main content

ADR 004: Plugin Sandbox Architecture

  • Status: Accepted
  • Date: 2026-06-12
  • Deciders: Lance Miller, Claude (A1.2)

Context

GospeLib's plugin host (M15-004) allows core plugins to run in the main thread with full API access. As the ecosystem opens to community-authored plugins (M26), untrusted code must be isolated to prevent DOM manipulation, data exfiltration, and denial-of-service (infinite loops, memory exhaustion).

Key requirements:

  1. Community plugin code must never access window, document, fetch, or eval.
  2. Each plugin operation must be time-bounded (5 s).
  3. Memory consumption must be capped per plugin (16 MB).
  4. The host exposes a restricted API surface via message-passing: api.passages.get, api.annotations.create, api.ui.registerPanel, api.ui.registerToolbarButton.
  5. Permissions declared in the plugin manifest gate each API call at runtime (see ADR-003: Plugin Permissions).

Decision

Use QuickJS compiled to WebAssembly via the quickjs-emscripten npm package as the sandbox runtime for community plugins.

Runtime model

AspectDetail
VM engineQuickJS WASM (single-threaded, deterministic, no JIT)
Module initnewQuickJSWASMModule() -- lazy singleton
Per-plugin instanceSeparate QuickJSRuntime + QuickJSContext
Time limitruntime.setInterruptHandler checked every quantum; 5 s cap
Memory limitruntime.setMemoryLimit(16 * 1024 * 1024)
API bridgeHost functions exposed as globals on context.global
CommunicationSynchronous host calls; async results via QuickJS promises

Permission enforcement

Permissions (passages.read, annotations.write, ui.panel, ui.toolbar) are checked at call time inside each bridge function, not at registration time. The API object tree is always present on api.* so plugin code can feature-detect capabilities, but calling a method without the corresponding permission throws immediately.

Official vs community routing

Core plugins (manifest core: true) bypass the sandbox entirely and continue to run in the main thread with the full GospeLibAPI. The PluginHost.activate() path is unchanged for them. Only community plugins (installed via the Plugin Manager) are routed through loadPluginInSandbox.

Signature verification

Plugin packages fetched from the CDN carry an optional ed25519 signature. Verification uses the Web Crypto API (SubtleCrypto.verify with Ed25519). Browsers that do not yet support Ed25519 fall back to accepting unsigned packages with a console warning. This is a temporary measure until Ed25519 support is universally available.

Consequences

Positive

  • Community plugins cannot access the DOM, network, or eval -- all data flows through the auditable bridge layer.
  • Time and memory limits prevent denial-of-service from hung or greedy plugins.
  • Permission enforcement gives users control over what each plugin can do.
  • QuickJS WASM adds ~400 KB gzipped to the bundle but only loads lazily when the first community plugin is activated.

Negative

  • QuickJS is an interpreter (no JIT) so plugin code runs slower than native JS. This is acceptable because plugins perform small operations (data lookup, annotation create) rather than heavy computation.
  • Async host API calls require QuickJS promise plumbing which adds complexity to the bridge layer.

Neutral

  • The sandbox module is self-contained in apps/web/lib/plugin-host/sandbox.ts and does not affect the existing plugin host or core plugin paths.
  • The Dexie plugins table (v7) stores raw source code per plugin for bootstrap-on-reload without re-fetching from CDN.