isopod

Getting started

This is the full setup walk-through for isopod: prerequisites, building, guest images, host networking, first runs, the warm pool, the optional jail, and registering the MCP server for Claude Code. The README quick start is the condensed version of this document.

Everything below is written for a normal, unprivileged user account. Exactly one step (isopod setup) needs root, once.

The whole path, and the two places it branches — a package install skips the Firecracker build (§2a vs §2b), and a --no-network-only setup skips the root step (§4):

flowchart TB
    K["Linux x86_64 with /dev/kvm, your user in the kvm group"] --> HOW{"install from"}
    HOW -->|"a release .deb, .rpm or tarball"| PKG["isopod · isopod-mcp · isopod-jail installed<br/>Firecracker and the guest agent come prebuilt"]
    HOW -->|"a source checkout"| SRC["cargo build --release"]
    SRC --> BFC["isopod dev build-fc<br/>compiles the vendored Firecracker"]
    PKG --> FK
    BFC --> FK["isopod image fetch-kernel<br/>pinned guest kernel, digest-verified"]
    FK --> BA["isopod image build-all<br/>guest rootfs and squashfs bases"]
    BA --> NEED{"does your workload need a network?"}
    NEED -->|"yes"| SETUP["sudo isopod setup<br/>the one step that needs root"]
    NEED -->|"no, --no-network only"| RUN
    SETUP --> RUN["isopod run ..."]

1. Prerequisites

Hardware / kernel:

bash ls -l /dev/kvm # should exist sudo usermod -aG kvm "$USER" # then log out and back in

Toolchain and host packages:

bash sudo apt install nftables iproute2 e2fsprogs squashfs-tools build-essential

(nftables/iproute2 for networking, mkfs.ext4/resize2fs for stage images, mksquashfs for base images, a C toolchain to build Firecracker.)

Footprint: expect a few GiB of disk for the build tree (target/, vendored Firecracker) and low single-digit GiB under ~/.isopod for the Firecracker binary, kernel, and images (base-alpine is ~150 MiB; committed stages and warm-pool snapshots add what you put in them). Each running VM takes 512 MiB of RAM by default (--mem-mib to change).

2. Install a package — or build the workspace

2a. From a release package (recommended)

Every release on the releases page ships a .deb, an .rpm, and a plain tarball for x86_64 Linux, with checksums in SHA256SUMS:

sudo apt install ./isopod_*_amd64.deb     # Debian/Ubuntu
sudo dnf install ./isopod-*.x86_64.rpm    # Fedora/RHEL-family

The package installs isopod, isopod-mcp, and isopod-jail into /usr/bin, plus prebuilt Firecracker and guest-agent binaries under /usr/lib/isopod/ — so you skip the Rust toolchain, the submodule, and the dev build-fc step entirely. Continue at §3 (you still build guest images and run setup; image fetch-kernel + image build-all + sudo isopod setup is your whole remaining path). A source build in your home directory, if you later make one, takes precedence over the packaged Firecracker.

The tarball is the same content in a directory (isopod-<ver>-x86_64-linux/ with the three binaries at top level and lib/ holding Firecracker + the guest agent) for hosts without a package manager; put the binaries on your PATH and set ISOPOD_FC_BIN and ISOPOD_GUEST_AGENT_BIN to the two lib/ files.

2b. From source

git clone https://github.com/me1iissa/isopod.git
cd isopod
git submodule update --init --recursive   # vendored Firecracker v1.16.1 source
cargo build --release

This produces three binaries under target/release/:

Binary Role
isopod The CLI.
isopod-mcp The MCP server Claude Code spawns over stdio.
isopod-jail The rootless jail helper (only used when the jail is enabled).

Optionally put the CLI on your PATH — a user-writable location is enough, no root needed:

install -m755 target/release/isopod ~/.local/bin/isopod

The rest of this doc writes isopod for brevity; substitute ./target/release/isopod if you skipped the install.

sudo note: sudo isopod setup will usually fail with "command not found" because sudo's secure_path does not include user directories. Use an explicit path: sudo ~/.local/bin/isopod setup or sudo ./target/release/isopod setup.

3. Build Firecracker and the guest images

All of this is unprivileged and idempotent. Artifacts land under ~/.isopod/, and each command prints a JSON object on success telling you what it produced.

Package installs skip dev build-fc — the packaged Firecracker at /usr/lib/isopod/firecracker is found automatically. Start at fetch-kernel.

