tinysandbox
An ultra-minimal, Linux-like sandbox for AI agents — a shell, coreutils, a filesystem, and a secure JavaScript runtime in a single Rust crate, with no containers, no VMs, and no access to the host.
Rust
use Sandbox;
async
TypeScript
import { Sandbox } from '@tinysandbox/tinysandbox'
const sandbox = new Sandbox()
await sandbox.exec('mkdir /workspace')
await sandbox.exec("echo 'hello from the sandbox' > /workspace/greeting.txt")
const result = await sandbox.exec('cat /workspace/greeting.txt | grep -c sandbox')
console.assert(result.stdout === '1\n')
Table of contents
Why
Agents are good at bash and JavaScript because the training data is full of both. But giving an agent a real shell means giving it your filesystem, your network, and your process table — so the usual answer is a container or a microVM, which costs seconds of startup, megabytes of memory per instance, and an orchestration layer you now own.
tinysandbox takes a different trade. It executes a bash-compatible shell and GNU-faithful coreutils natively in your process against a virtual filesystem, and reserves heavyweight isolation (Wasmtime) for the one thing that actually runs untrusted code: agent-authored JavaScript. The result:
- Boot is instant and idle sandboxes cost kilobytes. A
Sandboxis a plain struct around an in-memory filesystem.echo hello > out.txtis microseconds — no VM, no fork/exec, no syscall filter. - The happy path feels exactly like Linux. Supported commands, flags,
and JS APIs match their GNU/bash/Node counterparts precisely (verified
against the real tools in the test suite). You don't have to tell your
agent it's in a special environment — probing with
ls /binorwhich grepbehaves like it would anywhere else. - Everything outside the subset fails loudly. Unsupported flags, shell constructs, and JS APIs produce clear errors, never silently different behavior.
- The host stays unreachable. Files live in a quota-enforced VFS. JS runs in a WebAssembly guest whose module has no filesystem imports at all — there is no code path from a script to your disk or network.
Quickstart
Rust
use Sandbox;
async
TypeScript
import { Sandbox } from '@tinysandbox/tinysandbox'
const sandbox = new Sandbox()
// By default, each exec starts from the builder's cwd/env. The VFS persists.
await sandbox.exec('mkdir -p /workspace/data')
await sandbox.exec("echo 'alpha\nbeta\nalpha' > /workspace/data/words.txt")
// GNU-faithful output shapes, down to wc padding stdin counts to width 7.
const result = await sandbox.exec('sort -u /workspace/data/words.txt | wc -l')
console.assert(result.stdout === ' 2\n')
console.assert(result.exitCode === 0)
// JavaScript with a Node-compatible fs API, sandboxed under Wasmtime.
await sandbox.exec(`echo 'const fs = require("fs"); console.log(fs.readFileSync("/workspace/data/words.txt", "utf8").length)' > /workspace/count.js`)
const counted = await sandbox.exec('js /workspace/count.js')
console.assert(counted.stdout === '17\n')
The host can also work with the filesystem directly — useful for seeding input files or reading results without going through the shell:
Rust
use Sandbox;
use OpenMode;
TypeScript
import { Sandbox } from '@tinysandbox/tinysandbox'
const sandbox = new Sandbox()
await sandbox.fs.mkdir('/workspace')
await sandbox.fs.writeFile('/workspace/report.txt', Buffer.from('direct host access'))
What's inside
Shell
A hand-rolled, heavily tested parser and executor for the bash subset agents actually use. Semantics inside the subset are verified against real bash:
- Pipelines (
cat log | grep err | wc -l), lists (&&,||,;), newline separators, and line continuation after&&/||/| - Redirects:
>,>>,<,2>,2>>,2>&1— with bash-correct left-to-right fd resolution (cmd 2>&1 > fdiffers fromcmd > f 2>&1, as it should) - Quoting: single, double, backslash escapes, correct field splitting of
unquoted
$VARexpansions - Variables:
$VAR,${VAR},$?,VAR=x cmdprefixes, bare assignments,export/unset, and opt-in persistent cwd/env perSandbox - Loud, positioned errors for what's not supported: globs,
$(...), backticks, heredocs,&, subshells, tilde expansion
Builtins
Native Rust implementations running directly against the VFS — no processes spawned. GNU-faithful for supported flags, GNU-shaped error messages and exit codes; golden tests pin the output shapes against the real tools.
files: cat ls cp mv rm mkdir touch stat which pwd cd
text: grep head tail sort uniq wc sed echo
other: true false export unset js
/bin is synthesized from the command registry, so ls /bin and
which cat work and writes to /bin fail with EACCES. One documented
deviation: grep and sed use Rust regex syntax (linear-time matching, so
hostile patterns can't burn CPU) rather than POSIX BRE.
JavaScript runtime
js script.js [args...] and js -e 'code' run agent scripts on
quickjs-ng compiled to WebAssembly
and hosted by Wasmtime. The runtime targets Node fidelity for everything it
implements (the test suite runs the same scripts under real Node and pins
identical output):
| Area | Supported |
|---|---|
fs (sync) |
readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync (incl. withFileTypes), statSync, renameSync, rmSync, unlinkSync, rmdirSync, existsSync, copyFileSync, openSync, readSync, writeSync, ftruncateSync, closeSync |
require |
Relative/absolute CommonJS: ./x, ../x, /x, extension inference (.js, .json), dir/index.js, module cache, Node cycle semantics, module.exports/exports aliasing, require.main, MODULE_NOT_FOUND shapes |
| Globals | console.log/info/warn/error (Node formatting incl. %s %d %j-style substitution), process.argv/env/cwd()/exit(), __filename, __dirname, Buffer (from, alloc, isBuffer, toString('utf8'/'hex'/'base64')) |
| Errors | Node-shaped: .code ('ENOENT'...), libuv-faithful .errno, .syscall, .path, messages like ENOENT: no such file or directory, open '/x' |
| Limits | Per-run memory cap (default 64 MB) with clean OOM errors, CPU deadline via epoch interruption (while(true){} exits 124), catchable RangeError on stack exhaustion |
Not there on purpose: async APIs, event loop, timers, network, and
node_modules resolution — bare require('lodash') tells you plainly that
there is no npm in the sandbox. All file access goes through the same VFS
and quotas as the shell. Known cosmetic deviations (stack-frame naming,
line-1 column offsets) are documented in the js module docs.
Custom commands
Anything registered with the builder is indistinguishable from a builtin: it
shows up in /bin, resolves via which, and composes in pipelines. A
command is just an async function from CommandContext (args, env, cwd,
stdio streams, a VFS handle, limits) to an exit code:
Rust
use ;
use AsyncWriteExt;
async
TypeScript
import { Sandbox } from '@tinysandbox/tinysandbox'
const sandbox = new Sandbox({
commands: {
greet: async ({ args }) => {
const name = args[0] ?? 'world'
return { stdout: Buffer.from(`hello ${name}\n`) }
}
}
})
const result = await sandbox.exec('greet agent | wc -w')
console.assert(result.stdout === ' 2\n')
This is the intended way to expose tools to an agent — file converters, linters, API bridges — while the sandbox contains everything the agent's own code does with the results.
Bring your own VFS
The filesystem is a trait, and the in-memory implementation is just the
default. Back it with SQLite, object storage, or a network service by
implementing tinysandbox::vfs::Vfs — eleven synchronous, FUSE-style methods
(stat, readdir, mkdir, rename, unlink, rmdir, open,
read_at, write_at, truncate, close). Blocking implementations are
fine: the sandbox dispatches VFS calls to worker threads unless your
implementation opts into the in-memory fast path via is_fast().
Attach it in the builder and the whole sandbox — shell, builtins, JS scripts, and direct host access — runs against it:
Rust
use Arc;
use Sandbox;
let sandbox = builder
.vfs
.build;
// Or share one VFS between sandboxes / keep a handle for yourself:
let vfs = new;
let sandbox = builder.vfs_arc.build;
TypeScript
import { Sandbox } from '@tinysandbox/tinysandbox'
const sandbox = new Sandbox({
vfs: MyVfs.connect('s3://agent-42-workspace')
})
The crate ships the same conformance suite that validates InMemoryVfs, so
you can prove your implementation behaves like a POSIX filesystem —
open-mode enforcement, rename-over-existing, unlink-while-open handle
semantics, quota accounting, path containment, and more:
Rust
TypeScript
import { runConformance } from '@tinysandbox/tinysandbox'
await runConformance((quota) => new MyVfs(quota))
The JavaScript conformance runner covers the core VFS contract. Snapshot
conformance is Rust-only for now because VfsSnapshot uses an associated
snapshot type that does not map cleanly onto the callback-object adapter.
See the tinysandbox::vfs rustdoc for the full trait contract (errno
expectations per method, quota semantics, handle identity rules).
Snapshots
InMemoryVfs supports cheap copy-on-write snapshots for rollback and
branching. A snapshot captures path-visible filesystem contents, not open file
handles; restoring one invalidates handles opened before the restore.
Rust
use ;
TypeScript
The Node binding exposes live VFS operations and JS-backed VFS adapters. Snapshot capture/restore/branch is currently Rust-only.
Sandbox::vfs() returns Arc<dyn Vfs>, so snapshot-aware callers should keep
their own concrete handle and pass a clone into the builder:
Rust
use Arc;
use Sandbox;
use ;
async
TypeScript
Snapshot-aware workflows should keep this part in Rust for now. TypeScript
VFS adapters can still validate the non-snapshot contract with
runConformance(vfsFactory).
Limits and observability
Every Sandbox enforces wall-clock timeouts (exit 124, like GNU timeout),
stdout/stderr caps with head+tail truncation, a per-exec command budget,
VFS byte/file quotas (surfacing as ENOSPC), and a wasm memory cap for JS.
All configurable via Limits:
Rust
use Duration;
use ;
TypeScript
import { Sandbox } from '@tinysandbox/tinysandbox'
const sandbox = new Sandbox({
limits: {
wallTimeMs: 5000,
wasmMemoryBytes: 32 * 1024 * 1024
}
})
ExecResult carries per-run metrics (wall time, per-command timings, pipe
byte counts, truncation flags, peak wasm memory), and Sandbox::stats()
reports VFS usage and total commands run.
Security model
- Native code never runs agent input. The shell and builtins only interpret command text against the VFS; the only thing that executes agent-authored code is the wasm guest.
- The wasm guest is capability-free. The vendored QuickJS module
(see assets/PROVENANCE.md for the reproducible build) imports no WASI
filesystem functions — no preopens, no
path_open. Its only window to the world is the audited hostcall ABI, which routes through the same VFS, quotas, and path containment as everything else. - Resources are bounded per execution: memory (ResourceLimiter), CPU (epoch interruption), wall clock, output size, file quotas.
..traversal is contained at the VFS root;/binis read-only.
tinysandbox is one layer, not the whole story: for hostile multi-tenant workloads you should still run your process under OS-level defense in depth (non-root, seccomp/cgroups, or a microVM) appropriate to your threat model.
Comparison with just-bash
just-bash is the closest neighbor: a TypeScript simulated bash with a virtual filesystem, also built for agents. Both give an agent a familiar shell without a container or VM, but the designs differ in ways that matter:
- Random file reads and writes. The tinysandbox VFS is handle-and-offset
based (
open,read_at,write_at), and the JS runtime exposes the matching fd APIs (fs.openSync/readSync/writeSyncwith explicit positions). just-bash's filesystem interface is whole-file: reading one byte means materializing the entire file in memory, and a command's output is fully buffered before it can be written back. In tinysandbox, a VFS backed by object storage or a database can serve TB-scale files while the sandbox only touches the KBs actually read. - Agent code always runs in WebAssembly. In tinysandbox, the only thing that executes agent-authored code is the capability-free QuickJS wasm guest, with hard memory and CPU limits enforced by Wasmtime. just-bash interprets the shell and its commands in the host JavaScript engine and relies on language-level hardening against engine breakouts.
- Host language. tinysandbox is a Rust crate with Node.js bindings; just-bash is TypeScript and runs in Node or the browser. If you want native performance, a typed VFS trait, Rust embedding, or the same sandbox from Node, that's tinysandbox.
Feature flags
| Feature | Default | Effect |
|---|---|---|
js |
on | The js command, Wasmtime, and the embedded QuickJS module (~600 KB). Disable with default-features = false for a shell-and-coreutils-only sandbox with a much smaller dependency tree. |
Examples
Runnable with cargo run --example <name>:
quickstart— sessions, pipelines, redirects, and reading results back from the hostcustom_command— registering a host command and composing it with builtinsjs_scripts— multi-file JS withrequire, thefsAPI, and a look at limits and metrics
Runnable with npm --prefix tinysandbox-node run examples after the package
dependencies are installed:
quickstart.ts— sessions, pipelines, redirects, and host readscustom_command.ts— registering a TypeScript host commandjs_scripts.ts— multi-file sandboxed JS with limits and metricsjs_vfs.ts— TypeScript-backed VFS callbacks plusrunConformance
Roadmap
- Prebuilt Node binary publishing is planned for a follow-up release. Local
development builds use
npm --prefix tinysandbox-node run build.
License
Licensed under either of MIT or Apache-2.0, at your option.