The fastest way to run untrusted JavaScript safely.
SandCastle is a JavaScript sandbox that just works. Install it, run code, get results. No binaries to download, no Docker, no configuration. It auto-detects your runtime (Bun or Node.js) and picks the fastest isolation backend.
Bun: 66,000 ops/sec (zero dependencies)
Node.js: 380,000 ops/sec (via isolated-vm)
# Bun (zero dependencies)
bun add @grayhaven/sandcastle
# Node.js (installs isolated-vm for V8 sandboxing)
npm install @grayhaven/sandcastle isolated-vmimport { evaluate, run } from "@grayhaven/sandcastle";
await evaluate("1 + 1"); // 2
await evaluate("x * y", { x: 6, y: 7 }); // 42
await run("return items.filter(x => x > 2)", [1,2,3,4]); // [3, 4]No constructor. No setup. It works out of the box.
You can. But you'll end up writing SandCastle yourself.
Here's what "run untrusted JS with isolated-vm" actually looks like when you need console capture, input injection, timeouts, error handling, and result extraction — the stuff every real app needs:
// Raw isolated-vm: ~40 lines for a single execution
import ivm from "isolated-vm";
const isolate = new ivm.Isolate({ memoryLimit: 128 });
const context = isolate.createContextSync();
const jail = context.global;
const logs = [];
const cb = new ivm.Callback((level, msg) => logs.push({ level, msg }));
jail.setSync("__cb", cb);
context.evalSync(`
var console = {
log: (...a) => __cb("log", a.map(String).join(" ")),
warn: (...a) => __cb("warn", a.map(String).join(" ")),
error: (...a) => __cb("error", a.map(String).join(" ")),
};
`);
const inputCopy = new ivm.ExternalCopy({ x: 21 });
jail.setSync("__input", inputCopy);
context.evalSync("var input = __input.copy();");
inputCopy.release();
try {
const wrapped = `(()=>{try{return JSON.stringify({ok:true,value:(()=>{return input.x * 2})()})}catch(e){return JSON.stringify({ok:false,error:e.message})}})()`;
const raw = context.evalSync(wrapped, { timeout: 10000 });
const result = JSON.parse(String(raw));
// now manually build your result object, handle errors, extract logs...
} finally {
context.release();
isolate.dispose();
}// SandCastle: 1 line
import { run } from "@grayhaven/sandcastle";
await run("return input.x * 2", { x: 21 }); // 42And the performance is identical — we benchmarked both doing the same work:
| Scenario | SandCastle | Raw isolated-vm |
|---|---|---|
| Simple expression | 88,000 ops/sec | 100,000 ops/sec |
| JSON processing (100 items) | 21,000 ops/sec | 22,000 ops/sec |
| String template rendering | 31,000 ops/sec | 32,000 ops/sec |
| Console-heavy (10 logs/call) | 17,000 ops/sec | 17,000 ops/sec |
| Error handling | 58,000 ops/sec | 58,000 ops/sec |
| Compute (fibonacci 30) | 219 ops/sec | 216 ops/sec |
Zero overhead. SandCastle gives you eval(), wrap(), session(), batch(), test(), presets, middleware, globals injection, streaming console, typed errors, and execution transcripts — at the same speed as hand-rolling it yourself. Plus it works on Bun with zero dependencies.
No return needed. Inject variables as globals.
await evaluate("Math.max(1, 5, 3)"); // 5
await evaluate("name.toUpperCase()", { name: "alice" }); // "ALICE"
await evaluate("items.length", { items: [1, 2, 3] }); // 3Use return to produce output. Second argument becomes input in the sandbox.
await run("return 1 + 1"); // 2
await run("return input.x * 2", { x: 21 }); // 42
await run("return input.map(x => x * 10)", [1, 2, 3]); // [10, 20, 30]import { SandCastle } from "@grayhaven/sandcastle";
const sc = new SandCastle({
defaults: { timeoutMs: 5_000, memoryMb: 64 },
pool: { maxIsolates: 8 },
hostFunctions: {
getPrice: (ticker) => prices[ticker],
},
onConsole: (level, msg) => console.log(`[sandbox] ${msg}`),
});
await sc.run("return getPrice('AAPL')");await sc.eval("x + y", { x: 40, y: 2 }); // 42
await sc.eval("[1,2,3].map(x => x * 2)"); // [2, 4, 6]Turn sandbox code into a function you call like any other function.
const double = sc.wrap<number, [number]>("return args[0] * 2");
await double(21); // 42
await double(5); // 10
const greet = sc.wrap<string>("return `Hello, ${name}!`");
await greet({ name: "Alice" }); // "Hello, Alice!"Variables, functions, and state carry across calls. Like a REPL.
const session = await sc.session();
await session.run("var counter = 0");
await session.run("counter++");
await session.run("counter++");
await session.eval("counter"); // 2
await session.run("function double(x) { return x * 2 }");
await session.eval("double(21)"); // 42
session.dispose();const results = await sc.batch([
"return 1 + 1",
"return 2 + 2",
"return 3 + 3",
]);
// [2, 4, 6]await sc.test("return 1 + 1"); // true
await sc.test("throw new Error('no')"); // false
await sc.test("while(true){}"); // false (timeout)const result = await sc.execute({ code: 'console.log("hi"); return 42' });
result.ok // true
result.value // 42
result.ms // 0.3
result.logs // [{ level: "log", message: "hi", ts: 0 }]
result.memoryBytes // 2048000
result.status // { type: "success" }
result.transcript // full execution transcript// Tight limits (32MB, 1s timeout) — for untrusted user code
const sc = SandCastle.strict();
// Generous limits (512MB, 60s timeout, large pool) — for internal tools
const sc = SandCastle.permissive();sc.use({
beforeExecute(ctx) {
console.log("Starting execution...");
},
afterExecute(ctx, result) {
metrics.record("sandbox_ms", result.ms);
metrics.record("sandbox_memory", result.memoryBytes);
},
onError(ctx, error) {
logger.error("Sandbox failed", error);
},
});Pass variables directly into the sandbox scope:
await sc.run("return greeting + ' ' + name", {
globals: { greeting: "Hello", name: "World" },
});
// "Hello World"
// Globals and input work together
await sc.run("return input.x + bonus", {
globals: { bonus: 10 },
input: { x: 32 },
});
// 42Expose Node.js/Bun functions to sandboxed code:
const sc = new SandCastle({
hostFunctions: {
fetchPrice: (ticker) => prices[ticker],
log: (msg) => console.log("[sandbox]", msg),
readConfig: (key) => config[key],
},
});
await sc.run("log(fetchPrice('AAPL')); return readConfig('max_retries')");Get console.log output in real-time as code executes:
const sc = new SandCastle({
onConsole: (level, message, ts) => {
process.stderr.write(`[${level}] ${message}\n`);
},
});Sandbox code also supports console.time(), console.timeEnd(), and console.timeLog().
Top-level await works naturally:
await sc.run(`
const data = await Promise.resolve({ name: "test" });
return data.name;
`);
// "test"import { TimeoutError, MemoryExceededError, GuestError } from "@grayhaven/sandcastle";
try {
await sc.run("while(true){}");
} catch (e) {
if (e instanceof TimeoutError) {
// e.result has the full execution result
// e.guestStack has the sandbox stack trace
}
}const controller = new AbortController();
setTimeout(() => controller.abort(), 100);
await sc.execute({
code: "while(true){}",
signal: controller.signal,
});SandCastle auto-detects your runtime and picks the best backend:
| Runtime | Backend | Dependencies | Performance |
|---|---|---|---|
| Bun | Native Worker threads (JSC) | None | 66,000 ops/sec |
| Node.js | V8 isolates (isolated-vm) | isolated-vm |
380,000 ops/sec |
On Bun: Each execution runs in a separate Worker thread with its own JavaScriptCore context. Zero npm dependencies — just bun add @grayhaven/sandcastle and go.
On Node.js: Uses isolated-vm for in-process V8 isolates with context reuse and evalSync for minimal overhead.
Both backends provide:
- Separate JS context per execution (no shared globals)
- Timeout enforcement
- Memory limits
- Console capture
- Structured error reporting
Enable isolate/worker pooling for maximum throughput:
const sc = new SandCastle({ pool: { maxIsolates: 8 } });This reuses warm isolates/workers across calls instead of creating new ones, which is where the 66K-380K ops/sec numbers come from.
SandCastle is designed for AI agent code execution:
const sandbox = new SandCastle();
const tool = {
name: "run_code",
description: "Execute JavaScript in a secure sandbox",
execute: async ({ code, input }) => {
const result = await sandbox.execute({ code, input });
if (result.ok) return result.value;
return `Error: ${result.status.message}`;
},
};Replace N sequential tool calls with 1 code execution — up to 80% token reduction:
import { createCodeTool, TwoPassExecutor } from "@grayhaven/sandcastle/codemode";
const tools = [
{
name: "getUser",
description: "Get user by ID",
inputSchema: { type: "object", properties: { id: { type: "number" } }, required: ["id"] },
execute: async ({ id }) => db.getUser(id),
},
{
name: "sendEmail",
description: "Send an email",
inputSchema: { type: "object", properties: { to: { type: "string" }, body: { type: "string" } }, required: ["to", "body"] },
execute: async (input) => mailer.send(input),
},
];
const codemode = createCodeTool({ tools, executor: new TwoPassExecutor() });
// Give `codemode` to your LLM as a single toolNothing to deploy — the sandbox runs in-process. Just install and use.
// Next.js API route
import { run } from "@grayhaven/sandcastle";
export async function POST(req: Request) {
const { code, input } = await req.json();
return Response.json(await run(code, input));
}For microservices or multi-language backends:
npx sandcastle serve --http 0.0.0.0:8080
curl -X POST http://localhost:8080/execute \
-H 'Content-Type: application/json' \
-d '{"code": "return input.x * 2", "input": {"x": 21}}'const sc = new SandCastle({ httpEndpoint: "http://localhost:8080" });docker compose up -dWorks out of the box on Node.js 18+. On Bun-based serverless, zero dependencies.
| Solution | Install | Latency | Isolation | Dependencies |
|---|---|---|---|---|
| SandCastle (Bun) | bun add |
15µs pooled | Worker thread | None |
| SandCastle (Node) | npm install |
2.6µs pooled | V8 isolate | isolated-vm |
| isolated-vm (raw) | npm install |
~0.5ms | V8 isolate | Native addon |
| Docker | Docker daemon | ~500ms | Container | Docker |
| E2B | API key | ~100ms | Firecracker VM | Network |
eval() |
Built-in | ~0.01ms | None | None |
Guest code runs in an isolated context with no access to the host:
- No filesystem — no
fs, norequire('fs') - No network — no
fetch, no sockets (unless you expose them via host functions) - No process access — no
process.exit, nochild_process - Timeout enforcement — infinite loops are killed
- Memory limits — configurable per-sandbox caps
- Host functions are opt-in — the sandbox can only call what you explicitly expose
Apache 2.0