Contributing to isopod
Thanks for your interest in isopod. This guide covers building the project from source, running the tests, the crate layout, coding conventions, and how to propose changes.
isopod is pre-1.0; main is the active development line. Please open an issue to discuss a substantial change before investing a lot of work in it — the design in PLAN.md is the reference for what is in scope for v1 versus the v2+ backlog.
Security bugs do not go through the public issue tracker. See SECURITY.md for private reporting.
Prerequisites
isopod targets Linux with KVM. You need:
- KVM access —
/dev/kvmmust exist and your user must be in thekvmgroup (ls -l /dev/kvm,groups). If you are developing inside a VM or WSL2, nested virtualization must be enabled. - Rust — the toolchain is pinned by
rust-toolchain.toml:stable, with thex86_64-unknown-linux-musltarget (the guest agent is a static musl binary).rustupreads this file automatically; runningcargoin the repo installs the pinned channel and target on first use. - The vendored Firecracker submodule — Firecracker v1.16.1 is vendored as a git submodule at
vendor/firecrackerand built from source. Fetch it before building anything:
bash
git submodule update --init --recursive
- Host tooling used by the image pipeline and networking:
nftablesandiproute2— NAT setup and tap devices (isopod setup).e2fsprogs—mkfs.ext4,resize2fsfor scratch and stage images.squashfs-tools—mksquashfsfor the read-only base images.- a working C toolchain — to compile the vendored Firecracker.
On a Debian/Ubuntu host these come from nftables iproute2 e2fsprogs squashfs-tools build-essential (package names vary by distro).
Build
# Build the whole workspace (CLI, MCP server, core, guest agent) in release mode.
cargo build --release
This produces three binaries under target/release/:
isopod— the CLI (fromcrates/cli).isopod-mcp— the MCP stdio server (fromcrates/mcp).isopod-jail— the rootless jail helper (fromcrates/jail, used only whenISOPOD_JAIL=1).
(The guest agent, isopod-guest-agent, is also built as part of the workspace but is baked into the guest images by the image pipeline rather than run on the host.) The front ends are separate binaries built from a shared isopod-core library, so after changing crates/core you must rebuild whichever front end you are exercising (cargo build --release builds all of them).
To bring up a working environment (Firecracker binary, guest kernel, guest images), then run once:
./target/release/isopod dev build-fc # build vendored Firecracker into ~/.isopod/bin
./target/release/isopod image fetch-kernel # pinned, digest-verified guest kernel
./target/release/isopod image build-all # every guest image incl. the squashfs bases (unprivileged)
sudo ./target/release/isopod setup # one-time host networking (the only root step)
All runtime state lives under ~/.isopod (file-locked). Nothing is written outside your home directory except what sudo isopod setup provisions on the host network stack, which sudo isopod setup --remove cleans up.
Test
cargo test # run the full workspace test suite
cargo clippy --all-targets --all-features # lint; keep it clean
cargo fmt --all # format (rustfmt)
A few notes on the test suite:
- Unit and integration tests that do not require booting a VM run anywhere
cargoruns. The bulk ofisopod-fc,isopod-proto,isopod-corestore/stage/naming logic, and the MCP argument marshaling are covered this way. - Tests that boot a real microVM require KVM and a provisioned environment (Firecracker binary, kernel, rootfs). Stage round-trip correctness — write → commit → fork → write → commit, verifying both chains stay independent, whiteout deletions survive across layers, and xattrs are preserved — is the load-bearing integration coverage; keep it green when touching the stage store.
- Please add or update tests in the same change as the behavior they cover. New CLI/MCP surface should include a JSON-shape assertion; new stage/snapshot bookkeeping should include a round-trip test.
Dogfooding is a first-class gap-finding mechanism here: running real work through isopod itself surfaces issues that unit tests miss. Findings are logged in docs/dogfood-findings.md; if you discover a gap by using the tool, add an entry with a severity and a fix-or-file decision.
What CI enforces
flowchart LR
PR["pull request"] --> VG["Version guard"]
PR --> BT["Build and test"]
PR --> DP["Dependency policy<br/>cargo-deny licenses, bans, sources"]
PR --> WL["Workflow lint<br/>zizmor and actionlint"]
CRON["daily cron"] --> ADV["RustSec advisories<br/>cargo-deny"]
BOT["weekly Dependabot"] --> PIN["grouped PR bumping<br/>action hash pins"]
MAIN["push to main"] --> COV["Coverage report<br/>advisory, never a gate"]
TAG["tag push"] --> REL["Release packages"]
REL --> SUM["SHA256SUMS"]
REL --> ATT["provenance attestation"]
Every PR runs four independent gates (ci.yml); all four are deterministic — the same tree gives the same answer forever:
- Version guard — the versioning contract below, enforced mechanically.
- Build and test —
cargo fmt --check; clippy over all targets and features at-D warnings; workspace build and tests--locked; a--no-runcompile of the KVM-gated tests so they cannot rot silently;cargo docat-D warnings; and the mutation harness (scripts/mutation-check.py). - Dependency policy —
cargo deny check licenses bans sourcesagainstdeny.toml. Every crate inCargo.lockmust carry an allowed permissive license and come from crates.io. A dependency arriving under a new license fails this gate; the fix is a reviewed addition to the allow list, not a workaround. - Workflow lint — zizmor and actionlint over
.github/workflows/. Everyuses:is pinned to a full commit SHA with the version as a trailing comment; tags are mutable, hashes are not, and zizmor enforces the policy from then on.
Off the PR path:
- Advisories (
advisories.yml, daily cron) —cargo deny check advisories: RustSec advisories and yanked releases acrossCargo.lock. It runs on a clock, not on PRs, because a new CVE flips it with no code change — a red run means a locked crate has a known advisory, never that your PR broke something. The badge in README.md is the visibility mechanism. - Coverage (
coverage.yml, pushes tomain) —cargo llvm-cov, advisory only: no threshold, no service,continue-on-error, and it must stay that way. The#[ignore]d /dev/kvm suite cannot run on hosted runners, so the percentage systematically undercounts the best-defended paths (jail, VM lifecycle, network enforcement). The report exists to find modules no test executes, not to grade the tree. - Dependabot (
dependabot.yml) — weekly grouped PRs bumping action hash pins. Thegithub-actionsecosystem only: a cargo update PR would touchCargo.lockwithout the version bump the guard demands and arrive permanently red. Cargo security coverage comes from Dependabot alerts and security updates (a repo-settings toggle) plus the advisories cron. - Release provenance (
release.yml) — every asset a release ships carries a Sigstore-signed build attestation (SLSA v1.0 Build L2) tying it to this repository, workflow, and commit. Verify any downloaded asset withgh attestation verify <asset> -R me1iissa/isopod. The checksums inSHA256SUMSprove a download is intact; the attestation proves where the build came from. - CodeQL runs through GitHub's default setup, configured in repository settings — there is deliberately no CodeQL workflow file to edit.
None of this runs the live suite. GitHub runners have no /dev/kvm, so the real-boot and egress-ledger tests still run only by hand — green CI proves the build, the dependencies, and the workflows, not that the sandbox holds.
Crate map
isopod is a Cargo workspace of eight crates. Solid arrows are Cargo
dependencies; dashed arrows are runtime relationships that no Cargo.toml
records:
flowchart TB
CLI["crates/cli<br/>isopod"] --> CORE
MCP["crates/mcp<br/>isopod-mcp"] --> CORE["crates/core<br/>isopod-core"]
CORE --> FCC["crates/fc-client<br/>isopod-fc"]
CORE --> PROTO["crates/proto<br/>isopod-proto"]
GA["crates/guest-agent<br/>isopod-guest-agent"] --> PROTO
CORE -.->|"execs it when ISOPOD_JAIL=1"| JAIL["crates/jail<br/>isopod-jail"]
OCI["crates/oci-unpack<br/>isopod-oci-unpack"] -.->|"standalone: nothing depends on it yet"| NONE["image import<br/>not wired up — wave 1 is the extractor alone"]
FCC -.->|"HTTP/JSON over a per-VM unix socket"| FCB["firecracker<br/>not a crate — the vendored VMM"]
GA -.->|"baked into the image, runs as PID 1"| VM["guest microVM<br/>not a crate — the sandbox itself"]
isopod-proto is the only crate compiled into both sides of the boundary — the
host links it through isopod-core, the guest through isopod-guest-agent —
which is why its PROTO_VERSION gates host/guest compatibility.
| Path | Package | Responsibility |
|---|---|---|
crates/fc-client |
isopod-fc |
Typed client for the Firecracker management API, pinned to the v1.16.1 Swagger. One HTTP client per VM over its API unix socket, with a runtime pre-boot/post-boot phase guard, process supervision (kill_on_drop, spawn-in-slot), and hybrid-vsock helpers. Deliberately dependency-light so it can be extracted as a standalone SDK. |
crates/core |
isopod-core |
All orchestration logic. Modules: vm (lifecycle: spawn Firecracker, configure via the API, run, reap), stage (commit/fork/stack/gc of the content-addressed store), snapshot (warm-pool save/restore + cache invalidation), net (tap slot claim/release), agent (guest-agent vsock RPC client), store/paths (~/.isopod on-disk state), image (kernel + rootfs pipeline), names (vanity/stage naming). |
crates/proto |
isopod-proto |
The host↔guest RPC contract: length-prefixed serde-JSON frames over vsock, one connection per operation. PROTO_VERSION is exchanged in the ping handshake so mismatched host/guest pairs fail fast. |
crates/guest-agent |
isopod-guest-agent |
Static musl binary that runs as PID 1 in the guest: mounts pseudo-filesystems and the overlay, pivot_roots, resyncs the clock, reaps zombies, and serves the exec/file/configure RPC on vsock port 52. |
crates/cli |
isopod |
The isopod binary — run, stage, vm, warmpool, setup, image, dev. One-shot argv + JSON. |
crates/mcp |
isopod-mcp |
The rmcp 2.2 stdio MCP server exposing sandbox_run, stage_list/stage_info/stage_rm, vm_list/vm_gc. A thin async shim over isopod-core. |
crates/jail |
isopod-jail |
The rootless microjail helper that wraps each Firecracker process when ISOPOD_JAIL=1: user/pid namespaces, minimal bind-mount chroot, per-VM cgroup v2 caps. |
crates/oci-unpack |
isopod-oci-unpack |
Confined extractor for OCI image layer tars: a directory-fd walk with O_NOFOLLOW that applies untrusted layers, whiteouts and all, without following a symbolic link a layer planted. Depends on nothing in the workspace and nothing depends on it yet — it is deliberately reviewable on its own, because it is the one component that writes attacker-authored bytes onto the host before any VM exists. |
Supporting directories: images/ (checked-in kernel config, image build inputs), skill/ (the Claude Code workflow skill), docs/ (getting started, MCP usage, engineering logs), vendor/firecracker/ (the pinned submodule).
Coding conventions
- One-shot, non-interactive, structured output. Every CLI subcommand and every MCP tool is a single invocation that reads its arguments, does the work, prints exactly one JSON object to stdout, and exits. No REPLs, no interactive prompts, no persistent stdin. Human-readable logs and diagnostics go to stderr — for the MCP server this is mandatory, since stdout carries the JSON-RPC stream.
- State on disk. Anything that must survive between invocations lives under
~/.isopod, file-locked so multiple sessions (Claude Code, a shell, CI) can share it safely. Do not hold cross-invocation state in memory only. - Follow the phase machine in
isopod-fc. The Firecracker API distinguishes pre-boot from post-boot operations; the typed client encodes this. Do not bypass it with raw requests. - Treat vsock connections as disposable. One connection per RPC operation; assume a connection is dead after any snapshot pause/resume/fork.
- Immutability of stages is a hard invariant. A committed stage is never mutated. Forks add a fresh scratch on top of the read-only chain; correctness of the layered-diff bookkeeping (whiteouts, xattrs, chain independence) is where this kind of system usually breaks, so guard it with tests.
- Cite public specs, not private notes. Where a comment needs to reference an external contract, cite the public source (the Firecracker docs/Swagger, POSIX, an RFC, a man page).
- Keep
cargo fmtandcargo clippyclean; both are expected to pass before review.
Versioning
The project follows Semantic Versioning with the usual pre-1.0
reading: while the leading version is 0, a minor bump signals a feature wave or
any breaking change (new subcommands, guest-image format changes, host↔guest protocol
cutovers), and a patch bump signals backward-compatible fixes only.
- The workspace version in the root
Cargo.tomlis the single source of truth; every crate inherits it viaversion.workspace = true, and.claude-plugin/plugin.jsonmust be kept in lockstep by hand. - Every change wave that lands on
mainbumps the version in the same commit — the version atHEADalways describes the code atHEAD. Never leave the number behind while the code moves on. - Each version bump gets an annotated tag
vX.Y.Zon the commit that bumps it, and an entry in CHANGELOG.md (a few lines, most recent first). - CI enforces this (
version-guardjob): a PR touching code paths must carry a strictly greater version than its base; a lower version is always rejected. Docs/CI-only changes may keep the version unchanged. PROTO_VERSIONincrates/protois a separate, independently-incremented compatibility number for the host↔guest RPC contract; it is not tied to the crate version.
Historical note: versions v0.2.0–v0.6.0 were tagged retroactively at each
milestone-close commit (M2–M6) when the versioning discipline was adopted at v0.7.0;
the manifests at those commits still read 0.1.0.
Proposing changes
- Branch off
main. Do not commit directly tomain. - Keep changes focused. Solve the stated problem; avoid unrelated refactors in the same change. Update tests and any affected docs (
README.md,docs/,PLAN.mdmilestone notes) in the same change. - Run the checks locally before opening a PR:
cargo fmt --all,cargo clippy --all-targets --all-features,cargo test. If the change adds or updates a dependency, also runcargo deny check licenses bans sources(cargo install cargo-deny). - Write a clear PR description explaining what changed and why, how you verified it (including any live VM/dogfood testing), and any follow-ups you are deliberately leaving out of scope.
- Reference the relevant PLAN.md milestone or backlog item where applicable, so reviewers can place the change in the roadmap.
Commit messages should be descriptive and explain the why, not just the what. There is no mandated trailer format.
By contributing, you agree that your contributions are licensed under the project's Apache License 2.0.
Rendered from CONTRIBUTING.md on the main branch.