tinysandbox 0.3.1

Ultra-minimal Linux-like sandbox for AI agents: shell, coreutils, VFS, and a Wasmtime-hosted JS runtime in one crate
Documentation

tinysandbox

crates.io npm docs.rs CI license

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 tinysandbox::sandbox::Sandbox;

#[tokio::main]
async fn main() {
    let sandbox = Sandbox::builder().build();

    sandbox.exec("mkdir /workspace").await;
    sandbox
        .exec("echo 'hello from the sandbox' > /workspace/greeting.txt")
        .await;

    let result = sandbox
        .exec("cat /workspace/greeting.txt | grep -c sandbox")
        .await;
    assert_eq!(result.stdout, "1\n");
}

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 Sandbox is a plain struct around an in-memory filesystem. echo hello > out.txt is 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 /bin or which grep behaves 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

cargo add tinysandbox tokio
npm i @tinysandbox/tinysandbox

Rust

use tinysandbox::sandbox::Sandbox;

#[tokio::main]
async fn main() {
    let sandbox = Sandbox::builder().build();

    // By default, each exec starts from the builder's cwd/env. The VFS persists.
    sandbox.exec("mkdir -p /workspace/data").await;
    sandbox
        .exec("echo 'alpha\nbeta\nalpha' > /workspace/data/words.txt")
        .await;

    // GNU-faithful output shapes, down to wc padding stdin counts to width 7.
    let result = sandbox.exec("sort -u /workspace/data/words.txt | wc -l").await;
    assert_eq!(result.stdout, "      2\n");
    assert_eq!(result.exit_code, 0);

    // JavaScript with a Node-compatible fs API, sandboxed under Wasmtime.
    sandbox
        .exec(r#"echo 'const fs = require("fs"); console.log(fs.readFileSync("/workspace/data/words.txt", "utf8").length)' > /workspace/count.js"#)
        .await;
    let result = sandbox.exec("js /workspace/count.js").await;
    assert_eq!(result.stdout, "17\n");
}

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 tinysandbox::sandbox::Sandbox;
use tinysandbox::vfs::OpenMode;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let sandbox = Sandbox::builder().build();
    let vfs = sandbox.vfs();
    let handle = vfs.open("/workspace/report.txt", OpenMode::write_only().create())?;
    vfs.write_at(handle, 0, b"direct host access")?;
    vfs.close(handle)?;
    Ok(())
}

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 > f differs from cmd > f 2>&1, as it should)
  • Quoting: single, double, backslash escapes, correct field splitting of unquoted $VAR expansions
  • Variables: $VAR, ${VAR}, $?, VAR=x cmd prefixes, bare assignments, export / unset, and opt-in persistent cwd/env per Sandbox
  • 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 tinysandbox::sandbox::{CommandContext, CommandResult, Sandbox};
use tokio::io::AsyncWriteExt;

#[tokio::main]
async fn main() {
    let sandbox = Sandbox::builder()
        .command("greet", |mut ctx: CommandContext| async move {
            let name = ctx.args.first().map_or("world", String::as_str);
            let _ = ctx.stdout.write_all(format!("hello {name}\n").as_bytes()).await;
            CommandResult::success()
        })
        .build();

    let result = sandbox.exec("greet agent | wc -w").await; // pipes like any builtin
    assert_eq!(result.stdout, "      2\n");
}

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 std::sync::Arc;
use tinysandbox::sandbox::Sandbox;

let sandbox = Sandbox::builder()
    .vfs(MyVfs::connect("s3://agent-42-workspace")?)
    .build();

// Or share one VFS between sandboxes / keep a handle for yourself:
let vfs = Arc::new(MyVfs::connect("s3://agent-42-workspace")?);
let sandbox = Sandbox::builder().vfs_arc(Arc::clone(&vfs)).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

#[test]
fn my_vfs_conforms() {
    tinysandbox::vfs::conformance::run(|quota| MyVfs::new(quota));
}

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 tinysandbox::vfs::{InMemoryVfs, OpenMode, Vfs, VfsQuota, VfsSnapshot};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let vfs = InMemoryVfs::new(VfsQuota::unlimited());
    let handle = vfs.open("/draft.txt", OpenMode::write_only().create_new())?;
    vfs.write_at(handle, 0, b"before")?;
    vfs.close(handle)?;

    let snapshot = vfs.snapshot()?;
    let branch = vfs.branch(&snapshot)?;

    vfs.unlink("/draft.txt")?;
    assert!(vfs.stat("/draft.txt").is_err());
    assert!(branch.stat("/draft.txt")?.is_file());
    Ok(())
}

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 std::sync::Arc;
use tinysandbox::sandbox::Sandbox;
use tinysandbox::vfs::{InMemoryVfs, VfsQuota, VfsSnapshot};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let vfs = Arc::new(InMemoryVfs::new(VfsQuota::unlimited()));
    let sandbox = Sandbox::builder().vfs_arc(vfs.clone()).build();
    let before_turn = vfs.snapshot()?;
    let result = sandbox.exec("echo draft > /answer.txt").await;
    if result.exit_code != 0 {
        vfs.restore(&before_turn)?;
    }
    Ok(())
}

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 std::time::Duration;
use tinysandbox::sandbox::{Limits, Sandbox};

fn main() {
    let sandbox = Sandbox::builder()
        .limits(Limits {
            wall_time: Duration::from_secs(5),
            wasm_memory_bytes: 32 * 1024 * 1024,
            ..Limits::default()
        })
        .build();
}

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; /bin is 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 / writeSync with 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 host
  • custom_command — registering a host command and composing it with builtins
  • js_scripts — multi-file JS with require, the fs API, 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 reads
  • custom_command.ts — registering a TypeScript host command
  • js_scripts.ts — multi-file sandboxed JS with limits and metrics
  • js_vfs.ts — TypeScript-backed VFS callbacks plus runConformance

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.