A capability-based JavaScript sandbox in the browser
Plugins, user formulas, automation scripts — sometimes you need to run JavaScript you didn't write. NanoVM's Boa runner evaluates it in Boa, a JavaScript engine written in Rust and compiled to WebAssembly. The script runs in its own engine context, can't see your page's globals, and can touch nothing until you grant a capability.
No powers by default
A fresh engine has no filesystem, no command execution and only console. Everything else is opt-in through expose, and members you don't grant are never registered — they don't exist inside the sandbox, so there is nothing to bypass. Values cross the boundary as JSON.
| Option | Type | Default | Description |
|---|---|---|---|
expose.fs | "none" | "readonly" | "readwrite" | "none" | readonly adds nano.fs.readText/readFile/list/exists; readwrite adds writeFile. |
expose.run | boolean | false | Adds nano.run, nano.sh and nano.exec: BusyBox commands on the RISC-V VM. |
expose.node | boolean | false | Adds nano.node(args). |
expose.webapis | ("console" | "encoding" | "url" | "timers")[] | ["console"] | Which WebAPI globals exist (TextEncoder, URL, setTimeout, …). |
limits.loopIterations | number | — | Maximum iterations of any single loop before the script throws. |
limits.recursion | number | — | Maximum call depth. |
timeoutMs | number | — | Host watchdog for asynchronous work; on expiry the engine is disposed and the call rejects. |
syncOnly | boolean | false | Register only synchronous bridge members (no run, node or async host functions). |
globalName | string | "nano" | Name of the bridge global. |
env | object | — | Read-only JSON bag exposed as <global>.env. |
The same probe at three capability levels
One script, evaluated three times with different expose grants. It reports which bridge members exist.
const probe = `JSON.stringify({
fs: typeof nano.fs,
writeFile: typeof (nano.fs && nano.fs.writeFile),
run: typeof nano.run,
})`;
for (const fs of ["none", "readonly", "readwrite"]) {
print(fs.padEnd(9), await nano.script(probe, { expose: { fs } }));
}
print("with run:", await nano.script(probe, { expose: { run: true } }));none {"fs":"undefined","writeFile":"undefined","run":"undefined"}
readonly {"fs":"object","writeFile":"undefined","run":"undefined"}
readwrite {"fs":"object","writeFile":"function","run":"undefined"}
with run: {"fs":"undefined","writeFile":"undefined","run":"function"}A write attempt with read-only access
The script may read /work/config.json but tries to overwrite it. writeFile isn't there, so it throws inside the sandbox and the file is untouched.
nano.fs.writeFile("/work/config.json", '{"admin":false}');
try {
await nano.script(`
const cfg = JSON.parse(nano.fs.readText("/work/config.json"));
cfg.admin = true;
nano.fs.writeFile("/work/config.json", JSON.stringify(cfg));
`, { expose: { fs: "readonly" } });
} catch (e) {
print("denied:", e.name, "-", e.message);
}
print("config is still", nano.fs.readText("/work/config.json"));denied: ScriptError - TypeError: not a callable function
at <main> (unknown at :4:22)
config is still {"admin":false}Allowed: scripts that drive the VM
With grants, a script becomes a small automation layer over the VM. The samples below use the page's default sandbox for Boa sandbox samples: fs: "readwrite", run: true, and the console, encoding, url and timers WebAPIs.
Plain JavaScript in Boa
The last expression is the result; it comes back as JSON.
const words = "the quick brown fox jumps over the lazy dog".split(" ");
console.log("words:", words.length);
({ longest: words.reduce((a, b) => (b.length > a.length ? b : a)), sorted: [...words].sort().slice(0, 3) })words: 9
→ {
"longest": "quick",
"sorted": [
"brown",
"dog",
"fox"
]
}Read, transform and write files
A seeded CSV is parsed in the sandbox and written back as JSON through the granted nano.fs.
const csv = nano.fs.readText("/work/sales.csv");
const rows = csv.trim().split("\n").slice(1).map((l) => l.split(","));
const total = rows.reduce((s, [, n]) => s + Number(n), 0);
nano.fs.writeFile("/work/summary.json", JSON.stringify({ rows: rows.length, total }));
console.log(nano.fs.readText("/work/summary.json"));{"rows":3,"total":245}Run BusyBox commands from a script
nano.run (granted by run: true) executes a command on the RISC-V VM and resolves with its result. Boa evaluates scripts, not modules, so top-level await goes inside an async function.
(async () => {
const r = await nano.run("uname -m");
console.log("arch:", r.stdout.trim(), "exit:", r.exitCode);
})()arch: riscv64 exit: 0
Limits and timeouts
Capabilities stop a script from reaching things; limits stop it from hogging the tab. loopIterations and recursion are enforced by the engine on synchronous code. timeoutMs is a host-side watchdog checked while the engine waits on asynchronous host work (such as nano.run); when it fires, the engine is disposed and the call rejects.
An infinite loop, stopped
while (true) {} would freeze a naive eval. With a loop limit it throws after 100 000 iterations.
try {
await nano.script("let n = 0; while (true) { n++; }", { limits: { loopIterations: 100_000 } });
} catch (e) {
print("stopped:", e.message.split("\n")[0]);
}stopped: RuntimeLimit: Maximum loop iteration limit 100000 exceeded
Runaway recursion, stopped
A recursion cap turns unbounded recursion into a catchable error instead of a crashed engine.
try {
await nano.script("function f(n) { return f(n + 1) + 1; } f(0)", { limits: { recursion: 200 } });
} catch (e) {
print("stopped:", e.message.split("\n")[0]);
}stopped: RuntimeLimit: exceeded maximum number of recursive calls
A watchdog timeout on async work
The script keeps the VM busy forever. timeoutMs: 1000 disposes the engine about a second in.
const t0 = Date.now();
try {
await nano.script("(async () => { for (;;) await nano.run('true'); })()", { expose: { run: true }, timeoutMs: 1000 });
} catch (e) {
print(e.message, "after ~" + Math.round((Date.now() - t0) / 1000) + "s");
}script timed out after 1000ms after ~1s
Host functions and globals
For plugin systems, nano.scripting(opts) returns a long-lived engine. registerFunction exposes a host callback (async by default; results come back as JSON) and defineGlobal injects plain data. Call dispose() when you are done to free the engine's heap.
A plugin calling back into the host
The script sees only PRICES and tax() — your page decides what tax does.
const sandbox = await nano.scripting({ onStdout: (s) => print(s.trimEnd()) });
sandbox.defineGlobal("PRICES", { apple: 3, pear: 5 });
sandbox.registerFunction("tax", (x) => Math.round(x * 1.2));
const total = await sandbox.eval(`
(async () => {
let sum = 0;
for (const k of Object.keys(PRICES)) sum += await tax(PRICES[k]);
console.log("items:", Object.keys(PRICES).length);
return sum;
})()
`);
print("total with tax:", total);
sandbox.dispose();items: 2 total with tax: 10
When to use which runner
| You need to… | Use |
|---|---|
| Run code you don't trust, with explicit grants | Boa runner — nano.script() / nano.scripting() |
| Run a Node.js program or npm tool | Node on the host engine or the RISC-V VM |
| Run a compiled CLI tool | The wasm runner or the RISC-V VM |
import { createNano } from "@userland-run/nano-sdk";
const nano = await createNano({
image: { wasm: "/nano/console.wasm" },
ramMB: 512,
scripting: { wasm: "/nano/boa.wasm" }, // fetched lazily, on the first script
});
const out = await nano.script(userCode, {
expose: { fs: "readonly", webapis: ["console"] },
limits: { loopIterations: 1e6, recursion: 256 },
timeoutMs: 2000,
});FAQ
Can a sandboxed script access the page's DOM, cookies or window?
No. The script runs inside a separate Boa engine compiled to WebAssembly. Its only links to the outside are the bridge members you grant with expose, functions you register and globals you define.
What happens when a script throws or times out?
The call rejects with a ScriptError carrying the message. On a watchdog timeout the engine is also disposed, so a misbehaving script can't continue in the background.
Is boa.wasm downloaded for every page?
Only when scripting is configured and the first script runs: scripting: { wasm } is loaded lazily, so apps that never script pay nothing.