Skip to content
GitHub Get Started
Use Cases

Plugin Systems

Let users upload scripts or extensions without risking your host. The host controls what plugin code runs, what capabilities are available, and what structured data comes back.

The host owns the plugin source and the input. Run the plugin with run() inside a sandboxed VM and get a structured value back: the guest calls globalThis.__return(value) with any JSON-serializable value, and that value is decoded on the host as result.value.

The plugin below gets filesystem access but no network access. The guest proves it cannot reach the network, then transforms the host-supplied input.

import { NodeRuntime } from "secure-exec";
// Boot a sandboxed VM for running untrusted plugin code. The plugin can use
// the filesystem, but network access is denied. (childProcess/process stay
// allowed because the kernel spawns the guest `node` process to run the
// plugin - denying it would block the runtime itself.)
const runtime = await NodeRuntime.create({
permissions: {
fs: "allow",
network: "deny",
childProcess: "allow",
process: "allow",
env: "allow",
},
});
try {
// The host owns the plugin source and the input. Here the plugin is a
// title-case transformer; in a real system it would be uploaded by a user.
const pluginSource = `
function transform(input, options = {}) {
const words = String(input)
.split(/\\s+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
return (options.prefix ?? "") + words.join(" ");
}
const manifest = { name: "title-case", version: "1.0.0" };
`;
const input = "hello from plugin land";
const options = { prefix: "Plugin says: " };
// Run the plugin in isolation and get a structured value back via run().
// The guest calls __return() with a JSON-serializable value, decoded on the
// host as result.value. The plugin also proves it cannot reach the network.
const { value, stdout, exitCode } = await runtime.run<{
manifest: { name: string; version: string };
output: string;
networkBlocked: boolean;
}>(`
${pluginSource}
console.log("running plugin:", manifest.name);
let networkBlocked = false;
try {
await fetch("http://example.com");
} catch {
networkBlocked = true;
}
__return({
manifest,
output: transform(${JSON.stringify(input)}, ${JSON.stringify(options)}),
networkBlocked,
});
`);
console.log("guest stdout:", stdout.trim());
console.log("exit code:", exitCode);
console.log("plugin name:", value?.manifest.name);
console.log("plugin version:", value?.manifest.version);
console.log("plugin output:", value?.output);
console.log("network blocked:", value?.networkBlocked);
} finally {
await runtime.dispose();
}

See Full Example

The plugin executes inside the kernel isolation boundary with only the capabilities you granted (network access is denied here), and the host gets back structured data via __return() rather than direct access to plugin internals.

You can combine this with TypeScript to type-check uploaded plugin code before enabling it.

Denying a capability outright is one option, but most plugin systems need the opposite: the plugin must reach a few host capabilities, just not the raw underlying access. The canonical pattern is host tools. You register a narrow set of named tools whose handlers run on the host, and the untrusted plugin invokes them by name. The plugin never gets the database connection, the secret, or the network socket behind the tool; it only gets the curated surface you chose to expose.

Register host tools with the tools option on create() (or add them to a live runtime with rt.registerTools()). Each tool becomes a named command inside the VM, and the plugin invokes it with the callHostTool(name, input) global, which resolves with the host handler’s return value.

import { NodeRuntime } from "secure-exec";
// Register curated host tools. Their handlers run on the host, so the plugin
// reaches these capabilities only through the tool surface you expose - never
// the underlying database, secrets, or network behind them.
const runtime = await NodeRuntime.create({
tools: {
lookupUser: {
description: "Look up a user by id",
inputSchema: {
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
},
// Runs on the host. In a real system this would hit your database.
handler: ({ id }: { id: string }) => ({
id,
name: id === "u_1" ? "Ada Lovelace" : "Unknown",
}),
},
},
});
try {
// The untrusted plugin reaches the host capability only through callHostTool,
// which resolves with the handler's return value.
const { value } = await runtime.run<{ id: string; name: string }>(`
const user = await callHostTool("lookupUser", { id: "u_1" });
__return(user);
`);
console.log("looked-up user:", value?.name);
} finally {
await runtime.dispose();
}

See Full Example

This is safe because the plugin reaches host capability only through the curated tool surface, never the underlying access behind it. The tool permission scope gates invocation: when you pass tools and set no tool policy, the scope is granted so the registered tools are invocable, but you can supply your own permissions.tool policy to gate individual tools.

See Bindings for the full guide, including input schemas, command aliases, and worked examples.