iso4
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.
Why
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 for typical prefixes — and sub-millisecond calls once a warm instance of that prefix is resident.
- A small, auditable runtime — no Node-stdlib emulation, no kernel, no virtual POSIX.
Quick example
import { createSandbox } from '@iso4/sandbox'
import { createSafeFetch } from '@iso4/fetch'
// soft limit of 128 MB, hard limit of 160 MB for warm runs (precompiled)
// one off runs have a hard limit of 128 MB and no soft limit
const sandbox = await createSandbox({ memoryMb: 128 })
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 is set on createSandbox, not per run
})
if (result.ok) {
console.log(result.exports.default) // { count: 42 }
}
await sandbox.dispose()
Calling into the sandbox
export default { fetch(request) } is a first-class shape: prepare the
module once, then call its exported handler per request with real typed
arguments — a Request crosses in, a Response crosses back, and the result
carries the handler's return value instead of exports:
const prefix = await sandbox.prepare({ code: workerBundle })
const result = await prefix.call({
export: 'default.fetch', // or any named export, e.g. 'handleEvent'
args: [new Request('https://example.com/in', { method: 'POST', body: 'hi' })],
})
if (result.ok) {
const response = result.value as Response
console.log(response.status, await response.text())
}
The same works on a direct run (sandbox.run({ code, call })), resolved
against the freshly evaluated module. The receiver is the exported object
itself, so handlers reading this behave normally; a path that does not
resolve to a callable fails with ERR_CALL_TARGET_NOT_FOUND.
Batch event streams
For a stream of events, hand the sandbox an array and take an array back. Arguments cross as one blob per call, so a batch of 32 pays one crossing instead of 32 — the single biggest throughput lever there is for event-shaped work:
const results = await prefix.call({
export: 'default.transform',
args: [events], // one array, not one call per event
})
Measured on a warm prefix over an 8-slot pool, ~750 B analytics events:
~98k events/sec one at a time, ~365k at 32 per call — 3.6×, most of it
already there at 8. Keep several batches in flight (one call runs on one
slot) and remember that a batch shares one run's limits — see
@iso4/sandbox.
For the deploy path, sandbox.readExports({ code }) loads a module once and
returns its serializable exports (IaC-style declarations); function-valued
exports are absent and reported in skippedExports — never an error:
const { exports, skippedExports } = await sandbox.readExports({ code: workerBundle })
console.log(exports.limits) // { memoryMb: 128 } — declaration exports
console.log(skippedExports) // ['default'] — the handler object, skipped
Packages
Development
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 bump
Current status
sandbox.run({ code, limits })— direct sandboxed execution ✅sandbox.precompile()+prefix.run()— prefix/postfix pattern ✅- CPU and wall-clock limits enforced, async wait excluded from CPU budget ✅
- Host-declared globals bridged into V8 (
fetch,myTool, any name) ✅ - Setup errors on prefix runs (undeclared, frozen or invalid rebinds, a disposed prefix) throw before anything runs ✅
- 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 ✅ - Host → sandbox calls —
prefix.call({ export, args })/run({ code, call })invoke exported handlers (default.fetch, named) with real typed arguments ✅ sandbox.readExports()— deploy-path declaration reader; non-serializable exports skipped and reported viaskippedExports✅- Warm instances — prefix runs reuse resident isolates and skip boot + prefix evaluation; a transparent cache, discarded on any fired limit, abort, or dispose ✅
- Memory-bounded residency —
memoryBudgetMbis an RSS mark for the runtime process: idle instances are evicted byheapUsed × idleTimeand new warmth is refused above it, with no instance-count cap ✅ sandbox.stats()— active runs, queue depth, warm/idle counts, idle heap, budget/RSS, pressure latch, per-prefix counts, answered on a dedicated connection ✅@iso4/fetch— rules-based origin + route allowlist, three-level middleware, DNS pinning, SSRF/redirect protection ✅
How globals work
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.
A global name can be any well-formed string the runtime does not reserve.
One that is not a JavaScript identifier
(my-tool, user.lookup) or is a reserved word (class) is reachable only
as globalThis['my-tool'], not by bare name.
@iso4/sandbox/utils exports the name rules. SANDBOX_GLOBALS is a Set
of every name a fresh sandbox already defines. Check generated names against
it before installing them. RESERVED_WORDS is a Set of names that cannot be
a binding in module source, such as class or eval; iso4 refuses
host-module export names in it, except default.
To check generated names before prepare() instead of getting a failed
setup back, import the predicates from @iso4/sandbox/utils. That entry
never loads the client or the native binary:
import { isValidExportName, isValidGlobalName } from '@iso4/sandbox/utils'
for (const name of Object.keys(tools)) {
if (!isValidExportName(name))
throw new Error(`'${name}' cannot be a host-module export`)
}
if (!isValidGlobalName(globalName))
throw new Error(`'${globalName}' cannot be a host global`)
isValidExportName answers whether a string can be a key of a host-module
object in imports: an identifier per IDENTIFIER_NAME (Unicode included,
so café is fine) that is not in RESERVED_WORDS, or default, which maps
to the module's default export. default cannot be written as
export const default in generated source. isValidGlobalName is the check prepare() and
run() apply to host global names: any well-formed string except the
runtime-owned names (console, Response, NaN, …), a copy of the
runtime's list that a test checks against the runtime. Names starting with
__iso4_ are iso4's internal namespace (the dispatcher, shim handlers): a
host global there may be silently overwritten or dropped, and
isValidGlobalName does not flag it.
Async context (AsyncLocalStorage)
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.
License
MIT © schplitt