@iso4/sandbox
v0.4.1
Published
Fast, sandboxed V8 isolate runtime for agent-generated JavaScript. Two-process architecture for crash isolation.
Maintainers
Readme
@iso4/sandbox
Fast, sandboxed V8 isolate runtime for agent-generated JavaScript. Runs user code in a separate Rust process for full crash isolation — an OOM or panic in the sandbox kills only the subprocess, not your host application.
Built for the AI-agent prefix/postfix pattern: precompile host setup (globals, libraries, tool bindings) once into a V8 startup snapshot, then run many agent-generated code strings against the snapshot in parallel.
Status: core execution works end-to-end. Not yet at 1.0.
Install
npm i @iso4/sandbox
# hardened fetch defaults (recommended):
npm i @iso4/fetchQuick start
import { createSandbox } from '@iso4/sandbox'
import { createSafeFetch } from '@iso4/fetch'
const sandbox = await createSandbox()
// Compile host setup once into a V8 snapshot
const prefix = await sandbox.prepare({
code: `
const config = { apiBase: 'https://api.example.com' }
globalThis.config = config
`,
globals: {
fetch: createSafeFetch({ policy: ({ host }) => host === 'api.example.com' }),
},
})
// Run agent-generated code against the snapshot — as many times as needed
const result = await prefix.execute({
code: `
const res = await fetch(config.apiBase + '/users')
export default { count: res.length }
`,
limits: { cpuTimeMs: 200, wallTimeMs: 5_000, memoryMb: 64 },
})
if (result.ok) {
console.log(result.exports.default) // { count: 42 }
} else {
console.error(result.error.code, result.error.message)
}
await sandbox.dispose()
sandbox.prepare()andprefix.execute()are the current names. The former names —sandbox.precompile()andprefix.run()— remain as deprecated aliases with identical behavior and will be removed in a future major.
How globals work
globals wires any non-reserved name directly into the sandbox's global
object as a bridge stub. The bridge is fully generic — fetch is not
special-cased:
const options = {
globals: {
searchWeb: async (query: string) => {
const res = await fetch(`https://api.example.com/search?q=${encodeURIComponent(query)}`)
return res.json()
},
}
}Functions in bridge return values are currently dropped — return plain data, not class instances with methods.
TypeScript-checked rebinding
prepare() infers the globals shape G from what you pass, and the
returned Prefix<G> only allows rebinding those names at run time:
const prefix = await sandbox.prepare({
globals: { fetch: defaultFetch, myTool: defaultTool },
})
prefix.execute({ globals: { fetch: perUserFetch } }) // ✅ rebind one
prefix.execute({ globals: { unknown: handler } }) // ❌ TS errorResource limits
prefix.execute({
code: agentCode,
limits: {
cpuTimeMs: 200, // active JS execution only (await time excluded)
wallTimeMs: 5_000, // hard cap including async waits
memoryMb: 64, // V8 heap + ArrayBuffer budget
maxBridgeCalls: 10, // max host-bridge calls per run (0 = unlimited)
maxBridgePayloadBytes: 0, // max bytes per bridge call (0 = 64 MiB framing cap)
},
})Async context (AsyncLocalStorage)
Run/postfix code can import a minimal, Node-compatible AsyncLocalStorage to
carry an ambient value across await points — concurrency-safe, unlike a
module variable:
prefix.execute({
code: `
import { AsyncLocalStorage } from 'node:async_hooks'
const als = new AsyncLocalStorage()
export default await als.run('trace-42', async () => {
await somethingAsync()
return als.getStore() // 'trace-42', even several awaits deep
})
`,
})Only run(store, callback, ...args) and getStore() are provided. Built on
V8's continuation-preserved embedder data; no promise hooks, so it's free
unless used. Not available in prepare() (prefix) code — it's for the
postfix. See DESIGN.md §16.
Result shape
type RunResult
= | { ok: true, exports: SandboxExports, stdout: string[], stderr: string[], durationMs: number, cpuTimeMs: number, bridgeCalls: BridgeCallEntry[] }
| { ok: false, error: RunError, stdout: string[], stderr: string[], durationMs: number, cpuTimeMs: number, bridgeCalls: BridgeCallEntry[] }
// durationMs — wall-clock time of the run; cpuTimeMs — active V8 execution
// time (bridge waits excluded). Both measured in the runtime, µs resolution.
interface BridgeCallEntry { // recorded in the Rust runtime; one per attempt, in order
name: string // 'fetch', 'myTool', or '<specifier>.<path>' for host-module imports
startMs: number // offset from run start (same clock as durationMs)
durationMs: number // round-trip the sandbox waited (handler + IPC)
argBytes: number // serialized call payload size
responseBytes: number // serialized response value size (0 on handler error)
ok: boolean
blocked: boolean // blocked by a limit runtime-side; never reached the host
}
interface RunError {
code: RunErrorCode
name: string
message: string
stack?: string
fields?: Record<string, unknown> // all other own-enumerable props of the thrown error
}run() never throws for sandboxed failures — only for infrastructure errors
(subprocess crashed, binary not found). ok: false with an error code is the
normal failure path.
Thrown errors keep their identity across the bridge, in both directions:
- Sandbox → host: an uncaught sandbox throw surfaces as
ERR_USER_CODEwith the error's realname,message,stack, and every other own-enumerable property undererror.fields(namespaced so a customcodeproperty can't collide with the iso4error.code).name/message/stackare reserved and never appear insidefields. - Host → sandbox: a host handler that throws rejects the sandbox call with
a real
Errorcarrying the samename(instanceof TypeErrorworks for built-ins) and its extra properties re-attached directly (e.status,e.reason, …). Sandbox code can catch it and continue; uncaught it fails the run withERR_HOST_BRIDGE. The host stack never crosses into the sandbox.
Architecture
V8 runs in a separate Rust subprocess communicating over a Unix domain socket.
A pool of connections (one per maxIsolates slot) provides concurrency —
five concurrent prefix.execute() calls each get their own slot and execute in
parallel.
License
MIT
