In-Memory Implementation
The local sandbox runs real commands on real files. That's great until you want to let the agent explore code without trusting it to not break anything.
just-bash is the answer. It's a virtual filesystem with copy-on-write semantics: the agent reads from real disk, but anything it writes lives in memory and disappears when the sandbox stops. Fast, cheap, safe. Ideal for exploration, testing, and any time you'd rather not let an agent loose on your actual files.
Outcome
createJustBashSandbox(dir) returns a Sandbox backed by just-bash. The harness can switch between local and in-memory backends with an environment variable, and the agent runs the same prompts against either.
Fast Track
- Install
just-bashwithbun add just-bash - Implement
createJustBashSandbox(dir)insrc/sandbox-just-bash.ts - Map
readFileandexecto thejust-bashAPI, paying attention to the virtual mount point - Pick the backend at startup based on
process.env.SANDBOX
Hands-on Exercise 4.3
Add the just-bash backend and wire the env-var switch.
Requirements:
createJustBashSandbox(dir: string): Promise<Sandbox>(note thePromise, because creation is async)- Use
JustBashSandbox.create({ overlayRoot: dir })to spin up the virtual FS - Inside
readFileandexec, translate paths through the virtual mount point/home/user/project - In
index.ts, chooselocalorjust-bashbased onprocess.env.SANDBOX
Implementation hints:
JustBashSandbox.createreturns a promise. Your factory has to be async too- The mount point is the trap.
overlayRoot: "/Users/you/project"does not mount at/, it mounts at/home/user/project. Every path inside the sandbox has to be prefixed with that constant runCommandreturns a command handle, not a result. Callwait()for the exit code,output()for the combined stdout/stderr
The just-bash API
A quick tour of the parts you'll wrap:
import { Sandbox as JustBashSandbox } from "just-bash";
const jb = await JustBashSandbox.create({ overlayRoot: "/path/to/project" });
const content = await jb.readFile("/home/user/project/package.json");
const cmd = await jb.runCommand("ls", { cwd: "/home/user/project" });
const finished = await cmd.wait();
console.log(await cmd.output());
console.log(finished.exitCode);When you pass overlayRoot: "/path/to/project", just-bash mounts that directory at /home/user/project inside the virtual filesystem. Not at /. Not at the original path. Every readFile and runCommand call has to use the virtual mount point. This will trip you up. It trips everyone up.
The implementation
import { Sandbox as JustBashSandbox } from "just-bash";
import type { Sandbox } from "./sandbox";
const MOUNT = "/home/user/project";
export async function createJustBashSandbox(dir: string): Promise<Sandbox> {
const jb = await JustBashSandbox.create({ overlayRoot: dir });
return {
type: "just-bash",
workingDirectory: dir,
readFile: async (p) => {
const virtualPath = `${MOUNT}/${p}`;
return jb.readFile(virtualPath);
},
exec: async (command) => {
const cmd = await jb.runCommand(command, { cwd: MOUNT });
const finished = await cmd.wait();
return {
stdout: await cmd.output(),
exitCode: finished.exitCode,
};
},
stop: async () => {},
};
}The MOUNT constant is the only thing the just-bash backend cares about that the local one didn't. Every path in, every path out, gets translated through it.
Wire the env-var switch
import { createLocalSandbox } from "./src/sandbox-local";
import { createJustBashSandbox } from "./src/sandbox-just-bash";
const sandboxType = process.env.SANDBOX || "local";
const sandbox =
sandboxType === "just-bash"
? await createJustBashSandbox(cwd)
: createLocalSandbox(cwd);
console.error(`Sandbox: ${sandbox.type}`);The factory for local is synchronous, the factory for just-bash is async. The conditional handles that for us. Everything downstream (tools, agent, prompt builder) is the same.
Copy-on-write, in one sentence
Reads come from the real disk. Writes go to memory. The real filesystem is never modified. When the sandbox stops, the virtual filesystem is garbage collected. The agent can read your package.json, then create and delete test.txt a hundred times, and your project on disk is untouched.
Try It
Same prompt, two backends:
bun run index.ts . "Read the package.json"SANDBOX=just-bash bun run index.ts . "Read the package.json"You should get the same answer both times, with Sandbox: local and Sandbox: just-bash printed in the respective runs. That's the interface working.
Try a write-shaped task on the in-memory backend:
SANDBOX=just-bash bun run index.ts . "Create a file called scratch.txt with the text 'hello'"The agent writes the file. Now check the real disk: scratch.txt isn't there. The write happened in the overlay, in memory.
npx tsc --noEmitSome tools will work identically across both backends. Some will quietly fail because they assumed something about the host. grep is a common offender, since the shell behavior under just-bash is simulated and not always byte-identical to your system's grep. The portability test is real, not theoretical. Plan to fix one or two tools after this swap.
Commit
git add src/sandbox-just-bash.ts index.ts package.json
git commit -m "feat(sandbox): add just-bash backend with in-memory FS"Done-When
just-bashis installedsrc/sandbox-just-bash.tsexportscreateJustBashSandbox(dir)that returns aPromise<Sandbox>- Paths route through the
MOUNTconstant SANDBOX=just-bash bun run ...runs the agent against the in-memory backend- A write task on
just-bashdoesn't touch the real filesystem npx tsc --noEmitpasses
Pick a prompt that works against the local backend but fails or behaves differently under just-bash. Track down which tool is making the host-specific assumption. Then decide: do you fix the tool, or do you let the interface absorb the difference (for example, by having just-bash provide a shim for that command)? Either is a real design choice. Notice which one keeps the tool simpler.
Solution
import { Sandbox as JustBashSandbox } from "just-bash";
import type { Sandbox } from "./sandbox";
const MOUNT = "/home/user/project";
export async function createJustBashSandbox(dir: string): Promise<Sandbox> {
const jb = await JustBashSandbox.create({ overlayRoot: dir });
return {
type: "just-bash",
workingDirectory: dir,
readFile: async (p) => {
const virtualPath = `${MOUNT}/${p}`;
return jb.readFile(virtualPath);
},
exec: async (command) => {
const cmd = await jb.runCommand(command, { cwd: MOUNT });
const finished = await cmd.wait();
return {
stdout: await cmd.output(),
exitCode: finished.exitCode,
};
},
stop: async () => {},
};
}const sandboxType = process.env.SANDBOX || "local";
const sandbox =
sandboxType === "just-bash"
? await createJustBashSandbox(cwd)
: createLocalSandbox(cwd);Was this helpful?