# (Source builds only.) Compile the vendored Firecracker v1.16.1 and install
# it to ~/.isopod/bin. This is the slowest step (a full Rust release build —
# typically a few minutes). Verify: ~/.isopod/bin/firecracker --version
isopod dev build-fc

# Download the pinned guest kernel — a few tens of MiB from Firecracker's
# public CI artifact store, verified against a vendored sha256 digest.
isopod image fetch-kernel

# Build every guest rootfs image (dev images + the squashfs bases).
# Needs network: base-alpine fetches its packages from the Alpine CDN.
isopod image build-all

# Inspect what you have; images are stamped with the RPC protocol version.
isopod image ls

The guest kernel is pinned by exact artifact and digest — fetch-kernel refuses anything that does not match. (--allow-unpinned exists solely for maintainers discovering the digest of a new kernel before pinning it.)

Everything built? isopod dev boot boots a throwaway VM with no networking and reports its boot latency — a good end-to-end smoke test before touching the root step below.

If you later see a protocol mismatch error (host and guest images disagreeing on the RPC version after an update), rebuild all images together: isopod image build-all.

4. Host networking (the one root step)

sudo isopod setup

This provisions, once:

Which pool a run lands in, and what each pool can actually reach:

flowchart TB
    R1["a run with no allowlist"] --> PUB["public slot<br/>isopod-tap0 .. isopod-tap7"]
    R2["a run with --allow-host, --allow-cidr or --deny-egress"] --> FIL["filtered slot<br/>isopod-tap8 .. isopod-tap11"]
    PUB -->|"NAT off your default-route interface"| WAN["the public internet"]
    PUB -.->|"dropped by the ruleset"| PRIV["your LAN · other RFC1918 · CGNAT · link-local metadata"]
    FIL -->|"only 1080 SOCKS5, 3128 HTTP and 5353 DNS<br/>on this slot's own gateway"| BR["host-side egress broker"]
    FIL -.->|"all other forwarding dropped"| NOTHING["nothing at all"]
    BR -->|"allowlisted destinations only"| WAN

Each slot is its own /30. For slot i the host holds 10.107.<i>.1 on isopod-tap<i> and the guest gets 10.107.<i>.2 — the address a run reports back as guest_ip. Every tap is pinned to its own source address, so one slot cannot spoof another.

Three things worth knowing:

Verify it worked: ip link | grep isopod-tap should list the taps (isopod-tap0isopod-tap11). If setup itself fails, the usual causes are nft missing (install nftables) or no default route to NAT off (pass --iface <your-egress-interface> explicitly).

Upgrading from 0.8.x? An existing slots.json keeps working untouched and all your slots stay public. The first run that asks for an allowlist fails before boot and prints the exact command to re-provision — nothing changes underneath you until you run it.

sudo isopod setup --remove tears everything back down (taps, nftables table, sysctl file).

If you only ever run with --no-network, you can skip this section: exec works over vsock regardless of networking.

5. First runs

# Ephemeral: boot ~0.4 s, exec, destroy. Output is one JSON object on stdout.
isopod run --stage base --base base-alpine -- python3 -c 'print(6*7)'

# Untrusted code: no NIC at all.
isopod run --no-network --stage base --base base-alpine -- python3 suspicious.py

# Untrusted code that still needs one dependency source: default-deny egress.
isopod run --allow-host pypi.org --allow-host '*.pythonhosted.org' \
  --stage base --base base-alpine -- pip install requests

# Pull just the fields you care about.
isopod run --stage base --base base-alpine -- uname -a | jq '{exit_code, stdout}'

A successful run prints one JSON object (host paths yours, of course):

{"ok":true,"vm_id":"dev-3d39a8fd","name":"fallen-thunderlord",
 "exit_code":0,"signal":null,"timed_out":false,
 "stdout":"42\n","stderr":"","stdout_truncated":false,"stderr_truncated":false,
 "stdout_bytes":3,"stderr_bytes":0,"exec_ms":139,"total_ms":483,
 "path":"warm","resume_ms":160,"snapshot_built":false,
 "vcpus":1,"mem_mib":512,"rootfs_flavor":"base-alpine",
 "fc_binary":{"path":"/home/you/.isopod/bin/firecracker","provenance":"vendored-build"},
 "serial_log_path":"/home/you/.isopod/vms/dev-3d39a8fd/console.log",
 "stdout_log_path":"/home/you/.isopod/vms/dev-3d39a8fd/exec-stdout.log",
 "stderr_log_path":"/home/you/.isopod/vms/dev-3d39a8fd/exec-stderr.log",
 "slot":0,"guest_ip":"10.107.0.2"}

