How Instatic's Plugin Sandbox Security Works: Multi-Layer Isolation and Permission Controls

Instatic isolates third-party plugins in a multi-layer sandbox that combines Bun process isolation, a restricted WebAssembly-based QuickJS VM, strict resource limits, and a fine-grained permission model to prevent malicious code from escaping or compromising the host system.

Instatic, an open-source project maintained by CoreBunch, implements a robust plugin architecture designed to safely execute untrusted code. Understanding Instatic's plugin sandbox security is essential for developers building extensions or auditing the platform's threat model.

Sandbox Architecture Overview

The plugin execution environment in Instatic is engineered as a stack of isolated layers, each providing a distinct security boundary. According to the source code in [server/plugins/quickjs/vm.ts](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/vm.ts), the topology consists of four distinct layers:

  • Bun Host (Main Process) – The core server that manages plugin lifecycles and spawns worker threads.
  • Bun Worker – A dedicated worker thread providing crash isolation and preventing hung plugins from blocking the main event loop.
  • QuickJS-WASM Context – A WebAssembly instance of the QuickJS engine that executes plugin code with no direct host environment access.
  • Bootstrap + Plugin Source – The SDK façade evaluated first, followed by the compiled plugin bundle (an IIFE populating globalThis.__plugin_exports).

This layered approach ensures that a vulnerability in one layer does not automatically compromise the entire system.

Process Isolation with Bun Workers

Each plugin runs inside a dedicated Bun.Worker created before the QuickJS context is instantiated. This design provides two critical safety guarantees:

  • Crash Isolation – Uncaught errors or fatal crashes inside a plugin terminate only the worker process, leaving the main server and other plugins unaffected.
  • CPU Yielding – Long-running computations cannot block the host's event loop because they execute within the worker's isolated thread.

The worker acts as the first line of defense, containing failures before they reach the WebAssembly boundary.

QuickJS WebAssembly Sandbox

Within the worker, plugin code executes inside a QuickJS engine compiled to WebAssembly (quickjs-emscripten). This VM configuration deliberately restricts capabilities to minimize attack surface:

  • Limited Host Functions – Only three host functions are exposed: __hostCall, __hostSleep, and __log. All other system APIs are unavailable to the guest code.
  • Synchronous Execution – The VM uses the synchronous QuickJS variant exclusively, as the async implementation has known stability issues that could compromise isolation.
  • Secure Dispatchers – After the bootstrap evaluates, the system captures stable dispatcher functions (__runLifecycle, __runRoute, etc.) to prevent plugins from hijacking host call entry points (see lines 24-31 in [server/plugins/quickjs/vm.ts](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/vm.ts)).

Resource Limits and Defense in Depth

Instatic enforces hard resource caps defined in [server/plugins/quickjs/limits.ts](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/limits.ts) to prevent denial-of-service attacks. The host applies these limits when creating each VM context (lines 102-108 in vm.ts):

Limit Constant Value Security Function
Memory DEFAULT_MEMORY_LIMIT_BYTES 64 MiB Prevents heap exhaustion attacks
Stack DEFAULT_STACK_SIZE_BYTES 1 MiB Blocks runaway recursion
Eval Timeout DEFAULT_EVAL_TIMEOUT_MS 5 seconds Interrupts infinite loops
Module Timeout MODULE_PACK_EVAL_TIMEOUT_MS 2 seconds Protects canvas preview modules

When a plugin exceeds any limit, the VM aborts the operation and logs an error with the plugin identifier, allowing administrators to identify misbehaving extensions.

Permission-Based API Gating

Access to sensitive capabilities requires explicit declarations in the plugin manifest, defined in [src/core/plugins/manifest.ts](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/manifest.ts). The system distinguishes between requested permissions and granted permissions:

  • permissions – The list of capabilities the plugin requests.
  • grantedPermissions – The subset the host administrator actually approves.

At runtime, the assertPluginPermission function in [src/core/plugins/runtime.ts](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/runtime.ts) (lines 74-78) validates every host API call against the granted permission set. If a plugin lacks a required permission, the runtime skips the registration and logs a diagnostic message (lines 515-525).

