just-bash
A virtual bash environment with an in-memory filesystem, written in TypeScript and designed for AI agents.
Broad support for standard unix commands and bash syntax with optional curl, Python, JS/TS, and sqlite support.
Note: This is beta software. Use at your own risk and please provide feedback. See security model.
Quick Start
npm install just-bash
import { Bash } from "just-bash";
const bash = new Bash();
await bash.exec('echo "Hello" > greeting.txt');
const result = await bash.exec("cat greeting.txt");
console.log(result.stdout);
console.log(result.exitCode);
Each exec() call gets its own isolated shell state — environment variables, functions, and working directory reset between calls. The filesystem is shared across calls, so files written in one exec() are visible in the next.
Custom Commands
Extend just-bash with your own TypeScript commands using defineCommand:
import { Bash, decodeBytesToUtf8, defineCommand } from "just-bash";
const hello = defineCommand("hello", async (args, ctx) => {
const name = args[0] || "world";
return { stdout: `Hello, ${name}!\n`, stderr: "", exitCode: 0 };
});
const upper = defineCommand("upper", async (args, ctx) => {
return {
stdout: decodeBytesToUtf8(ctx.stdin).toUpperCase(),
stderr: "",
exitCode: 0,
};
});
const bash = new Bash({ customCommands: [hello, upper] });
await bash.exec("hello Alice");
await bash.exec("echo 'test' | upper");
Custom command callbacks receive a ResolvedCommandContext with fs, cwd,
env, stdin, resolved limits, and exec (for subcommands), and work with
pipes, redirections, and all shell features. The legacy CommandContext remains
available for standalone context inputs; use createCommandContext({ fs }) when
calling a command directly with a fully resolved context.
Host-provided commands preserve the legacy trusted default whether supplied to
the Bash constructor, declared through defineCommand, loaded lazily, or
added later with bash.registerCommand(). Set trusted: false (or use
defineCommand(name, execute, { trusted: false })) to select the restricted
extension boundary. Trusted commands run in the embedding process and should
never execute guest-provided JavaScript.
Every invocation is bound by maxExecutionTimeMs. On cancellation, just-bash
revokes the command context immediately; maxExtensionCleanupTimeMs only
bounds how long it waits for the now-authority-free command promise to settle.
A late continuation cannot use ctx.fs, ctx.env, ctx.exec, or other context
capabilities. Cleanup work that must run at scope closure can be registered with
ctx.executionScope.registerCleanup(). A cleanup failure is returned as a
generic exit-126 shell result rather than rejecting Bash.exec() or exposing
host error details. JavaScript cannot forcibly stop arbitrary host code, so
extensions requiring a hard guarantee against external side effects must run
in a terminable worker or process. Tests that invoke command objects directly
can use createCommandContext({ fs }) to get a fully resolved context without
duplicating internal defaults.
Supported Commands
File Operations
cat, cp, file, ln, ls, mkdir, mv, readlink, rm, rmdir, split, stat, touch, tree
Text Processing
awk, base64, column, comm, cut, diff, expand, fold, grep (+ egrep, fgrep), head, join, md5sum, nl, od, paste, printf, rev, rg, sed, sha1sum, sha256sum, sort, strings, tac, tail, tr, unexpand, uniq, wc, xargs
Data Processing
jq (JSON), sqlite3 (SQLite), xan (CSV), yq (YAML/XML/TOML/CSV)
Optional Runtimes
js-exec (JavaScript/TypeScript via QuickJS; requires javascript: true), python3/python (Python via CPython; requires python: true)
Compression & Archives
gzip (+ gunzip, zcat), tar
Navigation & Environment
basename, cd, dirname, du, echo, env, export, find, hostname, printenv, pwd, tee
Shell Utilities
alias, bash, chmod, clear, date, expr, false, help, history, seq, sh, sleep, time, timeout, true, unalias, which, whoami
Network
curl, html-to-markdown (require network configuration)
All commands support --help for usage information.
Shell Features
- Pipes:
cmd1 | cmd2
- Redirections:
>, >>, 2>, 2>&1, <
- Command chaining:
&&, ||, ;
- Variables:
$VAR, ${VAR}, ${VAR:-default}
- Positional parameters:
$1, $2, $@, $#
- Glob patterns:
*, ?, [...]
- If statements:
if COND; then CMD; elif COND; then CMD; else CMD; fi
- Functions:
function name { ... } or name() { ... }
- Local variables:
local VAR=value
- Loops:
for, while, until
- Symbolic links:
ln -s target link
- Hard links:
ln target link
Configuration
const env = new Bash({
files: { "/data/file.txt": "content" },
env: { MY_VAR: "value" },
cwd: "/app",
executionLimits: { maxCallDepth: 50 },
python: true,
javascript: true,
});
await env.exec("echo $TEMP", { env: { TEMP: "value" }, cwd: "/tmp" });
await env.exec("cat", { stdin: "hello from stdin\n" });
await env.exec("env", { replaceEnv: true, env: { ONLY: "this" } });
await env.exec("grep", { args: ["-r", "TODO", "src/"] });
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
await env.exec("while true; do sleep 1; done", { signal: controller.signal });
await env.exec("cat <<EOF\n indented\nEOF", { rawScript: true });
Timezone
date defaults to UTC (%Z=UTC, %z=+0000) regardless of the host clock, so the sandbox does not leak the host timezone. To opt into a specific zone, pass TZ as an initial env var:
const bash = new Bash({ env: { TZ: "America/New_York" } });
await bash.exec("date");
-u always forces UTC; an unset or invalid $TZ falls back to UTC. Setting TZ exposes that timezone to scripts running in the sandbox, so only pass a value you are comfortable revealing — forwarding the host's real $TZ (e.g. process.env.TZ) reintroduces the disclosure that the UTC default exists to prevent.
exec() options:
env | Record<string, string> | Environment variables for this execution only |
cwd | string | Working directory for this execution only |
stdin | string | Standard input passed to the script |
args | string[] | Additional argv passed directly to the first command (bypasses shell parsing; does not change $1, $2, ...) |
replaceEnv | boolean | Start with empty env instead of merging (default: false) |
signal | AbortSignal | Cooperative cancellation; stops at next statement boundary |
rawScript | boolean | Skip leading-whitespace normalization (default: false) |
Filesystem Options
Four filesystem implementations:
InMemoryFs (default) - Pure in-memory filesystem, no disk access:
import { Bash } from "just-bash";
const env = new Bash({
files: {
"/data/config.json": '{"key": "value"}',
"/data/large.csv": () => "col1,col2\na,b\n",
"/data/remote.txt": async () => (await fetch("https://example.com")).text(),
},
});
OverlayFs - Copy-on-write over a real directory. Reads come from disk, writes stay in memory:
import { Bash } from "just-bash";
import { OverlayFs } from "just-bash/fs/overlay-fs";
const overlay = new OverlayFs({
root: "/path/to/project",
maxMemoryBytes: 256 * 1024 * 1024,
});
const env = new Bash({ fs: overlay, cwd: overlay.getMountPoint() });
await env.exec("cat package.json");
await env.exec('echo "modified" > package.json');
maxMemoryBytes defaults to 1 GiB and covers aggregate files retained in the
copy-on-write layer, including append chunks. Set it to the deployment's memory
budget when an OverlayFs is reused across executions.
ReadWriteFs - Direct read-write access to a real directory. Use this if you want the agent to be able to write to your disk:
import { Bash } from "just-bash";
import { ReadWriteFs } from "just-bash/fs/read-write-fs";
const rwfs = new ReadWriteFs({ root: "/path/to/sandbox" });
const env = new Bash({ fs: rwfs });
await env.exec('echo "hello" > file.txt');
Keep ReadWriteFs pointed at a workspace directory, not at the installed just-bash package or any other trusted runtime code. Guest-writable roots should stay separate from trusted code.
ReadWriteFs uses normal in-place filesystem operations for private regular
files. For multiply-linked regular files, it isolates append and metadata
changes by copying the file and replacing only the sandbox directory entry, so
a host-created hard link cannot carry those changes beyond the configured root.
Implicit copies are limited by maxCopyOnWriteSize (100 MB by default; set it
to 0 to disable the limit). Overwrite does not need to copy existing content.
Explicit cp copies can be limited with the opt-in maxCopySize option
(unlimited by default). The portable copy path may materialize sparse-file
holes, so embeddings that require a disk-allocation bound should configure
maxCopySize.
Shared-inode isolation has a few deliberate limitations:
- Append,
chmod, and utimes on a multiply-linked regular file require read
access to the file and write access to its parent directory. They fail with
EFBIG when the file exceeds maxCopyOnWriteSize.
- Copies use
O_NOATIME when the Node.js runtime exposes it and retry with
normal read semantics if the kernel returns EPERM. Runtimes and platforms
without O_NOATIME may update access-time metadata visible through another
hard link.
- Do not mutate a
ReadWriteFs root concurrently through direct host filesystem
APIs. Node.js does not expose the descriptor-relative operations needed to
make pathname validation atomic against an external actor. A concurrent host
append to a multiply-linked file may be lost when the isolated entry is
replaced.
- Mutations in overlapping
ReadWriteFs roots are serialized within the
process. Unrelated roots proceed independently. The queue is not cancellable
or bounded, so a large mutation can delay later operations in overlapping
roots even if the requesting script is subsequently aborted.
- Content writes and appends to FIFOs, sockets, devices, and other special files
are rejected. This avoids indefinitely occupying an overlapping-root mutation
slot on a blocking special-file open. Metadata operations remain supported
for single-link special files; multiply-linked special files are rejected
because they cannot be isolated without changing their file type.
- Private-file and single-link special-file metadata operations use pathname
APIs to preserve normal host permission semantics. They are not atomic
against a trusted host actor concurrently replacing that pathname with a
symlink. With
allowSymlinks: false, symlinks present during normal path
validation are still rejected.
- Copying a symlink preserves whether its guest target is absolute or relative.
Only symlinks whose resolved targets remain inside the root are copied.
- Regular-file copies replace the destination entry to prevent writes through
hard links. Existing destinations must still be writable, and their parent
directory must be writable so the isolated entry can be committed. Thus a
writable destination in a non-writable directory cannot be copied over.
Copying over a FIFO, socket, device, or other special entry is rejected.
MountableFs - Mount multiple filesystems at different paths. Combines read-only and read-write filesystems into a unified namespace:
import { Bash, MountableFs, InMemoryFs } from "just-bash";
import { OverlayFs } from "just-bash/fs/overlay-fs";
import { ReadWriteFs } from "just-bash/fs/read-write-fs";
const fs = new MountableFs({ base: new InMemoryFs() });
fs.mount("/mnt/knowledge", new OverlayFs({ root: "/path/to/knowledge", readOnly: true }));
fs.mount("/home/agent", new ReadWriteFs({ root: "/path/to/workspace" }));
const bash = new Bash({ fs, cwd: "/home/agent" });
await bash.exec("ls /mnt/knowledge");
await bash.exec("cp /mnt/knowledge/doc.txt ./");
await bash.exec('echo "notes" > notes.txt');
You can also configure mounts in the constructor:
import { MountableFs, InMemoryFs } from "just-bash";
import { OverlayFs } from "just-bash/fs/overlay-fs";
import { ReadWriteFs } from "just-bash/fs/read-write-fs";
const fs = new MountableFs({
base: new InMemoryFs(),
mounts: [
{ mountPoint: "/data", filesystem: new OverlayFs({ root: "/shared/data" }) },
{ mountPoint: "/workspace", filesystem: new ReadWriteFs({ root: "/tmp/work" }) },
],
});
Optional Capabilities
Network Access
Network access is disabled by default. Enable it with the network option:
const env = new Bash({
network: {
allowedUrlPrefixes: [
"https://api.github.com/repos/myorg/",
"https://api.example.com",
],
},
});
const env = new Bash({
network: {
allowedUrlPrefixes: ["https://api.example.com"],
allowedMethods: ["GET", "HEAD", "POST"],
},
});
const env = new Bash({
network: {
allowedUrlPrefixes: [
"https://public-api.com",
{
url: "https://ai-gateway.vercel.sh",
transform: [{ headers: { Authorization: "Bearer secret" } }],
},
],
},
});
const env = new Bash({
network: { dangerouslyAllowFullInternetAccess: true },
});
Note: The curl command only exists when network is configured. Without network configuration, curl returns "command not found".
Allow-List Security
The allow-list enforces:
- Origin matching: URLs must match the exact origin (scheme + host + port)
- Path prefix: Only paths starting with the specified prefix are allowed
- HTTP method restrictions: Only GET and HEAD by default (configure
allowedMethods for more)
- Redirect protection: Redirects to non-allowed URLs are blocked
- Header transforms: Firewall headers are injected at the fetch boundary and override any user-supplied headers with the same name, preventing credential substitution from inside the sandbox. Headers are re-evaluated on each redirect so credentials are never leaked to non-transform hosts
Using curl
curl -s https://api.example.com/data | grep pattern
curl -s https://example.com | html-to-markdown
curl -X POST -H "Content-Type: application/json" \
-d '{"key":"value"}' https://api.example.com/endpoint
JavaScript Support
JavaScript and TypeScript execution via QuickJS is opt-in due to additional security surface. Enable with javascript: true:
const env = new Bash({
javascript: true,
});
await env.exec('js-exec -c "console.log(1 + 2)"');
await env.exec('js-exec script.js');
await env.exec('js-exec -m -c "import fs from \'fs\'; console.log(fs.readFileSync(\'/data/file.txt\', \'utf8\'))"');
Bootstrap Code
Run setup code before every js-exec invocation with the bootstrap option:
const env = new Bash({
javascript: {
bootstrap: `
globalThis.API_BASE = "https://api.example.com";
globalThis.formatDate = (d) => new Date(d).toISOString();
`,
},
});
await env.exec('js-exec -c "console.log(API_BASE)"');
Node.js Compatibility
js-exec supports require() and import with these Node.js modules:
- fs:
readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync, rmSync, fs.promises.*
- path:
join, resolve, dirname, basename, extname, relative, normalize
- child_process:
execSync, spawnSync
- process:
argv, cwd(), exit(), env, platform, version
- Other modules:
os, url, assert, util, events, buffer, stream, string_decoder, querystring
- Globals:
console, fetch, Buffer, URL, URLSearchParams
fs.readFileSync() returns a Buffer by default (matching Node.js). Pass an encoding like 'utf8' to get a string.
Note: The js-exec command only exists when javascript is configured. It is not available in browser environments. Execution runs in a QuickJS WASM sandbox with a 64 MB memory limit and configurable timeout (30 seconds in the default normal profile and 10 seconds in the opt-in hardened profile). Enabling network access does not extend the configured deadline.
Tool Invocation Hook
js-exec scripts can call host-defined tools through a global tools proxy
when javascript.invokeTool is provided:
const bash = new Bash({
javascript: {
invokeTool: async (path, argsJson) => {
const args = argsJson ? JSON.parse(argsJson) : undefined;
if (path === "math.add") {
return JSON.stringify({ sum: args.a + args.b });
}
throw new Error(`Unknown tool: ${path}`);
},
},
});
await bash.exec(`js-exec -c 'console.log((await tools.math.add({a:3,b:4})).sum)'`);
The hook is generic — wire any tool framework through it (raw maps, MCP,
Anthropic tool-use, etc.). For full GraphQL / OpenAPI / MCP discovery via
@executor-js/sdk, plus auto-generated bash namespace commands, use the
companion package
@just-bash/executor.
Python Support
Python (CPython compiled to WASM) is opt-in due to additional security surface. Enable with python: true:
const env = new Bash({
python: true,
});
await env.exec('python3 -c "print(1 + 2)"');
await env.exec('python3 script.py');
Note: The python3 and python commands only exist when python: true is configured. Python is not available in browser environments.
SQLite Support
sqlite3 uses sql.js (SQLite compiled to WASM), sandboxed from the real filesystem:
const env = new Bash();
await env.exec('sqlite3 :memory: "SELECT 1 + 1"');
await env.exec('sqlite3 data.db "SELECT * FROM users"');
Note: SQLite is not available in browser environments. Queries run in a worker thread with a configurable timeout (30 seconds in the default normal profile and 5 seconds in the opt-in hardened profile) to prevent runaway queries from blocking execution.
AST Transform Plugins
Parse bash scripts into an AST, transform them, and serialize back to bash. Good for instrumenting scripts (e.g., capturing per-command stdout/stderr) or extracting metadata before execution.
import { Bash, BashTransformPipeline, TeePlugin, CommandCollectorPlugin } from "just-bash";
const pipeline = new BashTransformPipeline()
.use(new TeePlugin({ outputDir: "/tmp/logs" }))
.use(new CommandCollectorPlugin());
const result = pipeline.transform("echo hello | grep hello");
result.script;
result.metadata.commands;
const bash = new Bash();
bash.registerTransformPlugin(new CommandCollectorPlugin());
const execResult = await bash.exec("echo hello | grep hello");
execResult.metadata?.commands;
See src/transform/README.md for the full API, built-in plugins, and how to write custom plugins.
Integrations
AI SDK Tool
bash-tool wraps just-bash as an AI SDK tool:
npm install bash-tool
import { createBashTool } from "bash-tool";
import { generateText } from "ai";
const bashTool = createBashTool({
files: { "/data/users.json": '[{"name": "Alice"}, {"name": "Bob"}]' },
});
const result = await generateText({
model: "anthropic/claude-sonnet-4",
tools: { bash: bashTool },
prompt: "Count the users in /data/users.json",
});
See bash-tool for more.
Vercel Sandbox Compatible API
Sandbox is a drop-in replacement for @vercel/sandbox — same API, but runs entirely in-process with the virtual filesystem. Start with just-bash for development and testing, swap in a real sandbox when you need a full VM.
import { Sandbox } from "just-bash";
const sandbox = await Sandbox.create({ cwd: "/app" });
await sandbox.writeFiles({
"/app/script.sh": 'echo "Hello World"',
"/app/data.json": '{"key": "value"}',
});
const cmd = await sandbox.runCommand("bash /app/script.sh");
const output = await cmd.stdout();
const exitCode = (await cmd.wait()).exitCode;
const content = await sandbox.readFile("/app/data.json");
await sandbox.mkDir("/app/logs", { recursive: true });
await sandbox.stop();
CLI
CLI Binary
Install globally (npm install -g just-bash) for a sandboxed CLI:
just-bash -c 'ls -la && cat package.json | head -5'
just-bash -c 'grep -r "TODO" src/' --root /path/to/project
echo 'find . -name "*.ts" | wc -l' | just-bash
just-bash ./scripts/deploy.sh
just-bash -c 'echo hello' --json
The CLI uses OverlayFS — reads come from the real filesystem, but all writes stay in memory and are discarded after execution.
Important: The project root is mounted at /home/user/project. Use this path (or relative paths from the default cwd) to access your files inside the sandbox.
Options:
-c <script> - Execute script from argument
--root <path> - Root directory (default: current directory)
--cwd <path> - Working directory in sandbox
-e, --errexit - Exit on first error
--json - Output as JSON
Interactive Shell
pnpm shell
The interactive shell has full internet access by default. Disable with --no-network:
pnpm shell --no-network
Execution Protection
Bash protects against infinite loops and deep recursion with configurable limits:
const env = new Bash({
executionLimitProfile: "hardened",
executionLimits: {
maxCallDepth: 100,
maxCommandCount: 20000,
maxSourceBytes: 8 * 1024 * 1024,
maxFileSystemBytes: 256 * 1024 * 1024,
maxOutputSize: 32 * 1024 * 1024,
maxArchiveBytes: 256 * 1024 * 1024,
maxDatabaseBytes: 128 * 1024 * 1024,
maxExecutionTimeMs: 30_000,
maxExtensionCleanupTimeMs: 25,
},
});
All resources remain bounded by default in both profiles. Explicit values
override the selected profile; non-negative safe integers and the legacy
Infinity spelling are accepted. Infinite deadlines omit the corresponding
platform timer rather than overflowing it. Invalid values are rejected when
Bash is constructed. Error messages identify the resource that was hit.
Security Model
The Node.js package requires Node >=20.18.1.
- The shell only has access to the provided filesystem.
- All execution happens without VM isolation. This does introduce additional risk. The code base was designed to be robust against prototype-pollution attacks and other break outs to the host JS engine and filesystem.
- There is no network access by default. When enabled, requests are checked against URL prefix allow-lists and HTTP-method allow-lists.
- Python and JavaScript execution are off by default as they represent additional security surface.
- Execution is protected against infinite loops and deep recursion with configurable limits.
- Host-realm defense-in-depth uses the strongest scoped controls available on
each supported Node runtime. Where
node:module.registerHooks() is present,
builtin ESM imports can also be denied only for the untrusted async context;
older runtimes retain best-effort scoped protection without failing existing
applications. It never installs a process-global deny-all loader. Query the
resolved capabilities with DefenseInDepthBox.getInstance().getStatus().
Audit mode reports level: "none" because it records violations without
enforcing them.
- Scoped defense uses reversible proxies for
Reflect, JSON, and Math and
restores their host descriptors on deactivation. This is reported as
intrinsicProtection: "scoped-best-effort": same-realm JavaScript that
cached an intrinsic or a mutation function before activation cannot be fully
revoked (including the direct delete operator). The separately named
processLifetimeIntrinsicHardening: true option permanently freezes those
objects and locks selected well-known Symbol descriptors; use it only in a
disposable or process-lifetime realm. Use an isolated worker/process when
complete protection and reversible host state are both required.
- Node worker
resourceLimits do not reliably cap the WebAssembly linear memory used by CPython or sql.js. Queue, deadline, file, database, bridge, and payload limits reduce exposure, but strong memory containment for these opt-in runtimes requires process/container isolation or a WASM build with a lower hard maximum.
- Use Vercel Sandbox if you need a full VM with arbitrary binary execution.
Browser Support
The core shell (parsing, execution, filesystem, and all built-in commands) works in browser environments. The following features require Node.js and are unavailable in browsers: python3/python, sqlite3, js-exec, and OverlayFs/ReadWriteFs (which access the real filesystem).
Default Layout
When created without options, Bash provides a Unix-like directory structure:
/home/user - Default working directory (and $HOME)
/bin - Contains stubs for all built-in commands
/usr/bin - Additional binary directory
/tmp - Temporary files directory
Commands can be invoked by path (e.g., /bin/ls) or by name.
AI Agent Instructions
For AI agents, bash-tool provides additional guidance in its AGENTS.md:
cat node_modules/bash-tool/dist/AGENTS.md
License
Apache-2.0