The fields you'll look at most: exit_code + stdout (your command's result), path ("warm" = snapshot resume, "cold" = full boot), and the *_log_paths (the full, uncapped output when stdout_truncated is true).

5.1 Filtered egress — the allowlist the guest cannot rewrite

--allow-host (repeatable) switches a run to default-deny egress. It claims a filtered slot, which the nftables ruleset drops all forwarding from, and reaches the network only through a broker running on the host — outside the sandbox, where no code in the guest can address it.

isopod run --allow-host pypi.org -- python3 -c \
  "import urllib.request; print(urllib.request.urlopen('https://pypi.org/simple/').read(40))"

Every run in this mode returns an egress block:

"egress": {
  "mode": "filtered",
  "allowed_rules": ["pypi.org", "*.pythonhosted.org"],
  "allowed": [{"host": "pypi.org", "port": 443, "ts_ms": 521}],
  "denied": [
    {"host": "example.com", "port": 443, "reason": "not_allowed", "ts_ms": 554},
    {"host": "exfil.evil.example.com", "port": 0, "reason": "not_allowed", "ts_ms": 556}
  ],
  "dns_queries": ["exfil.evil.example.com"],
  "total_events": 4, "truncated": false,
  "egress_log_path": "/home/you/.isopod/vms/dev-b806c77a/egress.jsonl"
}

A denied entry with "port": 0 is a DNS lookup that was refused — the guest has no route to any resolver but the broker, so a name you did not allow cannot even be resolved, let alone contacted. The lists are capped at 64 entries inline (truncated tells you when that bit); the complete record is JSON Lines at egress_log_path.

Filtered runs stay warm-pool eligible, so this costs a normal warm resume (~74 ms measured), not a cold boot.

Common surprises:

What this does not do: allowlisting is destination control, not data-loss prevention. If you allow github.com, data can leave to github.com. SECURITY.md states the full set of claims and non-claims.

--stage base starts from a fresh squashfs base image with zero committed layers. Two bases exist:

(The CLI defaults to the minimal image; the MCP server defaults to the toolchain image because agent workloads almost always want one.)

Stages: build once, fork forever

# Install something and commit the filesystem delta as a stage (exit 0 only).
isopod run --stage base --base base-alpine --commit-as demo/requests -- pip install requests

# Fork it — the parent stage is never mutated, fork as often as you like.
isopod run --stage demo/requests -- python3 -c 'import requests; print("ok")'

# Inspect / prune.
isopod stage list
isopod stage info demo/requests
isopod stage rm demo/requests

Committing again on top of a forked stage stacks a new layer. Chains keep one base flavor throughout; the chain depth limit is 10 layers.

Nothing above ever mutates a parent. A run assembles a fresh overlay from the read-only base, the read-only stage layers, and its own scratch — so forks are free to diverge and a stage can have as many children as you like:

flowchart TB
    BASE["base-alpine<br/>squashfs, read-only"]
    BASE -->|"run, then commit_as"| S1["demo/requests<br/>ext4 layer, immutable"]
    S1 -->|"fork, run, commit again = stack"| S2["demo/requests+app<br/>stacked layer, immutable"]
    S1 -.->|"fork"| F1["ephemeral run"]
    S1 -.->|"fork"| F2["ephemeral run"]
    S2 -.->|"fork"| F3["ephemeral run"]
    F1 -.->|"exit 0 and no commit_as"| GONE["discarded with the VM"]

Only a clean exit commits: a setup command that fails never leaves a broken stage behind.

When the base image is rebuilt

A stage's layers are overlay upperdirs over one build of the base image, not over the flavor name. isopod image build-all replaces that build — new Alpine packages, a new guest agent — and the old layers would still mount over the new root without complaint, leaving a chain whose contents no longer match what is beneath them (site-packages whose interpreter moved, a binary linked against a library that changed soname).