Common permissions include:

  • editor.commands – Required for registering palette commands.
  • frontend.assets – Allows injecting scripts or styles into the frontend.
  • network.outbound – Permits external HTTP requests (with hostname restrictions).

Network Outbound Controls

The network.outbound permission implements an explicit allowlist of approved hostnames. When a plugin invokes api.http.fetch, the host validates the target URL against this allowlist before establishing any connection. This prevents compromised plugins from exfiltrating data to arbitrary third-party services or contacting command-and-control servers.

Secure Host-Plugin Communication

All data crossing the VM boundary is serialized as plain strings via ctx.newString to avoid complex marshalling vulnerabilities. The bootstrap's dispatcher functions receive JSON payloads, which the host validates using TypeBox schemas before processing. This guarantees that malformed or malicious data cannot corrupt the host's internal state or trigger deserialization attacks.

Cleanup and Resource Disposal

When a plugin unloads, the runtime invokes dispose() on the VM instance to prevent resource leaks and reference retention:

  • Clears all host function handles.
  • Cancels pending timers and intervals.
  • Resolves or rejects pending deferred promises.
  • Releases QuickJS handles and the underlying WebAssembly context.

This thorough teardown ensures that plugins cannot maintain covert persistence through dangling references.

Code Examples

The following examples demonstrate how the sandbox constraints manifest in practice:

// Registration requires explicit permission checking
pluginRuntime.registerCommand({
  id: 'my-plugin.say-hello',
  title: 'Say Hello',
  handler: () => console.log('Hello!'),
  // Runtime rejects this if 'editor.commands' not in grantedPermissions
});
// Network requests are validated against the manifest allowlist
await api.http.fetch('https://api.example.com/data', {
  method: 'GET',
});
// Throws if 'network.outbound' missing or host not allowlisted
// Internal VM creation showing the layered initialization
const vm = await createPluginVm({
  pluginSource: compiledPluginBundle,
  env: { pluginId: 'my-plugin', grantedPermissions: ['editor.commands'] },
});
await vm.__runLifecycle('activate');

Summary

  • Multi-layer isolation combines Bun Workers, QuickJS WebAssembly, and strict resource limits to contain untrusted code.
  • Process boundaries prevent plugin crashes from affecting the host server or other plugins.
  • Resource caps (64MB memory, 1MB stack, 5-second timeouts) block denial-of-service attempts.
  • Permission gating via assertPluginPermission ensures plugins access only explicitly granted APIs.
  • Network restrictions use hostname allowlists to prevent unauthorized outbound connections.
  • Secure disposal clears all handles and timers when plugins unload to prevent memory leaks or persistence.

Frequently Asked Questions

What happens when a plugin exceeds the 64MB memory limit?

The QuickJS VM throws an OutOfMemory error inside the sandbox and aborts the current operation. The host logs the violation with the plugin identifier but the main server and worker process remain stable, as the memory limit is enforced at the WebAssembly level before it can affect the host heap.

Can plugins access the file system directly?

No. The QuickJS-WASM context exposes only three host functions (__hostCall, __hostSleep, __log) and provides no file system APIs. Any file access must route through the host via __hostCall, which validates the request against the plugin's granted permissions. Without specific filesystem-related permissions (which the current permission set does not include by default), such calls are rejected.

How does the permission model prevent plugins from registering unauthorized commands?

When a plugin attempts to register a command via pluginRuntime.registerCommand(), the runtime checks for the editor.commands permission using assertPluginPermission in [src/core/plugins/runtime.ts](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/runtime.ts). If the permission is absent from the grantedPermissions array defined in the manifest, the registration is skipped and a diagnostic message is logged, preventing the plugin from injecting UI elements or handlers into the editor.

Why does Instatic use synchronous QuickJS instead of the async variant?

The Instatic team deliberately avoids the async QuickJS implementation due to stability issues that could compromise the sandbox's integrity. The synchronous variant provides predictable execution control and reliable timeout enforcement through the DEFAULT_EVAL_TIMEOUT_MS limit, ensuring that long-running or hung operations can be interrupted cleanly without risking VM corruption.

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 →