How the QuickJS-WASM Plugin Sandbox Isolates and Executes Server Entrypoints

Instatic runs each plugin's server-side code inside a QuickJS-WASM virtual machine spawned within a Bun Worker, using a singleton WASM module, strict resource limits, and a minimal host-bridge API to ensure complete isolation from the host process.

The QuickJS-WASM plugin sandbox in Instatic provides a secure execution environment for untrusted server-side plugin code. Instead of running plugins directly in the host Node.js/Bun process, the system creates isolated QuickJS contexts within dedicated workers. According to the Instatic source code, this architecture ensures that plugin entrypoints cannot access the filesystem, network, or environment variables without explicit permission grants.

The Sandbox Architecture: Bun Worker to QuickJS Context

The isolation topology follows a three-layer hierarchy: Bun host → Bun Worker → QuickJS-WASM context. As implemented in server/plugins/quickjs/vm.ts, each active plugin receives its own Bun Worker, eliminating shared memory space with the host process.

The VM factory loads a singleton WASM module once per worker and reuses it for every context creation. This pattern, found at lines 65-71 in vm.ts, minimizes memory overhead while maintaining complete isolation between plugin instances. The WASM module serves as the security boundary; all plugin code executes within this VM without direct access to the underlying Bun runtime.

Isolation Guarantees and Security Boundaries

The QuickJS runtime operates with zero access to Node.js or Bun APIs, the filesystem, environment variables, or network capabilities unless explicitly granted. All host interaction flows through a restricted bridge comprising three polyfilled functions registered synchronously:

  • __hostCall – for asynchronous host operations
  • __hostSleep – for timer-based delays
  • __log – for debug output

These functions are wired at lines 11-13 and 90-95 in vm.ts, ensuring the plugin cannot invoke arbitrary host capabilities.

Before any plugin code executes, the system applies resource limits defined in server/plugins/quickjs/limits.ts. These constraints include maximum memory allocation, stack depth, and execution timeouts, preventing runaway scripts from consuming host resources. Limits are injected at lines 102-108 in vm.ts immediately after context creation.

How Plugin Entrypoints Are Loaded and Executed

Plugin bundles follow a specific format: a self-executing IIFE that attaches exported hooks to globalThis.__plugin_exports. The VM evaluates code in a strict sequence to maintain security invariants.

First, the bootstrap code from server/plugins/quickjs/bootstrap/index.ts executes. This bootstrap provides the SDK façade and registers dispatcher functions. Only after the bootstrap completes does the VM evaluate the actual plugin bundle. This ordering at lines 84-92 in vm.ts guarantees the plugin cannot overwrite the dispatcher handles established by the host.

After bootstrap execution, the VM stores persistent handles for dispatcher functions in a Map<DispatcherName, QuickJSHandle>. These dispatchers include:

  • __runLifecycle – for plugin lifecycle hooks
  • __runRoute – for HTTP route handlers
  • __runHookListener – for event listeners
  • __runLoopFetch – for scheduled tasks

The host invokes these dispatchers via ctx.callFunction, wrapped by helper methods callString and callVoid in server/plugins/quickjs/eval.ts (lines 24-30).

Host-to-Sandbox Communication Bridge

When plugin code calls __hostCall, the host creates a VM-side Promise using ctx.newPromise() and returns the handle immediately. The host then executes the actual asynchronous work (such as database queries or HTTP requests) outside the sandbox.

Once the operation completes, the host resolves the promise, and the VM's micro-task queue drains via runtime.executePendingJobs(). This cycle, detailed at lines 21-28 in vm.ts, propagates results back into the sandbox while keeping the untrusted code confined. The server/plugins/quickjs/marshal.ts file provides utilities (jsToHandle, handleToJs) that convert JSON-serializable values across this boundary.

Cleanup and Resource Management

Proper disposal prevents memory leaks and use-after-free errors. The dispose() method at lines 10-15 and 52-55 in vm.ts clears all host-function handles, dispatcher handles, pending timers, and any deferred promises. This ensures that when a plugin is disabled or restarted, no references persist in the host process.

