Fast, sandboxed V8 isolate runtime with permission-controlled
fetchand pluggable modules. Built for the AI-agent prefix/postfix pattern.
A minimal, composable V8 sandbox. Pure JavaScript execution in a separate Rust process.
Memory and CPU limits enforced at the V8 level. Async time spent waiting on host work
doesn't count against the CPU budget. Host code decides what fetch can reach and what
modules the sandbox can import — nothing leaks unless it's handed over by name.
Status: core execution works end-to-end. Not yet at 1.0.
The canonical pattern this is built for: host application sets up tools, data, and library bindings as a prefix; an AI agent generates a postfix that uses them; the host executes the postfix and gets a result. The host wants:
- Hard limits on memory and active execution time per run.
- Fine control over which URLs and which modules the sandbox can reach.
- Sub-5 ms cold start once the prefix is compiled (snapshot-based).
- A small, auditable runtime — no Node-stdlib emulation, no kernel, no virtual POSIX.
import { createSandbox } from '@iso4/sandbox'
import { createSafeFetch } from '@iso4/fetch'
const sandbox = await createSandbox()
const prefix = await sandbox.precompile({
code: `
const config = { apiBase: 'https://api.example.com' }
globalThis.config = config
`,
globals: {
fetch: createSafeFetch({
rules: {
host: 'api.example.com',
routes: [{ path: '/users/**', methods: 'GET' }],
},
}),
},
})
const result = await prefix.run({
code: `
const res = await fetch(config.apiBase + '/users')
const users = res.json()
export default { count: users.length }
`,
limits: { cpuTimeMs: 200, memoryMb: 64 },
})
if (result.ok) {
console.log(result.exports.default) // { count: 42 }
}
await sandbox.dispose()| Package | Status | Description |
|---|---|---|
@iso4/sandbox |
working | Subprocess V8 sandbox — crash-isolated, host bridge, snapshot-based prefixes |
@iso4/fetch |
working | Hardened fetch for sandbox globals: DNS pinning, SSRF blocking, route-based allowlist, middleware, redirect re-checking |
@iso4/embed |
future | In-process V8 sandbox for high-throughput trusted code (NAPI, no crash isolation) |
@iso4/fs |
future | node:fs stub factory with configurable root + permissions |
@iso4/crypto |
future | node:crypto stub factory (safe subset) |
@iso4/v8-<platform> |
working | Per-platform Rust binaries for @iso4/sandbox (built in CI, not committed) |
Requires Node 24+ and pnpm. Mise pins the toolchain (mise.toml).
pnpm install # bootstrap workspace
pnpm build:dev # build native binary (debug) + all TS packages
pnpm build # build native binary (release) + all TS packages
pnpm test:run # cargo test (Rust) + vitest across all packages
pnpm lint # eslint
pnpm lint:fix # eslint --fix
pnpm typecheck # tsc --noEmit across all packages
pnpm changeset # record a per-package version bumpsandbox.run({ code, limits })— direct sandboxed execution ✅sandbox.precompile()+prefix.run()— snapshot-based prefix/postfix ✅- CPU and wall-clock limits enforced, async wait excluded from CPU budget ✅
- Host-declared globals bridged into V8 (
fetch,myTool, any name) ✅ ERR_UNDECLARED_BINDINGenforced onprefix.run()globals ✅- Host-provided imports — source modules (
string) and host modules (objects with function/data leaves) resolved viaimportin sandbox code ✅ AbortSignalsupport — cancel in-flight runs;RunResult.statusdiscriminates'completed'|'failed'|'aborted', withreasonon abort ✅- Error propagation — thrown error
name,stack, and extra enumerable properties (error.fields) survive the bridge in both directions ✅ - Bridge call limits —
maxBridgeCalls,maxBridgeCallBytes,maxExportBytesper run ✅ @iso4/fetch— rules-based origin + route allowlist, three-level middleware, DNS pinning, SSRF/redirect protection ✅
globals wires any non-reserved name directly into the sandbox's global
object. The bridge is completely generic — the name fetch is not special:
const result = await sandbox.run({
code: `
const data = await searchWeb('cats')
export default data.results
`,
globals: {
searchWeb: async (query: string) => {
const res = await fetch(`https://api.example.com/search?q=${encodeURIComponent(query)}`)
return res.json()
},
},
})The handler receives the raw arguments from sandbox code and must return plain serializable data. Functions in return values are currently dropped.
Sandboxed code can carry an ambient value across await points without
threading it through every call — and, unlike a module-level variable,
concurrent async chains stay isolated. Imported the Node way:
const result = await sandbox.run({
code: `
import { AsyncLocalStorage } from 'node:async_hooks'
const keyScope = new AsyncLocalStorage()
// A durable-workflow style step: each nested step appends to the key,
// so a step nested inside another never collides with the same name used
// elsewhere.
function step(name, body) {
const parent = keyScope.getStore() ?? ''
return keyScope.run(parent ? parent + '/' + name : name, body)
}
let innerKey
await step('charge', async () => {
await step('validate', async () => {
await Promise.resolve()
innerKey = keyScope.getStore() // 'charge/validate'
})
})
export default innerKey
`,
})
// result.exports.default === 'charge/validate'Only run(store, callback, ...args) and getStore() are provided — the
concurrency-safe core. It's built on V8's continuation-preserved embedder data
(the same primitive modern Node uses), registers no promise hooks, and is
always available to run code at no cost unless used. It is not available in
precompile() (prefix) code — it's for the postfix. See DESIGN.md §16.
MIT © schplitt