A Bash Sandbox
Give a sandboxed bash shell a unfs store as its disk.
just-bash interprets bash against a pluggable filesystem instead of spawning processes, so whatever implements that interface is the sandbox's disk. unfs/just-bash implements it over unfs.
Every echo >, cp -r, mv, pipe, and redirect the shell runs then lands in a content-addressed Merkle DAG: snapshottable, block-sharing, and exportable as a portable pack. You can read the same tree through the node:fs-style API at the same time, with no export or import step in between.
npm install unfs just-bashjust-bash is an optional peer dependency, used for its types and never for a value.
#Running a shell
import { Bash } from "just-bash";
import { initStore, memoryStore } from "unfs";
import { justBashFs } from "unfs/just-bash";
const store = await initStore(memoryStore());
const fs = justBashFs(store, { now: () => Date.now() });
const bash = new Bash({ fs, cwd: "/" });
await bash.exec(`
mkdir -p /project/src
echo 'export const answer = 42;' > /project/src/index.ts
printf 'a\\nb\\nc\\n' > /project/list.txt
`);
const { stdout } = await bash.exec("cat /project/list.txt | tr a-z A-Z | sort -r");That's the whole integration. The mapping is nearly 1:1 because unfs is already node:fs-compatible: the adapter renames mv to rename, flattens Stats into just-bash's FsStat, and normalizes just-bash's "binary" encoding name to latin1.
justBashFs(store, options) takes the same options as createFs: ref, which named ref the shell mutates ("head" by default), and now, a clock. Pass a now if you want ls -l to show real times. Leave it out and the tree stays deterministic, so the same commands produce byte-identical blocks.
#Dropping out of the shell
The adapter exposes .unfs, the async facade over the same store and ref, so the shell is never a wall you have to export across:
await bash.exec("cp -r /project /backup"); // instant: identical subtrees share blocks
await fs.unfs.readFile("/project/list.txt", "utf8"); // "a\nb\nc\n"
await fs.unfs.head(); // the root CID after that copy
await fs.unfs.exportPackBytes("/project"); // a CARv1 archive of exactly those blocksThis is where content addressing pays off for a sandbox:
- Snapshots are free. Capture
await fs.unfs.head()before handing the shell to an agent. The shell can do what it likes and that ref still resolves the old tree, because nothing is overwritten in place. cp -rstores nothing new. A copy of an identical subtree is a second pointer at the same blocks.- A session is a file.
exportPackBytes("/")gives you the whole sandbox as one verifiable CARv1 pack, andimportPackrestores it ref-identically in another process.
#Sharing one filesystem
justBashFs also accepts an existing Unfs instead of a store, for when the shell is one of several views onto the same tree, say an editor and a file tree beside a terminal:
import { createFs } from "unfs";
const unfs = createFs(store, { ref: "session" });
const fs = justBashFs(unfs); // shares the handle; its store and ref win#Two things to know
- Permissions are recorded, not enforced.
chmodstores the mode, and unfs doesn't gate access on it. That's a property of unfs rather than of this adapter. - Glob expansion needs a synchronous store. just-bash requires
getAllPaths()to be synchronous, and unfs answers it through acreateFsSyncfacade over the same store, which needs a synchronous KV driver.memoryStore(),fsStoreSync()andsqliteStore()are the three. Back the store with an async backend, such as an unstorage driver or a remote store, and the method returns[], so*.tsstays literal. If you need globs there, subclassJustBashFsand answer from a snapshot you walked ahead of time.
The interpreter's own sandbox gates are just-bash's business rather than unfs's. The store is the only filesystem and has no reach onto the host, but network access, python, and javascript are options on new Bash({ ... }) worth pinning explicitly.
#Building a variant
To redirect paths, whether that means routing reads to a mount, presenting a subtree, or refusing writes, subclass JustBashFs and override two protected hooks. Every method goes through one of them, so there's nothing else to reimplement:
import { JustBashFs, type JustBashTarget } from "unfs/just-bash";
class MountedFs extends JustBashFs {
constructor(
ref: Unfs,
private readonly pkg: Unfs,
) {
super(ref);
}
// Where a read lands. Return another `Unfs` and a path within it, or
// `{ children }` for a directory you synthesize and no store holds.
protected override async route(path: string): Promise<JustBashTarget> {
if (path === "/node_modules") return { children: ["left-pad"] };
if (path.startsWith("/node_modules/left-pad")) {
return { fs: this.pkg, at: path.slice("/node_modules/left-pad".length) || "/" };
}
return { fs: this.unfs, at: path };
}
// Whether a write is allowed. Throw to refuse; return the path to permit.
protected override async writable(path: string): Promise<string> {
if (path.startsWith("/node_modules")) throw new Error(`${path}: read-only file system`);
return path;
}
}Throw an ENOENT from route to say "this path is mine, and it isn't there". Without it, a path you own but haven't populated falls through to the ref.
Writes are guarded rather than routed: they always land on this.unfs, so override the individual method if a write has to go elsewhere. Two operations opt out by design. cp routes only its destination, since unfs copies within one filesystem, and realpath isn't routed at all, since a resolved path only means something in the filesystem that resolved it.
If you're writing an adapter from scratch rather than subclassing it, the three translation helpers are exported on their own:
| export | what it does |
|---|---|
toEncoding(options) | just-bash's encoding option (or bare name) → a unfs encoding name |
toFsStat(stats) | a unfs Stats → just-bash's flat FsStat |
resolvePosixPath(base, path) | resolvePath: always POSIX, always absolute, whatever the host |
#A runnable example
examples/just-bash/ runs the demo above and then drops into an interactive unfs-backed shell, with tab completion served from the store and :head / :pack meta-commands that show the content-addressed layer under it. examples/ai-agent/ hands the same shell to an LLM and persists the session as a CAR pack.