For additional safety, src/core/plugins/sandboxScan.ts scans bundled plugin code for forbidden literals (such as node: prefixes or require( calls) before the code ever reaches the QuickJS sandbox.

Practical Implementation Example

The following example demonstrates creating a sandboxed VM and invoking an HTTP route handler:

import { createPluginVm } from '@/server/plugins/quickjs/vm'
import type { PluginVm } from '@/server/plugins/quickjs/types'

// Load the compiled plugin bundle produced by `instatic-plugin build`
const pluginSource = await Bun.file('./uploads/plugins/my-plugin.bundle.js').text()

// Create the VM with environment identifiers for the host bridge
const vm: PluginVm = await createPluginVm({
  pluginSource,
  env: { pluginId: 'my-plugin', pluginVersion: '1.0.0' },
})

// Invoke the route dispatcher for "/api/hello"
const responseJson = await vm.runRoute({
  path: '/api/hello',
  method: 'GET',
  body: null,
})

console.log('Plugin responded with:', responseJson)

// Clean up the VM when finished
await vm.dispose()

Inside the plugin, code exports handlers via the global object:

globalThis.__plugin_exports = {
  loopFetch: async ({ fetch }) => {
    const res = await fetch('https://api.example.com/data')
    const data = await res.json()
    return { body: JSON.stringify(data) }
  },
}

The host calls vm.runLoopFetch() to trigger the __runLoopFetch dispatcher, marshaling the fetch function across the bridge via __hostCall.

Summary

  • QuickJS-WASM provides the security boundary for plugin execution, with each plugin running in its own Bun Worker.
  • Singleton WASM module pattern minimizes memory while maintaining isolation between contexts.
  • Bootstrap-first evaluation ensures dispatchers are protected from plugin overwrite attempts.
  • Resource limits (memory, stack, timeout) enforced in limits.ts prevent resource exhaustion.
  • Host bridge via __hostCall uses VM-side Promises to handle async operations without exposing host APIs.
  • Automatic cleanup via dispose() prevents handle leaks and ensures safe plugin reloads.

Frequently Asked Questions

How does the QuickJS-WASM sandbox prevent plugins from accessing the filesystem?

The sandbox prevents filesystem access by running plugins inside a QuickJS virtual machine that has no built-in IO capabilities. Unlike Node.js or Bun, the QuickJS runtime does not expose fs, path, or process modules. The only communication channel is through the explicit __hostCall bridge, which the host controls. As implemented in server/plugins/quickjs/vm.ts, the VM context is created without any host environment bindings, ensuring that calls to require('fs') or Bun.file() fail immediately with reference errors.

What happens when a plugin exceeds the memory or execution time limits?

When a plugin exceeds the configured resource limits defined in server/plugins/quickjs/limits.ts, the QuickJS runtime throws an out-of-memory or interrupted exception that propagates to the host. The host catches these exceptions at the ctx.callFunction boundary in server/plugins/quickjs/eval.ts, terminates the specific plugin worker, and logs the violation. The limits are applied at lines 102-108 in vm.ts before any plugin code runs, ensuring that runaway scripts cannot impact the host process or other plugins.

Can plugins make HTTP requests or access external APIs?

Plugins can only make HTTP requests indirectly through the __hostCall bridge. When plugin code invokes fetch(), the SDK façade routes this to __hostCall, which the host handles by creating a ctx.newPromise(). The host performs the actual HTTP request outside the sandbox, then resolves the VM-side promise with the response. This architecture, detailed at lines 21-28 in vm.ts, means plugins cannot open raw sockets or bypass network policies, as all traffic flows through the host's audited HTTP client.

How does the system handle memory leaks in long-running plugins?

The system prevents memory leaks through explicit resource cleanup and worker isolation. The dispose() method in server/plugins/quickjs/vm.ts (lines 52-55) clears all QuickJS handles, pending timers, and deferred promises when a plugin is stopped. Because each plugin runs in a separate Bun Worker, terminating the worker via vm.dispose() releases all associated WASM memory back to the OS. Additionally, the singleton WASM module design ensures that recurring context creation does not accumulate module-level memory overhead.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →