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:
- Community plugin code must never access
window,document,fetch, oreval. - Each plugin operation must be time-bounded (5 s).
- Memory consumption must be capped per plugin (16 MB).
- The host exposes a restricted API surface via message-passing:
api.passages.get,api.annotations.create,api.ui.registerPanel,api.ui.registerToolbarButton. - 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
| Aspect | Detail |
|---|---|
| VM engine | QuickJS WASM (single-threaded, deterministic, no JIT) |
| Module init | newQuickJSWASMModule() -- lazy singleton |
| Per-plugin instance | Separate QuickJSRuntime + QuickJSContext |
| Time limit | runtime.setInterruptHandler checked every quantum; 5 s cap |
| Memory limit | runtime.setMemoryLimit(16 * 1024 * 1024) |
| API bridge | Host functions exposed as globals on context.global |
| Communication | Synchronous 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.tsand does not affect the existing plugin host or core plugin paths. - The Dexie
pluginstable (v7) stores raw source code per plugin for bootstrap-on-reload without re-fetching from CDN.
Related
- ADR-003: Plugin Permissions — the permissions model enforced by the sandbox bridge
- Decisions Overview — full ADR list