What it does
just-bash is a virtual bash environment written in TypeScript. It parses and executes shell scripts through a proper AST pipeline, runs commands against an in-memory filesystem, and never touches the host OS. Designed for AI agents that need shell access without the security risk of real exec().
Why I starred it
AI agents need to run shell commands. That is a terrifying sentence if you think about it for more than two seconds. The typical approach is sandboxed VMs or containers, but that adds latency and infrastructure. just-bash takes a different route: reimplement enough of bash in TypeScript that agents can cat, grep, sed, jq, and pipe things around in a process that cannot escape to the real filesystem.
The scope is what caught my eye. This is not a toy echo implementation. It handles pipes, redirections, &&/|| chaining, for/while loops, functions, glob patterns, heredocs, and variable expansion including ${VAR:-default}. There are 70+ commands implemented, each with its own module under src/commands/.
How it works
The architecture follows a clean pipeline: Input → Parser → AST → Interpreter → Output.
The parser in src/parser/parser.ts is a recursive descent parser that tokenizes bash scripts and produces a typed AST. The grammar comment at the top is refreshingly honest about what it covers:
script ::= statement*
statement ::= pipeline ((&&|'||') pipeline)* [&]
pipeline ::= [!] command (| command)*
command ::= simple_command | compound_command | function_def
It handles the gnarly parts — heredocs with pendingHeredocs tracking, nested command substitutions, arithmetic expansion — and enforces hard limits (MAX_PARSER_DEPTH, MAX_TOKENS, MAX_PARSE_ITERATIONS) to prevent malicious input from blowing up the parser. That matters when your input comes from an LLM.
The interpreter in src/interpreter/interpreter.ts walks the AST and delegates to specialized modules. The file imports tell the story: control-flow.ts for loops and conditionals, expansion.ts for word expansion, pipeline-execution.ts for pipes, redirections.ts for I/O redirection, arithmetic.ts for $(( )) expressions. Each one is its own module with its own tests. The separation is clean.
Commands are loaded lazily through src/commands/registry.ts. Each command is a LazyCommandDef with a dynamic import() loader, so bundlers only pull in what gets used:
interface LazyCommandDef<T extends string= string> {
name: T;
load: CommandLoader;
}
The filesystem layer is where the security model lives. InMemoryFs in src/fs/in-memory-fs/in-memory-fs.ts stores everything in a Map<string, FsEntry> — files, directories, symlinks, permissions. No node:fs calls. But there are also OverlayFs (copy-on-write over a real directory, writes stay in memory), ReadWriteFs (actual disk access, for when you want that), and MountableFs (combines multiple filesystems at different mount points). The layering is well-designed — you can give an agent read-only access to your project while keeping all writes virtual.
The security story goes deeper than just filesystem isolation. DefenseInDepthBox in src/security/defense-in-depth-box.ts monkey-patches dangerous JavaScript globals during script execution using AsyncLocalStorage for context tracking. This means patches only affect code running inside bash.exec(), not concurrent operations in the same process. It blocks dynamic import() through three layers: ESM loader hooks, Module._resolveFilename blocking, and filesystem restrictions.
Using it
import { Bash } from "just-bash";
const bash = new Bash({
files: { "/data/users.json": '[{"name": "Alice"}, {"name": "Bob"}]' },
});
await bash.exec('cat /data/users.json | jq ".[].name"');
// stdout: "Alice"\n"Bob"\n
Custom commands slot in with defineCommand:
import { Bash, defineCommand } from "just-bash";
const upper = defineCommand("upper", async (args, ctx) => {
return { stdout: ctx.stdin.toUpperCase(), stderr: "", exitCode: 0 };
});
const bash = new Bash({ customCommands: [upper] });
await bash.exec("echo 'test' | upper"); // "TEST\n"
The OverlayFs pattern is particularly useful for AI agents reviewing codebases — mount the project read-only, let the agent write temporary analysis files without risk:
import { OverlayFs } from "just-bash/fs/overlay-fs";
const overlay = new OverlayFs({ root: "/path/to/project" });
const bash = new Bash({ fs: overlay, cwd: overlay.getMountPoint() });
await bash.exec("grep -r TODO src/"); // reads real files
await bash.exec('echo "found 12 TODOs" > report.txt'); // stays in memory
There is also a CLI (just-bash -c 'ls -la') and a Sandbox class that is API-compatible with @vercel/sandbox for local dev/testing before swapping in a real VM.
Rough edges
The exec() isolation model resets shell state (env vars, functions, working directory) between calls but shares the filesystem. That is documented but can still surprise you — a function defined in one exec() call does not exist in the next.
Network access is opt-in and URL-prefixed, which is the right call, but the configuration is verbose. Python and JavaScript support both require WASM runtimes (CPython and QuickJS respectively), adding significant bundle size if you enable them.
The project has solid test coverage — there are dedicated test files for individual commands, agent workflow examples in src/agent-examples/, security fuzzing tests, and comparison tests against real bash. But the docs outside the README are thin. The THREAT_MODEL.md exists, which is more than most projects offer, but the inline code comments could be denser in the interpreter.
At 2.8k stars and active commits through April 2026 (latest: bzip2 compression support), it is clearly maintained and growing.
Bottom line
If you are building AI agents that need shell access — whether for code analysis, data processing, or tool use — just-bash gives you a real bash environment without the blast radius of real bash. The architecture is clean, the security model is layered, and the command coverage is broad enough for most agent workflows.
