Skip to content
Back to skills

Forkable Shell

ASecurity

Gives an agent session a disposable, sandboxed bash shell whose entire filesystem is a single JSON file — so the workspace can be snapshotted, forked, branched and resumed at ~0.24ms per fork instead of seconds per container. Backed by just-bash (in-process, no VM, no container, no host access). Use when you want to run agent-generated shell commands without giving them a real machine, checkpoint an agent workspace, fork one workspace into N parallel branches to try competing approaches, resu...

  • 4 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 28, 2026
ai-agentsrustgoshellbashsqlnodegitbackendsecurity

Works with

  • claude code
  • cli
  • mcp

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 15 files and shows the line behind each finding

Scanned September 28, 2026

npx -y skills add broomva/skills --skill forkable-shell --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Forkable Shell?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Forkable Shell
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/broomva-forkable-shell/badge)](https://www.skillsdirectory.com/skills/broomva-forkable-shell)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: forkable-shell
category: compute
tier: D
description: >
  Gives an agent session a disposable, sandboxed bash shell whose entire filesystem is
  a single JSON file — so the workspace can be snapshotted, forked, branched and resumed
  at ~0.24ms per fork instead of seconds per container. Backed by just-bash (in-process,
  no VM, no container, no host access). Use when you want to run agent-generated shell
  commands without giving them a real machine, checkpoint an agent workspace, fork one
  workspace into N parallel branches to try competing approaches, resume a workspace in a
  later session or process, or hand a Claude Code subprocess a throwaway shell. Triggers on
  "forkable shell", "virtual shell", "sandboxed shell", "disposable shell", "fork the
  workspace", "branch the agent state", "checkpoint this workspace", "snapshot the
  filesystem", "vbash", "just-bash", "sandbox without a container". NOT FOR running real
  binaries (git, node, npm, compilers), long-running daemons, listening sockets, or
  untrusted human adversaries — use a real VM or Vercel Sandbox for those.
---

# forkable-shell

A **world** is one JSON file holding an entire agent workspace: the virtual filesystem
plus shell state. Because a world is a single value, **forking is a file copy**.

Shell continuity is best-effort and self-reporting: state is replayed between calls,
and when a command exits before it can be captured, `exec()` says so rather than
pretending nothing changed.

```text
world.json ──fork──> A.json   (branch A works here)
           └─fork──> B.json   (branch B works here)
           trunk is never opened, so it cannot be mutated
```

## Setup

```bash
cd skills/compute/forkable-shell && npm install     # just-bash + MCP SDK
```

Requires **Node ≥ 20.18.1**. Run it under `node`, not `bun` — see Constraints.

## CLI (`scripts/vbash.mjs`)

```bash
node scripts/vbash.mjs init  <world> [--seed <hostfile>:<guestpath>]...
node scripts/vbash.mjs fork  <src> <dst>
node scripts/vbash.mjs exec  <world> <command...>
node scripts/vbash.mjs cat   <world> <guestpath>
node scripts/vbash.mjs info  <world>
node scripts/vbash.mjs mcp-config <world> [--log <path>]
node scripts/vbash.mjs drive <world> <prompt> [--log <p>] [--model m] [--max-turns n]
```

## Giving a Claude Code session a throwaway shell

`drive` runs one Claude Code turn against the world:

```bash
node scripts/vbash.mjs init /tmp/w.json --seed ./data.csv:/work/data.csv
node scripts/vbash.mjs drive /tmp/w.json "Summarise /work/data.csv into /work/out.json"
node scripts/vbash.mjs cat /tmp/w.json /work/out.json
```

It passes `--allowed-tools mcp__vbash__vbash` and denies `Bash,Read,Write,Edit,Glob,
Grep,WebFetch,WebSearch,Task,NotebookEdit`. To wire it into a session you drive
yourself, use `mcp-config` and pass the file to `claude --mcp-config`.

**What that does and does not establish.** Those flags are permission allow/deny rules.
What is *tested* here is narrower: commands sent through the vbash MCP server cannot
reach the host filesystem. This skill does not prove that a launched Claude process
exposes only vbash — inherited settings, other configured MCP servers, and tools not
named in the deny list are outside what these tests cover. Treat `drive` as a
convenient default, not a proof of confinement.

## The fork workflow

1. `init` a trunk and seed it with inputs.
2. `fork` the trunk once per approach you want to try.
3. `drive` each branch with a different prompt (they are independent files, so run them
   in parallel).
4. Score each branch by reading artifacts out with `cat`.
5. Promote the winner (`cp` it over your working world). The trunk is untouched, so you
   can re-fork and try again.

**Always fork before branching.** Opening a world only reads it, but every `exec` on the
opened world saves back to its file, so a world you run commands in is mutated; `fork`
never opens the source. `tests/unit/world.test.mjs` asserts a branch cannot change its trunk's bytes,
turn count, or file list.

## Constraints worth knowing before you trust it

Measured. The first five have regression tests in this skill; the Bun, turn-budget
and custom-backend notes are findings from the session that produced it, recorded here
because they will bite you, and are **not** covered by these tests:

- **Only `/work` persists.** A snapshot saves the `/work` prefix. A write to `/tmp`
  or `$HOME` succeeds (rc 0) but lives only in the running server, so it is gone
  after a restart and absent from any fork. Keep everything that matters in `/work`.
  (The one item here that IS pinned by a test: `tests/unit/world.test.mjs`.)
- **Shell state does not persist by itself.** just-bash resets env, cwd and functions
  between `exec()` calls; only the filesystem is shared. `scripts/persistent-shell.mjs`
  replays state host-side.
- **A command that exits early loses its state changes.** just-bash implements no
  `trap`, so `exit N` or a `set -e` failure abandons the script before state can be
  captured: the filesystem keeps the command's writes, but env and cwd stay at their
  previous values. This cannot be prevented, so it is *reported* — `exec()` returns
  `stateCaptured: false`, the CLI warns on stderr, and the MCP tool appends a
  `[warning] shell state (cwd, env) was NOT captured` line to its result.
- **Functions cannot be recovered from the guest.** `declare -f` returns a stub with the
  body elided, so register them host-side via `addRc()`.
- **Shell state must be restored paired with its filesystem.** Loading state onto a fresh
  filesystem silently drops the cwd back to the default.
- **Snapshots are prefix-scoped.** An unscoped walk captures ~180 synthetic `/bin`,
  `/usr/bin`, `/dev`, `/proc` entries — the whole virtual distro instead of the workspace.
- **Bun is not supported by default.** just-bash 3.4.2 throws
  `DefenseInDepthBox: critical patches failed: Module._resolveFilename` on the first
  `exec()` under Bun. `defenseInDepth: false` fixes the core shell but not `sqlite3`
  (a separate worker guard). Use Node.
- **Budget turns generously when driving.** A `--max-turns` cap that is too low produces
  an empty result that looks like a capability failure. The default here is 60.
- **A custom filesystem backend boots empty.** just-bash seeds `/bin`, `/tmp`, `/dev`,
  `/proc` only for filesystems exposing synchronous `mkdirSync`/`writeFileSync`.

## Not a security boundary against humans

just-bash runs without VM isolation and is beta. Containment is verified against the
tool (`tests/integration/containment.test.mjs`: 8 host probes plus a positive control
proving the probe can detect a leak), and it holds for agent-generated commands. It is
not a bounty-grade jail. For arbitrary binaries or hostile input, use a real VM.

## Files

| Path | Role |
|---|---|
| `scripts/world.mjs` | the world abstraction: open, save, exec, fork, info |
| `scripts/fs-snapshot.mjs` | filesystem serialize/rehydrate (dirs, symlinks, hardlink groups, modes, mtimes) |
| `scripts/persistent-shell.mjs` | replays env/cwd/functions across `exec()` calls |
| `scripts/vbash-server.mjs` | MCP stdio server exposing the `vbash` tool |
| `scripts/vbash.mjs` | CLI |

## Tests

```bash
npm test          # node --test tests/unit/*.test.mjs tests/integration/*.test.mjs
```

Unit tests cover snapshot fidelity (binary, UTF-8, empty dirs, symlinks, hardlink
groups, file modes and mtimes, odd filenames), shell-state replay, and fork isolation
— including forking onto a destination that is a hardlink to the trunk.

Integration tests are two separate suites: `mcp.test.mjs` drives the MCP server over
real stdio (tool listing, restart persistence, fork divergence, error reporting), and
`containment.test.mjs` asserts host isolation through that same server.

**Symlink modes and mtimes are not restored** — `symlink()` takes neither, and the
snapshot records them for information only. File modes and mtimes are restored.

Files in this skill

  • SKILL.md7.8 KB
  • package-lock.json75.6 KB
  • package.json387 B
  • scripts/fs-snapshot.mjs2.6 KB
  • scripts/persistent-shell.mjs5.2 KB
  • scripts/vbash-server.mjs4.3 KB
  • scripts/vbash.mjs5.2 KB
  • scripts/world.mjs4.8 KB
  • tests/integration/containment.test.mjs4.9 KB
  • tests/integration/mcp.test.mjs8.2 KB
  • tests/mutation-proof.mjs8.4 KB
  • tests/unit/cli.test.mjs2 KB
  • tests/unit/fs-snapshot.test.mjs4.6 KB
  • tests/unit/persistent-shell.test.mjs7.9 KB
  • tests/unit/world.test.mjs9.6 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…