Every commit records the content id of the base image it was built on (base_sha256 in stage info — the id the image's build sidecar reports, not a re-hash of the file), and a fork checks it — for every stage in the chain, not just the one you named, since an ancestor's layers get mounted too — before anything boots. A rebuild that changes nothing costs nothing. The pack pins every timestamp it writes — the superblock's and every file's — so an image built twice from the same tree is byte-identical and keeps its content id, and the stages on it go on forking. What moves the id is a change to what is in the image: a new guest agent, a PROTO_VERSION bump, different Alpine packages. The check is still deliberately conservative about those — it knows the image is not the one the layers were made over, not whether the difference matters to them.

flowchart TB
    S["stage myproj/deps<br/>base_sha256 = ccca1229…"]
    S --> Q{"host's base-alpine<br/>image today"}
    Q -->|"same content id"| RUN["fork boots"]
    Q -->|"no sidecar<br/>(image predates stamping)"| WARN["warns, boots<br/>nothing to compare"]
    Q -->|"rebuilt: different id"| REFUSE["refused before boot"]
    REFUSE -->|"rebuild the stage<br/>on the current image"| FRESH["run --stage base, commit again<br/>the whole chain agrees"]
    REFUSE -->|"ISOPOD_ALLOW_BASE_SKEW=1"| SKEW["boots this run; a commit stacks<br/>on the same stale ancestors"]
    SKEW -->|"the new stage inherits the old chain"| S

The refusal names both content ids and both ways out. ISOPOD_ALLOW_BASE_SKEW=1 covers the commit as well as the boot: rebuilding the images changes the base of every stage at once, and a hatch that booted the fork but refused to save what it produced would strand exactly the work it exists for. It does not excuse a different base flavor — those layers belong to another root, not an older one.

It is an escape for a run, not a repair. Committing under it stacks a new layer on the same stale ancestors, and the check walks every link, so the stage you get back still disagrees with the image and still needs the variable to boot. Its own base_sha256 is the current image's, so stage info on it reads clean while the chain beneath it does not. Rebuilding the stage from --stage base on the current image is the only thing that clears it — and nothing enumerates which stages a rebuild affected, so a refused fork is how you find out.

Stages committed before isopod 0.12.0 carry no content id. They fork exactly as they did, unchecked — there is nothing recorded to disagree with.

Sizing a VM

Flag Default Bounds
--vcpus 1 1 or an even number, ≤ host CPUs
--mem-mib 512 128 ≤ n ≤ host free RAM (with headroom)
--scratch-mib ~1024 128 ≤ n ≤ 65536 — the writable overlay upper; sparse, so it costs little until written
--timeout-s 120 ≤ 3600 — outer wall clock including boot, not exec-only

Over-cap requests fail fast with a clear message, before any VM boots.

Moving data in and out

6. Warm pool (optional, recommended)

The warm pool caches a full-VM snapshot of a booted-idle VM so eligible runs resume in tens of milliseconds instead of cold-booting:

isopod warmpool build     # build (or reuse) the snapshot for the default config
isopod warmpool list      # see cached snapshots
isopod warmpool rm --all  # drop them (they rebuild on demand)

warmpool build is optional: the first eligible run builds the snapshot automatically (paying the one-time cost, a few seconds, inside that run — you'll see snapshot_built: true in its result). Prebuilding just moves that cost off your first run.

Whether a given run resumes warm or cold-boots is decided here. This is the canonical statement of the rules; other docs paraphrase it.

flowchart TB
    START["a run starts"] --> Q1{"starts from a fresh base<br/>with no committed layers?"}
    Q1 -->|"no, it forks a stage"| COLD["cold boot"]
    Q1 -->|"yes"| Q2{"networking on?"}
    Q2 -->|"no, --no-network"| COLD
    Q2 -->|"yes"| Q3{"commit_as unset?"}
    Q3 -->|"no, it commits"| COLD
    Q3 -->|"yes"| Q4{"--scratch-mib unset?"}
    Q4 -->|"no, sized scratch"| COLD
    Q4 -->|"yes"| Q5{"does the cached snapshot<br/>key still match this host?"}
    Q5 -->|"no"| REFRESH["cold boot, then rebuild the cache"]
    Q5 -->|"yes"| WARM["warm resume, ~49 ms"]

The key checked at the last step covers the Firecracker build, host kernel, CPU model, base flavor, vCPUs, memory, and snapshot format. Change any one of them — a kernel upgrade, a different --mem-mib — and the next run cold-boots once and re-caches.

7. The rootless jail (optional second isolation layer)

For untrusted or multi-tenant workloads, wrap every Firecracker process in a rootless microjail:

ISOPOD_JAIL=1 isopod run --stage base --base base-alpine -- id

Set ISOPOD_JAIL=1 in the environment of whichever runtime you use (the CLI, or the MCP server's environment). It adds user/pid namespaces (an escape lands as an unprivileged unmapped uid, not your account), a minimal chroot (your home and the rest of ~/.isopod are not visible), and per-VM cgroup memory/cpu/pids caps.

Requirements: Linux 5.12 or newer, unprivileged user namespaces, a delegated cgroup v2 subtree (a normal systemd user session provides one), and kvm group membership. The preflight fails closed with a specific message if something is missing — it never silently runs unjailed. See SECURITY.md for what the jail does and does not defend against.

The kernel floor is mount_setattr(2), which arrived in 5.12 and is the only mechanism that makes a bind read-only including every mount beneath it — in one atomic call, adding the read-only attribute without clearing the flags a user namespace locks, and reaching mounts that another mount is stacked over. A hand-rolled equivalent for older kernels was tried and abandoned: it failed all three ways, each time on a real host and never on a developer's machine, because whether it fails at all depends on the host's mount table. Without that guarantee a read-only bind silently leaves its submounts writable, so on an older kernel the jail refuses to start rather than claim a boundary it cannot enforce. Everything else in isopod — including unjailed runs — is unaffected by this floor.

8. Claude Code: MCP server + skill

Register the MCP server at local scope (auto-trusted, no approval prompt):

cargo build --release -p isopod-mcp
claude mcp add --scope local isopod -- "$PWD/target/release/isopod-mcp"
claude mcp list    # -> isopod ... ✔ Connected

MCP servers load at session startup — restart Claude Code after registering. Tools appear as mcp__isopod__sandbox_run etc., and the bundled skill (skill/SKILL.md) teaches Claude the commit/fork workflow. Smoke-test it by asking Claude to run sandbox_run(cmd="echo hi") — expect exit_code: 0 and stdout: "hi\n".

Alternatively load the repo as a plugin (skill + server in one):

claude --plugin-dir /path/to/isopod

Full registration details, the tool list, and sandbox_run's parameters: docs/mcp-usage.md.

The MCP server is a stdio subprocess of Claude Code — after rebuilding isopod-mcp you must restart the Claude Code session to pick the new binary up.

9. Where state lives

Everything is under ~/.isopod, file-locked for concurrent sessions:

Path Contents
~/.isopod/bin/ The Firecracker binary (and jail helper) dev build-fc installs.
~/.isopod/images/ Guest kernel + rootfs/base images, stamped with their protocol version.
~/.isopod/stages/ The content-addressed stage store (one directory per layer + meta.json).
~/.isopod/vms/ Per-run VM records: config, exec/serial logs, throwaway disks.
~/.isopod/snapshots/ Warm-pool snapshots, keyed by environment.
~/.isopod/net/ Network slot bookkeeping.

Housekeeping: isopod vm gc --keep-last 20 reaps orphaned Firecracker processes and prunes old VM records (the MCP server also does this automatically); isopod stage rm removes stages (leaf-first — removal is refused while other stages fork from it); isopod warmpool rm drops snapshot caches. Stages are never auto-pruned.

10. Troubleshooting

Symptom Cause / fix
permission denied opening /dev/kvm Not in the kvm group (or no re-login since adding). sudo usermod -aG kvm "$USER", log out/in.
python3: not found in a stage run (CLI) The CLI's --base default is the toolchain-less base-sqfs — pass --base base-alpine.
Slot-exhaustion error on concurrent runs All 8 public slots are claimed. Wait, run with --no-network, or re-provision with sudo isopod setup --slots N.
no filtered-egress slots on an --allow-host run A different pool. Either the host predates 0.9 / was provisioned with --filtered-slots 0, or all 4 filtered slots are busy. The error prints the exact re-provisioning command; the two pools never borrow from each other.
Networking errors right after a host reboot / WSL restart Taps don't survive reboots — re-run sudo isopod setup (idempotent).
sudo: isopod: command not found sudo's secure_path skips user dirs — use sudo ./target/release/isopod setup or the full path.
Protocol-mismatch error naming host vs. image versions Images built by an older checkout — isopod image build-all.
Degraded-overlay error on a stage run Stale/corrupt guest image — isopod image build-all, then retry.
Jail preflight failure Read its message: usually no delegated cgroup v2 subtree (run inside a normal systemd user session) or missing userns support.
A run hangs then times out at --timeout-s That budget includes boot; raise it for slow commands. Check the serial log path in the JSON result for guest-side detail.
Host disk filling up isopod vm gc, isopod stage rm unused stages, isopod warmpool rm --all. Logs are capped per-VM but retained until pruned.

11. Uninstall

sudo isopod setup --remove   # taps, nftables table, sysctl drop-in
rm -rf ~/.isopod             # all images, stages, snapshots, VM records
rm -f ~/.local/bin/isopod    # the CLI, if you installed it

Plus claude mcp remove isopod if you registered the MCP server.

Rendered from docs/getting-started.md on the main branch.