Dogfood findings
Gaps found by using isopod, per the standing rule: dogfooding is the primary gap-discovery mechanism. Every entry gets a severity and a fix-or-file decision. Oldest first — the sessions read as a chronology. Format:
[status] severity — finding → decision.
Nine sessions over four days, each one a gauntlet thrown at whatever had just landed:
timeline
title Dogfood sessions
section 2026-07-21
M2 surface gauntlet : exit codes, streams, truncation, timeout, concurrency
M4 networking : live, after sudo isopod setup
M4 acceptance : pip and git through isopod on the Alpine base
section 2026-07-22
Self-build : isopod builds its own workspace : M6 warm-pool verification
MCP v2 gauntlet : all-MCP, including the self-build via MCP
Findings-fix wave : 15 to 25 closed : proto v3 : images rebuilt
Proto-v3 verification : the next-gauntlet checklist, post-restart
section 2026-07-23
Fix wave : 26 and 27, built in-sandbox and verified live
section 2026-07-28
0.12.0 dogfood : self-build on HEAD : the base-skew feature under real use
2026-07-21 — M2 surface gauntlet (exit codes, streams, truncation, timeout, env/cwd, binary, errors, concurrency)
What was thrown at isopod run: exit 42; stderr-only output; 200 KB stdout
(> 64 KiB cap); sleep 30 under --timeout-s 3; --env/--cwd; 4 KiB of
/dev/urandom; a nonexistent binary; two concurrent runs; 25-run directory
accumulation. All core behaviors correct (truncation exact, full logs retained,
timeout kill in 3.05 s wall, signal 9 reported, concurrent runs isolated with
distinct vanity names).
-
[fixed 9ecbd79] HIGH — vanity names exist but nothing lists or resolves them. Names are persisted in each VM dir's
meta.json, but there is noisopod vm ls, so a user/model cannot look upresilient-legionaryafter the fact — which defeats the point of memorable handles. → FIX at M3 integration:isopod vm ls(id, name, flavor, created, status) reading the meta.json files; name→vm resolution helper shared with stages. -
[fixed 9ecbd79] MEDIUM —
~/.isopod/vms/grows without bound. 25 dirs / 600 KB after one day of testing; harmless now (logs only), but every run adds one and nothing prunes. → FIX at M3 integration:isopod vm gc [--keep-last N] [--older-than 7d]with sane defaults; consider auto-gc on run. -
[fixed 9ecbd79] MEDIUM — command-not-found is indistinguishable from infra failure.
isopod run -- /bin/nonexistentyields{ok:false, error:"exec over vsock: guest agent reported an error: exec: No such file or directory"}— same shape as a genuine sandbox/transport failure, and no exit code. Callers (especially the future MCP tool) need to tell "your command is wrong" from "the sandbox broke". → FIX before M5: spawn-failure becomes a structured outcome (exit_code:127-style or anerror_kindfield), reservingok:falsefor infrastructure faults. -
[fixed M5] LOW — no stdin plumbing. The proto supports
stdin_b64end-to-end butisopod runhas no--stdin/--stdin-file, so piping data into a sandboxed command requires a file-put dance that doesn't exist yet either. → file for M5 (MCPfile_put+ a--stdin-fileflag land together). -
[fixed 9ecbd79] MEDIUM — guest rootfs has no
/tmp. Found by probing the guest environment:echo t > /tmp/xfails on a fresh dev-agent VM (the dir simply isn't in the image;mkdir -pworks). A large fraction of real scripts and tools assume/tmp. → FIX at M3 integration: add/tmp(mode 1777) and/var/tmpto every flavor's populate step (dev-busybox, dev-agent, base-sqfs). Guest-env facts for the record: 235 MB usable RAM of 256 configured, ~53 MB free rootfs, 302 busybox applets, uid 0.
2026-07-21 — M4 networking (live, post-sudo isopod setup)
Egress works (ICMP + DNS through the NAT), concurrency lands on distinct slots
(0/1), host isolation holds (guest can't reach the host tap — the iifname
input-drop fix), --no-network attaches no NIC. Two findings:
-
[fixed f332743+] HIGH — a leaked firecracker holding a tap breaks its slot until manually killed. A VMM that outlived its run (here
dev-85eddd65from an earlier failed attempt) keptisopod-tap0open, so every later slot-0 run died withEBUSYatPUT /network-interfaces— a confusing, persistent failure with no self-recovery. The stale-lock sweep only reclaims locks whose pid is dead; a live-but-orphaned VMM defeats it. → FIX: (a) claim should verify the tap is actually openable (or that no firecracker holds it) and either reclaim or skip to the next slot; (b) harden run teardown so a VMM is never orphaned (audit every error path between spawn and shutdown; the FcProcess Drop guard should cover it — find why this one escaped); (c)isopod vm gc/ a--kill-staleshould reap orphaned VMMs. Worth fixing before M5 (an MCP client hitting a wedged slot would be baffling). -
[note] MINOR — HTTP-by-IP to 1.1.1.1:80 is an unreliable egress probe.
wget http://1.1.1.1doesn't cleanly return 200 (Cloudflare redirects to HTTPS), so it's a poor liveness check even though egress works. Use ICMP + DNS (both confirmed) in the runbook; drop the plain-HTTP-by-IP check.
2026-07-21 — M4 acceptance (pip/git through isopod, Alpine base)
The marquee test passed: bare pip install requests into an Alpine stage →
commit → fork BY VANITY NAME → import requests with no reinstall → parent
byte-identical. Three fixes fell out of running it:
-
[fixed 0a37865+] HIGH — bare
pip installfailed (PEP 668). Alpine's Python 3.14 ships anEXTERNALLY-MANAGEDmarker, sopip installerrored and an agent would have to know--break-system-packages. In a disposable sandbox that protection is pure friction. → FIXED: thebase-alpinebuild removes everypythonX.Y/EXTERNALLY-MANAGEDmarker; barepip installnow works. -
[fixed] HIGH —
--commit-ascommitted a stage even when the command FAILED. The first pip run errored (PEP 668) yet still committed a stage (lucent-crucible) missing the package — a silent footgun for anyone who later forks it. → FIXED:--commit-asnow commits only onexit_code == 0, logging a clear skip reason otherwise. -
[fixed] HIGH — a stage didn't record which base it was built on. Meta hardcoded
base: base-sqfsregardless; forking an Alpine-built stage without remembering--base base-alpinewould mount alpine layers over a busybox base (site-packages but no interpreter) — a silent broken merge. → FIXED:stage::commitrecords the true base flavor; a fork auto-uses the recorded base (verified: forking with no--baseruns Python 3.14), and stacking enforces a single base per chain. -
[note] DOC —
--timeout-sbudget includes boot.--timeout-s 3gives the command ~2.6 s of real exec time (boot consumes ~0.4 s of the budget). Reasonable semantics for an outer wall clock, but must be documented in the CLI help and the eventual MCP tool description (whose default timeout should account for it).
2026-07-22 — self-build dogfood (isopod builds its own workspace) + M6 warm-pool verification
Headline positive: isopod compiled its own full 6-crate workspace — 182 crates including
rustls / aws-lc-sys / reqwest / tokio / rmcp — in 96 s inside an isopod sandbox, and the
freshly-built isopod binary ran (isopod 0.1.0) inside the sandbox. Recipe exercised the
stage-fork model end to end: stage the toolchain once (rustup stable-musl 1.97.1 → rust-stable
stage in 22 s), then fork it and build with target/ on a guest tmpfs. Proof the stage/fork
model + M5.5 flex resources carry a real heavy workload. Five gaps fell out.
-
[fixed] HIGH — sandbox networking doesn't survive a WSL2/host restart, and the failure is cryptic. The user-owned
isopod-tap0..7are non-persistent; after the WSL utility VM recycles they vanish and every networkedrun/sandbox_runfails with a raw Firecracker string —Open tap device failed: Operation not permitted ... Invalid TUN/TAP Backend provided by isopod-tap0— with no hint the fix issudo isopod setup. Hit both the MCP path and the M6 agent this session. → FIXED:require_network_setupnow, when a manifest exists, also checks the provisioned taps are actually present (net::provisioned_taps_present→/sys/class/net/isopod-tap<i>) and fails fast — before any disk work — with an actionable "networking was provisioned but its tap devices are missing — the host was most likely restarted … re-runsudo isopod setup(or --no-network)" message. (Runtime is unprivileged, so auto-reprovision isn't possible; the clear message is the fix. Unit-tested via an injected presence predicate.) (PLAN networking risk #3, made concrete.) -
[fixed] MED — writable scratch (overlay upper) was fixed at ~1 GiB with no size knob. A minimal rustup toolchain (799 MiB) nearly fills it (98 MiB free, 89 %); a real build (
target/reached 1.5 GiB) can't fit at all. Workaround that unblocked the self-build: mount a tmpfs in the guest and pointCARGO_TARGET_DIR/RUSTUP_HOME/CARGO_HOMEat it (trades RAM for space). → FIXED: added--scratch-mib(CLI) /scratch_mib(MCPsandbox_run), bounded 128..=65536 MiB, validated before boot (clear range error, no VM launched). The image is sparse, so a large apparent size costs little host disk until written. Verified live: 4096 → 3.9 G overlay, 8192 → 7.8 G. Passing it forces the cold ext4 path (a warm resume uses a RAM tmpfs upper), so the requested size always takes effect;--mem-mibremains the lever for a bigger RAM upper on warm runs. -
[fixed] MED — no Rust toolchain in any base, and
base-alpinehas noapk. The squashfs base bakes in python/node/git/gcc/make/cmake but strips the package manager, so you can'tapk adda toolchain at runtime.curl/xz/bashalso absent (wget/gzip/tar/base64present). rustup-over-wgetworks fine. → for a system whose own dogfood is "build yourself", consider a toolchain-bearing base flavor, or keepapkavailable in base-alpine. → FIXED (2026-07-22 wave): base-alpine retains the verified staticapk+ signing keys (in-guestapk add jqverified live) and addscmake+ GNUcoreutils; Rust toolchains stay stages (rust-stable), which the self-build proved out. -
[fixed] LOW/footgun —
--base Xwithout--stagesilently boots the legacydev-agentext4 rootfs, ignoring--base. The flag appears to do nothing — and, mid-M6, that legacy rootfs still carried the old proto v1 guest, surfacing a confusing "guest 1 does not match host 2" until you realise--baseonly applies with--stage. → warn/error when--baseis passed without--stage, or make--baseimply the squashfs/overlay topology. → FIXED (2026-07-22 wave): a lone--baseis now a hard error naming both valid spellings. -
[fixed] proto-version skew across guest images after a
PROTO_VERSIONbump. Bumping to v2 forConfigureNet(M6) requires rebuilding every guest image (base-sqfs, base-alpine squashfs, legacy dev-agent) and restarting any long-livedisopod-mcpserver, or the guest baked into one image (or the stale server binary) mismatches. Credit: the error is clear and names both versions. → build tooling should rebuild all guest images together + stamp their proto version; surface per-image proto version in a status command. → FIXED (2026-07-22 wave): every build stamps<image>.meta.json(flavor, proto, agent sha256, image sha256); run paths refuse a stale image pre-boot naming the fix;isopod image build-allforce-rebuilds every flavor together andimage lsshows per-image proto + stale/unstamped. Exercised for real by the v2→v3 bump.
Hypothesis retracted: I expected aws-lc-sys to fail for want of cmake — it built cleanly
(base-alpine ships cmake for node-gyp, and aws-lc-sys has a cc path). Not a finding.
Concurrency stress (positive, no gap). 6 networked run --stage base launched in parallel
all warm-resumed from the one shared 512 MiB snapshot, each claimed a distinct slot (0–5)
with its own /30, all exited 0 with NET-OK, and left zero leaks (no firecracker procs, no
held slot locks). The O_EXCL slot-claim held under this contention and concurrent resume from
a single read-only memfile is safe — the core multi-agent model holds under load.
Corrected 2026-07-26. This was read at the time as "the slot claim is race-free", which it was not. Six simultaneous launches is the one shape the
O_EXCL-plus-staleness claim got right; runs staggered by more than a few seconds is the shape it got wrong, and neither this stress nor the unit tests ever ran that. The claim is now anflockheld for the run's lifetime, which does not depend on timing at all. See the 0.11.0 changelog entry.
2026-07-22 — MCP v2 gauntlet (post-restart, all-MCP) + self-build via MCP
Session restart picked up the proto-v2 isopod-mcp binary; the whole surface was re-verified
through the MCP tools alone: a 6-agent workflow ran ~37 scenarios (exec semantics, stage
lifecycle, network/F1, resource caps, toolchain, warm pool) with an adversarial coverage critic,
plus inline probes. Headline: the full workspace now builds inside an isopod sandbox driven
end-to-end over MCP — clean debug build 2 m 05.9 s (4 vcpu / 3072 MiB / 8 GiB scratch, crates.io
downloads included), committed as stage isopod-build (1.53 GiB layer, +34.3 s commit),
incremental rebuild 6.93 s from a fork, cargo test -p isopod-proto -p isopod-fc green in
39.6 s, release isopod in 2 m 35 s, and the binary extracted byte-exact to the host
(stdout log 14,300,661 B complete; decoded 10,585,920 B, sha256 match) where it runs directly —
file: static-pie musl, no loader dependency. Chain: rust-stable → isopod-src →
isopod-build, both new stages retained for future builds (see docs/sandbox-build.md).
Corrections to the 2026-07-22 self-build entry above:
- cmake was never in base-alpine. ALPINE_PACKAGES (rootfs.rs) never listed it and
git log -S cmake on the file is empty; cmake --version in-guest fails. #15's
"python/node/git/gcc/make/cmake" list and the retracted-hypothesis note ("ships cmake for
node-gyp") were wrong — aws-lc-sys built via its cc path with no cmake present.
- The warm pool DOES engage via MCP — but invisibly, and two gauntlet agents misdiagnosed it
as broken (a warm-eligible run's console.log is 121 B: agent re-IP line only, no kernel boot).
What looked like a "provably cold" comparator (2 c/1024 MiB) had silently built its own
snapshot on first use (5.4 s) and warm-resumed afterwards. End-to-end: warm ≈ 430 ms,
cold ext4 path ≈ 570–700 ms, first-use-of-a-shape snapshot build ≈ 5.4 s. The M6 "resume
52–72 ms" figure is the restore step, not wall time. See #20.
-
[fixed] MED — a bad
cwdfails blaming/bin/sh, not the missing directory.sandbox_runwithcwd="/no/such/dir"returns exit 127 with stderrisopod-exec: /bin/sh: No such file or directory (os error 2)— the natural read is "the image has no shell" (and 127 usually means command-not-found), a wrong-way debugging lead. → isopod-exec should check/chdir the cwd first and reportcwd '/no/such/dir': No such file or directory. -
[fixed¹] MED — stale-proto guest failure is masked by a tap error on the networked path.
base-sqfs(still proto v1, #17) with default networking fails asOpen tap device failed: … Device or resource busy … Invalid TUN/TAP Backend provided by isopod-tap0(reproduced twice; base-alpine on the same slot works immediately after) — pointing at host networking instead of the real cause. Onlynetwork=falsesurfaces the correctguest agent protocol version 1 does not match host 2. → surface the proto mismatch before/instead of the tap error; also worth checking whether any cold boot can transiently collide with a warm-pool-held tap. -
[fixed] LOW — MCP result JSON omits boot-path and commit-cost observability. The CLI result has
path: "cold"|"warm"; the MCP result doesn't, which is exactly why the warm pool was misdiagnosed mid-gauntlet.commit_asruns also fold commit time intototal_ms(1612 ms total vs 41 ms exec on a trivial commit; ~34 s for a 1.53 GiB layer ≈ 20 s/GiB). → addpath(incl. distinguishing "snapshot-build" from plain cold) andcommit_msto the MCP result. -
[fixed] LOW — no first-class host↔guest file channel. Payload-in: MCP
stdintransits model context twice, so the 290 KB source tarball (~75 k tokens) is unusable over MCP — injection had to use CLI--stdin-file(which worked perfectly). Artifact-out: base64 over stdout is lossless (log file byte-exact at 14.3 MB) but floods the tool result with a truncated blob. →sandbox_runstdin_file(host path) + a copy-out parameter (guest path → host file); a git remote will also fix source-in. -
[documented] — parallel
sandbox_runtool calls in ONE Claude message execute serially. Six batched calls all ran on slot 0 at ~3.3 s each (a genuine overlap would force distinct slots). Client-side behavior, not a server bug: concurrent requests from separate agent processes interleaved fine during the gauntlet (slots 0/1 held simultaneously), matching the 6-way CLI proof. Guidance for agents: fan out via subagents for parallel sandboxes. -
[fixed] — guest hostname is
(none).$(hostname)in-guest prints(none); setting it to the vanity VM name (e.g.lucent-cryptarch) would improve log/prompt ergonomics. -
[fixed] — rootfs.rs comment implies in-guest
apk add, but no apk ships. The keep-parent-dirs comment says "so an online guest canapk addmore packages later", yetcommand -v apk apk.staticfinds nothing in-guest (re-verified). Align the comment with however #15 is resolved.
Positive re-verifications (all via MCP):
- F1 egress proven against a live service: host connects to its own LAN listener
(<host-lan-ip>:3478) instantly; the guest gets a filtered timeout on that same listening
port, and on gateway:80, and on RFC1918/link-local probes — while DNS through the gateway
works. Drop-not-refused + live-listener evidence closes the "maybe nothing was there" gap.
- Truncation: 600 KB stdout → in-band string capped, stdout_truncated=true, stdout_bytes
exact, log file complete (verified byte-exact at 14.3 MB during extraction). Binary stdout is
lossy U+FFFD in JSON but byte-accurate in the log; stdout_bytes counts raw bytes.
- Resource-cap errors are uniformly self-serve (vcpus 1-or-even w/ examples; host CPU cap;
128 MiB mem floor; over-mem shows the full headroom arithmetic; scratch range 128..=65536).
- Stage model: commit-on-zero only (exit 3 → no stage), chain/parent info correct, whiteouts
work, parents immutable, stage_rm protection names every dependent by id+label (excellent),
child-first removal clean; label + vanity-name + full-id resolution work (id prefix is not
supported — fine per docs, but docker/git-style unique-prefix ids would be nicer).
- Timeout shape: timed_out=true, exit_code=null, signal=9, partial stdout preserved.
- network=false: no NIC (fields absent from result), exec fine over vsock; offline forks of a
pip-carrying stage import the package with no network.
- Quoting/UTF-8/env/cwd/stdin (12 B and 8 KB) all exact.
Next-gauntlet checklist (from the adversarial coverage critic — none ever covered):
duplicate + concurrent commit_as labels; timeout during boot/commit and whether commit_as
fires on timed_out; stderr truncation + dual-stream flood (pipe-deadlock class); unconsumed
stdin (EPIPE) and 64 KB–1 MB stdin; hostile labels (unicode, ../x, very long) and env names
(=, empty, PATH/HOME override); opaque-dir whiteouts (rm -rf + recreate) and 8–16-layer
chains; cwd into stage-created/whiteouted dirs; VM-record/exec-log retention under a
long-lived MCP server (vm_gc semantics, dangling *_log_path); ICMP egress; nonexistent
command via MCP (#3 regression probe).
2026-07-22 — findings-fix wave: #15–#25 closed, proto v3, images rebuilt
One coordinated pass (plan-mode designed, code-explorer-mapped) closed every open finding.
Host-only wins first (#20 observability fields, #16 --base hard error, MCP stdin_file,
auto-GC at startup + every 20 runs, #22 docs), then a proto-v3 wave: SetHostname (#23),
streamed CopyOut (#21, CLI --copy-out / MCP copy_out), #18 cwd error fix, apk + cmake +
coreutils in base-alpine (#15/#24), image sidecars + image build-all/image ls + pre-boot
skew guard (#17, unmasks #19), and SnapshotKey v2 keyed on the base image's content id:
- [fixed] MED —
SnapshotKeyignored image content, so warm snapshots survived image rebuilds as silent stale resumes (surfaced by plan-mode exploration; almost certainly bit the v1→v2 bump too). Key material v2 adds the sidecar-recorded image sha256, cheap to read per run. A rebuilt base now simply keys to fresh snapshots.
Verified live post-cutover (all four images rebuilt + stamped proto v3, warmpool cleared):
guest hostname == vanity name on cold boot and warm resume; first warm-eligible run reports
snapshot_built:true with the ~4 s build visible in total_ms, next run path:"warm",
resume_ms:56, 406 ms total; bad cwd → isopod-exec: cwd '/no/such/dir': No such file or
directory; --copy-out extracted a 0755 artifact byte-exact; in-guest apk add jq → jq
1.8.1; cmake 4.2.3 + GNU coreutils 9.11 present; base-sqfs boots networked and
warm-resumes (the #19 mask scenario is gone); image ls shows all images proto 3, none
stale. Milestone: the full workspace test suite now runs inside a sandbox — 132/132 core
tests in-guest (GNU cp closed the last host-only gap).
¹ #19's masking is fixed and NIC errors now name slot + tap; the original tap-busy collision itself was never reproduced (static analysis cleared the slot claim, FC restore override, and shutdown ordering) — a live repro attempt is queued for the next gauntlet.
Caveat: the long-lived isopod-mcp server must restart to pick up proto v3 — until then
the MCP tools fail fast against v3 guests (by design, and now pre-boot). Full MCP-side
re-verification + the checklist gauntlet run after the restart.
2026-07-22 (post-restart) — proto-v3 MCP verification gauntlet (the "Next-gauntlet checklist")
The isopod-mcp server was restarted onto proto v3 and the whole never-covered checklist
was run through the MCP tools alone, orchestrated as a phased workflow: a reachability
canary → 8 parallel scenario buckets → an isolated concurrent-same-label commit race → the
queued #19 tap-busy live repro (4 agents) → vm_gc/retention → adversarial verification of
every candidate finding (to kill probe artifacts) → a coverage critic. 49 scenarios, 23
agents, ~843 k tokens, ~17 min. Result: 1 HIGH and 2 LOW confirmed; 2 candidate findings
refuted on verification. Everything else PASS/INFO, including the full F1 re-verification.
-
[fixed] HIGH — forking a stage with ≥10 overlay layers silently breaks and boots on the wrong rootfs. (Fix + live verification: see the 2026-07-23 fix-wave section below.)
sandbox_runhappily commits a 10th layer (exit 0), but forking any depth-≥10 stage boots onto the read-only squashfs base root with all committed layer state invisible, writes failing — and the MCP result returns a normal exit 0 with no error field (a command that happens to exit 0 could evencommit_asa bogus stage). Live repro:gaunt-chain-10fork →/bin/sh: can't create /root/chain/l11: nonexistent directory;/proc/mountsshows/dev/root / squashfs ro(no overlay),/dev/vdb..vdjmounted at/layers/1..9, but/layers/10absent; console:[isopod-agent] overlay: FAILED to assemble stage root (layers=10, upper=drive): mount layer /dev/vdk at /layers/10: No such file or directory (os error 2); continuing on the read-only base root. Root cause (crates/guest-agent/src/overlay.rs:189-193): the layer-mount loop mounts each drive at/layers/<i+1>but nevercreate_dir_alls the mountpoint (unlikeUPPER_DIR/WORK_DIRat:185-186); it relies on mountpoints pre-baked into the base image, which only ship/layers/0..9, so the 10th layer's/layers/10doesn't exist →mount(2)ENOENT. Practical cap is 9 committed layers. The unit tests (overlay.rs:354-355) only assertlayer_mountpoint(1)and(9)— never 10, so the cap was never caught. The silent half (overlay.rs:65, 87-96): an assembly failure is logged to the console only and boot proceeds on the base root ("best-effort by design"); nothing propagates to the host / MCP result. → FIX (two parts): (a) one-liner —std::fs::create_dir_all(&mnt)?;before themountin the:189loop, which removes the pre-baked-mountpoint dependency and lifts the cap entirely (add an integration test forking a 16-layer chain); (b) surface overlay-assembly failure as a run error (or at least aoverlay_degraded: trueflag in the exec result) instead of silently booting on the wrong rootfs — otherwise a broken deep fork masquerades as a healthy exit-0 run. (b) is a design call — making it fatal changes boot behavior — so worth an explicit decision. Requires a guest-agent rebuild + re-stamp of all images and a gauntlet re-run to close. -
[fixed] LOW — env keys are forwarded to the guest
execveenvironment without validation. (Fix + live verification: see the 2026-07-23 fix-wave section below.)env={"FO=O":"bar"}is accepted silently and lands in the guest environ as the ambiguous entryFO=O=bar(any parser reads it asFO="O=bar", not the requested name);env={"":"bar"}lands as a nameless=barentry. Verified at the raw/proc/self/environlevel (not a busyboxenvdisplay quirk). Neither crashes or wedges the guest agent, and later runs are unaffected — POSIX env names must be nonempty and=-free, butexecvedoesn't enforce it and the passthrough is faithful, so this is a minor input-validation gap, not a malfunction. Source:crates/guest-agent/src/exec.rs:84(for (k, v) in &req.env { cmd.env(k, v); }— no key check). → FIX: reject keys matching/=|^$/with a clear pre-boot error (host-side in the MCP/CLI param validation, mirroring thestdin/stdin_file-32602 style), or skip+warn inexec.rs.
Refuted on verification (recorded so they aren't re-raised):
- Commit runs return stage_id/stage_name, not the "documented" commit_id (G5, first
raised LOW) — refuted: the string commit_id exists nowhere in the repo; stage_id +
stage_name are the interface. No drift. (The workflow schema I wrote carried the wrong
field name in from the checklist prose — the verifier caught it.)
- sandbox_run doesn't return the committed stage's vanity name, so callers misreport it
(R2, first raised LOW) — refuted: the result does carry stage_name; both race
agents simply reported vm_name by mistake. The committed name is directly available and
can't be confused with the VM name.
Positive re-verifications (all via MCP, all PASS):
- F1 egress hardening holds after the restart — all 7 RFC1918/link-local probes
(incl. 169.254.169.254) DROP with a full ~3 s socket timeout (no CONNECTED, no fast
ConnectionRefused = pure drop semantics), while public ICMP (1.1.1.1), DNS, and HTTP
(example.com → 200) all work — destination-scoped, not a blanket kill. network=false is
airtight (no guest_ip/slot fields, instant OSError, exec still over vsock).
- #3 nonexistent-command and #18 cwd-error stay closed (structured exit 127, correct
isopod-exec: cwd '…': No such file or directory).
- stderr truncation (first stderr-side probe): 200 000 B → 64 KiB in-band cap,
stderr_truncated=true, stderr_bytes exact, on-disk log complete at 200 000 B.
- dual-stream 8 MB+8 MB interleaved flood (the pipe-deadlock probe): no deadlock —
completed in 176 ms, both streams truncated in-band with exact *_bytes, both logs complete
at 8 000 000 B each.
- stdin: unconsumed 1 MiB stdin_file → clean exit 0 (host writer tolerates EPIPE); 64 KiB
and 1 MiB delivered byte-exact (wc -c = 65536 / 1048576); stdin+stdin_file together and
stdin_file="-" both rejected pre-boot (-32602).
- hostile labels (unicode 😀-café-Ω, ../../../etc/passwd-pwned, 410-char) all safe:
labels are pure metadata, stage dirs are always st-<hex>, nothing written outside
~/.isopod/stages (host-checked). Duplicate labels → clean ambiguity error naming all
candidates (-32603), no silent pick.
- opaque-dir whiteout (rm -rf /data + recreate): only the new file visible, no lower-layer
bleed. cwd into a stage-created / whiteout-recreated dir: works.
- proto-v3 fixes: hostname == vanity name on cold and warm; copy_out byte-exact
(sha match, mode 0755); in-guest apk add jq → jq 1.8.1; cmake 4.2.3 + GNU coreutils cp
9.11; observability fields (path/resume_ms/snapshot_built/commit_ms) all present.
- timeout: exec-timeout → timed_out=true, exit_code=null, signal=9, partial stdout kept;
commit_as on a timed-out run correctly commits NOTHING (the key never-tested
interaction); boot-timeout edge leaks no slot.
- concurrent same-label commit race: store fully consistent — both stages present, all 29
vanity names unique, both forkable with distinct uncontaminated content, metadata survived
the server restart. (Commits landed 2 s apart — batched MCP calls serialize — so a truly
simultaneous index write wasn't forced; behavior nonetheless correct.)
- #19 tap-busy: NOT reproduced across 12 networked base-sqfs runs (cold→warm→warm on
slot 0, egress live each time). Caveat — under-powered: the workflow cap is 2 agents and
each agent's runs were sequential, so the concurrent / crashed-owner tap-reclaim paths
(crates/core/src/net.rs slot allocator) were not exercised. Absence here ≠ absence.
- vm_gc/retention: keep_last=5 keeps exactly the 5 newest ∪ sub-60 s records (the 62 s
record was correctly pruned — the 1-minute grace is a hard cutoff), disk physically freed
(39→5 dirs), no over-prune, and pruned runs' *_log_path become dangling by design
(matches the docstring warning).
Note — a security-heuristic false positive during the run: the R2 verifier was flagged for
stage_rm "solar-psion" "with no evidence it was created this session". Investigated and
cleared: solar-psion was that agent's own throwaway — it ran
commit_as="r2-verify-commit-name-probe" (which returned stage_name=solar-psion) to check
whether the result exposes the stage name, then deleted its own test stage. All 9 pre-existing
stages remained intact; no real stage was lost.
Next-gauntlet checklist (from the coverage critic — genuinely open):
- Post-#26-fix boundary sweep: chains at exactly 9/10/11/16 layers; assert layer-1 content
is visible at layer 16 and writes land. Plus a silent-fallback guard probe that asserts
overlay-assembly failure surfaces as a run ERROR, not exit-0 on the base root.
- cwd into a WHITEOUTED dir (fork a stage where the cwd target was rm -rf'd in a later
layer) — #18-style clean spawn error expected; only the stage-created case was covered.
- Deep opaque-dir whiteout (recreate at layer ~8 through a long lowerdir chain) — blocked by
#26 today.
- Deterministic timeout-DURING-commit (dirty 2–4 GB of incompressible scratch so the
commit genuinely runs multi-second; assert timed_out, no partial/orphan stage, store
integrity). H4 sparsified to ~632 KB so this stayed unobserved.
- Concurrent tap gauntlet: N>slot-count simultaneous networked runs (expect graceful
slot-exhaustion, not EBUSY) and SIGKILL-a-networked-VM-then-relaunch (crashed-owner tap
reclaim) — the actual #19 failure class, still unexercised.
- Warm-vs-cold matrix: record the path field per scenario and force both; the RAM-upper
(warm) vs drive-scratch (cold) overlay paths diverge, and big-write behavior on the RAM upper
(ENOSPC/OOM within mem_mib) is uncovered.
- vm_gc racing a live run: invoke vm_gc keep_last=1 mid-sandbox_run; assert the live
record + its *_log_path survive.
- Regression probes once #26/#27 land (env-name rejection; the deep-chain fix).
2026-07-23 — #26/#27 fix wave (built in-sandbox, verified live)
Root-cause refinement for #26: the intended chain-depth cap was always 10
(stage.rs MAX_CHAIN_DEPTH, derived from Firecracker's virtio-MMIO IRQ slot budget and
enforced at both commit and chain_paths) — the bug was an off-by-one between the baked
mountpoints and the 1-based layer indexing: base images shipped /layers/0..9 while a
depth-10 chain needs /layers/1..10, so exactly the last permitted depth broke. (The
gauntlet's "≥10 layers" phrasing was thus really "the depth-10 boundary"; depths >10 were
always refused loudly by the cap.)
Fixes landed:
- #26a (guest, overlay.rs) — layer mountpoints now live on a tmpfs mounted over
/layers and are create_dir_all'd per layer. The base root is a read-only squashfs, so
the naive create_dir_all fix would have EROFS'd; the tmpfs removes the baked-mountpoint
dependency for any depth the cap permits.
- #26b (proto + guest + host) — overlay-assembly failure is no longer silent: the guest
records it and reports it in every Pong (additive overlay_error field, proto stays v3);
the host's ping() turns it into a fatal AgentError::OverlayDegraded (exactly parallel to
ProtoMismatch), so all readiness paths — run, snapshot build, warm resume — refuse to
proceed on a wrong rootfs instead of returning exit 0. A degraded snapshot can never be
cached. The guest still boots to the base root for serial-log diagnosability (PID 1 must not
die), but no exec is served by a run.
- #27 (host + guest) — env validation at two levels: core::vm::validate_env rejects
empty/=/NUL names and NUL values pre-boot (the shared choke point covering the MCP
map, which parse_env_kv never sees), and the guest agent independently rejects the same
shapes before execve (defense in depth, exit 127 + isopod-exec: stderr).
- Readiness error contexts reworded ("readiness check failed" instead of "did not answer a
ping") so an OverlayDegraded/ProtoMismatch cause isn't wrapped in a misleading message.
- Not landed: a redundant 24-layer "vdz naming ceiling" guard drafted during the fix was
removed on review — MAX_CHAIN_DEPTH = 10 already governs strictly tighter at both commit
and resolve, so the device-naming ceiling is unreachable.
Build: full workspace built + tested inside an isopod sandbox (isopod-build stage,
offline — taps were down, see below; stdin_file source injection + copy_out extraction).
All tests green (135 core + 42 + 39 + guest/proto/cli suites, 0 failures) including new units:
layer_mountpoint(10), Pong overlay_error additive-shape round-trip, host
ping_rejects_degraded_overlay_root, validate_env, guest validate_env_pair. Warmed build
cache committed as stage isopod-build/2026-07-23-fix26. All four images re-stamped (proto 3,
agent 6b7d85db52c3…); stale warm-pool snapshots (old base ids) cleared.
Live verification (new CLI + images):
- Depth matrix at the real boundary: depth 9 fork OK (regression); depth-10 fork of
gaunt-chain-10 now works — all 10 markers visible, writes land, /proc/mounts shows the
10-deep overlay root (lowerdir=/layers/10/upper:…:/layers/1/upper:/); depth-11 commit
refused pre-emptively with the clear MMIO-budget error. (The checklist's "16-layer" probe is
moot — 16 was never bootable by design.)
- MCP path, no server restart needed for the guest side: sandbox_run forking
gaunt-chain-10 through the running server → 10 markers, overlay root, exit 0 (images are
read per run). env={"FO=O":"bar"} → exit 127, invalid environment variable name "FO=O";
env={"":"bar"} → exit 127, name must not be empty (the guest-side defense; host-side
pre-boot rejection activates on the next MCP server restart).
- The silent-fallback guard (#26b) is covered by unit + proto-shape tests; a live
forced-assembly-failure probe would need a deliberately broken image and stays on the
checklist.
Environment notes: WSL2 was restarted since the gauntlet — tap slots are gone, so
networked runs fail with the correct #13 guidance until sudo isopod setup is re-run (all of
the above verified with --no-network; warm-pool paths therefore unexercised this wave). The
long-lived MCP server still runs the pre-fix host code: guest-side fixes are already effective
through it (images re-read per run); validate_env, OverlayDegraded, and the reworded
contexts engage on its next restart (binaries at target/release/{isopod-mcp,isopod-jail} are
already the fixed builds).
2026-07-28 — 0.12.0 dogfood (self-build on HEAD, then the base-skew feature under real use)
The first session against 0.12.0 after the base-skew wave and the adversarial review of it. Two halves: build isopod's own HEAD inside isopod, then use the new machinery the way an operator would — rebuild a base and find out what happens to the store.
The self-build still works, and the tree is green in a clean guest. Forked
isopod-build/2026-07-23-fix26, injected HEAD's source as a tar over
stdin_file: cargo build --workspace 29.91 s; cargo test --workspace
502 passed, 0 failed, 11 ignored across 20 suites, the same totals the host
reports; cargo build --release -p isopod-cli 52.69 s with copy_out streaming
11 466 472 bytes to the host, where the static-pie musl binary ran and read the
real stage store. Both builds ran under default-deny egress: the ledger recorded
exactly the two crates.io flows the build needed, denied: [], nothing else
attempted.
The base-skew feature does what it claims. A stamped two-deep chain, a base
rebuild, and the fork was refused before boot on both surfaces with identical
text, naming the stale ancestor rather than the stage asked for. A pre-0.12.0
unstamped chain booted clean and silent on the rebuilt base — the legacy stages
survive. The rebuilt image layout (no /rom, an empty /layers) mounts and
stacks correctly at depth 2, and the guest builds its mountpoints on the tmpfs as
designed.
What the session found is what surrounds it. One image rebuild sets off a cascade that no command reports:
flowchart TB
R["isopod image build-rootfs --force<br/>or image build-all"] --> ID["new content id<br/>even when the tree did not change"]
ID --> S["every stamped stage on that flavor<br/>now refuses to fork"]
ID --> W["the warm-pool key changes<br/>a fresh 512 MiB snapshot is minted"]
S --> Q1["which stages?<br/>no command answers"]
W --> Q2["the old snapshot<br/>is never retired"]
Q1 --> D["found one refusal at a time"]
Q2 --> D2["3072 of 3584 MiB orphaned on this host"]
-
[open] MEDIUM — no surface derives base staleness, and
stage infoon a stacked tip is a false all-clear.stage listandstage infoserializeStageMetaverbatim (cli/src/main.rs:527,mcp/src/main.rs:958); neither reads an image sidecar, so no listing output can change when an image is rebuilt. Replayingcheck_base_chain_inover this host's store found 3RebuiltBaseamong 41 rows — all 41 printed identically. Worse for a stacked stage: its ownbase_sha256is the current image's while an ancestor's is not, so the record reads clean and the chain does not. Thepubcheck_base_chainwrapper (stage.rs:357) that would serve a listing has zero callers. → FIX: a derivedStageEntrybesideStageMeta(mirroringimage::ImageEntry) carryingbase_state/base_stale/base_reason, computed once from an in-memory index of the listing rather than re-reading each ancestor. Derived, never persisted — a stamped verdict would be a stale on-disk lie at the next rebuild. -
[open] MEDIUM — the rebuild that invalidates stages never looks at the stage store.
build_rootfs(image/rootfs.rs:195) publishes and returns; nothing warns before or after.image build-allis documented as required after aPROTO_VERSIONbump, so the mandatory operation silently makes every stamped stage on the host unforkable. → FIX: after a successful publish, count the stages whose chain references the outgoing id and report them in the result JSON and on stderr. It must never fail the rebuild. -
[fixed] MEDIUM — the squashfs pack is not timestamp-pinned, so a rebuild over an unchanged tree mints a new base identity. Measured: two
build-rootfs --forceruns 4 s apart over an identical tree gave86f20abd…and6398c829…. (Three runs inside the same second gave one id — the stamp has one-second granularity, which is what made this look reproducible at first.) This is the root cause of #28 and #29: most invalidation is spurious.run_mksquashfs(image/rootfs.rs:1280) passes-all-root -noappend -quiet -no-progress; squashfs-tools 4.6.1 already defaults to-reproducibleand accepts-mkfs-time/-all-time. Measured with those pinned: byte-identical output across a 3 s gap with the tree touched in between, and a genuine content change still moved the id. → FIX: pin both. Verify first that epoch-0 mtimes on base files upset nothing that reads them (everything a run writes lands in the overlay upper, not the base), and note thatbase-alpinepulls packages, so its inputs can vary for real — pinning removes the spurious churn, not the genuine kind. Fixed, to 1980-01-01 rather than the epoch so a DOS/ZIP date field can hold it. Two things the finding did not anticipate turned up in the doing. The timestamps a guest might have cared about are not read: Alpine ships hash-based bytecode caches (flags=3, PEP 552), so a pinned mtime cannot invalidate them. And squashfs-tools treats an ambientSOURCE_DATE_EPOCHas competing with the flags rather than as a default — with both present it exitsSOURCE_DATE_EPOCH and command line options can't be used at the same time to set timestamp(s)and builds nothing, so the naive pin would have brokenimage build-rootfsoutright on any host that exports it. The variable is removed from the packer's environment. -
[open] MEDIUM — warm-pool snapshots accumulate and nothing retires an orphan. The key embeds
base_sha256, so a rebuilt base can never resume a stale snapshot — that part is right, andensureself-heals by minting a new one on the first eligible run (measured: 3.87 s to build, 220 ms to resume after). But the one it replaces stays. This host: 7 snapshots, 3.6 GiB, 3072 MiB of it orphaned. The memfile is non-sparse by construction — Firecracker's Full-snapshot dumpset_lens to the full guest RAM and writes every byte, zero pages included.warmpool listcannot tell you which are orphaned,warmpool rmtakes a keyhash or--all,vm gcresolves onlypaths::vms_dir(), and the MCP surface exposes no warm-pool tool at all. → FIX: sweep orphans where the new id is already known —publish_imageruns on every rebuild — or addwarmpool rm --orphaned; and surface anorphanedflag inwarmpool list. -
[fixed] HIGH — two concurrent runs of the same warm shape build into one directory on fixed
.partialnames, with no lock.snapshot::ensurecreatesartifacts.dirand writesvmstate.partial/memfile.partial(snapshot.rs:551); there is noflockanywhere in the module (the one at:429claims a network slot). Two racers both seeis_complete()false, both dump into the same two paths, both rename. The window is the several-second memory dump, and it opens on exactly the shape the product invites — defaultsandbox_runs right after any rebuild empties the pool for every shape, while the tool description tells the model to issue concurrent sandboxes from separate agents. → FIX: an exclusiveflockper keyhash aroundensure, in the shapenet::claim_lockalready uses; second arrival waits and then finds the snapshot complete. Failing that, per-process unique staging names so a loser can only lose its own bytes. FIXED: both halves. An exclusive per-keyhashflock(build.lock, inside the keyhash directory sowarmpool rm— which removes directories and skips plain files — reclaims it) is taken inensurebefore any build. A second arrival waits up to 90 s, notices the moment the winner publishes, and reuses that snapshot instead of building a second one over the top; if the wait expires the run cold-boots, which is what a cache miss does anyway. Staging names carry the pid now, so even with the lock gone a loser can only destroy its own bytes. Tested at the primitive and at the call site:ensuregrew anensure_atseam so the wait-then-reuse path runs without booting a VM or touching the process-global$ISOPOD_HOME, and all three tests fail if theflockis removed. Three mutations. -
[open] MEDIUM — a warm resume that fails never retires the snapshot. The run falls back to a cold boot (
vm/mod.rs:2392) and reportspath: "cold"with nothing to say why. One bad snapshot — from #32, a full disk, a killed builder — poisons that shape permanently: every later run of it pays a failed resume before its cold boot. → FIX: on resume failure, unlink the snapshot'smeta.json, which is the fileis_complete()reads; the next run rebuilds it. Surface the reason in the report. -
[open] MEDIUM — the stage store's label uniqueness is a TOCTOU, and four places say the state is file-locked.
commit_inreads the whole store to check the label (stage.rs:418) and writes later;stage.rscontains no lock of any kind. MeanwhileREADME.md:120and its architecture diagram,docs/getting-started.md:542, andmcp/src/main.rs:678all describe~/.isopodas file-locked for concurrent sessions — true of network slots and the VM registry, not of this. Two agents committing the same label produce two stages carrying it, after which every reference to that label is ambiguous. → FIX: a blocking exclusive lock on a.commit.lockin the store root around the read-decide-write window; and narrow the three prose claims to the state that is actually locked. -
[open] MEDIUM —
commit_asis validated only after the run, and the scratch is deleted either way. Both refusalscommit_incan raise — a label already in use, and a chain atMAX_CHAIN_DEPTH— are computable before boot, but the check runs atvm/mod.rs:1615, after the exec. The failure is deliberately reported rather than thrown (so the exit code, output and logs survive) butcleanup_diskstill removes the scratch unless--keep, andsandbox_runhas nokeepparameter, so on the MCP surface a long build refused at commit time is unrecoverable. → FIX: a pureprecheck_commit_incalled from the preflight block beforeresolve_stage_plan, raising the same three bails while nothing has booted. -
[open] MEDIUM — the depth-cap refusal prescribes
stage flatten, which does not exist.stage.rs:507ends with "flatten the chain first"; there is no such subcommand, MCP tool, or documented procedure anywhere in the repo. An ordinary afternoon of use reached depth 7 of 10 in one lineage, and this store already holdsgaunt-chain-10at exactly the cap — a lineage that can never accept another commit. → FIX: until a flatten operation exists, name a remedy that does (rebuild the lineage from--stage base), in the shape of the sibling refusals in that file. -
[open] LOW — the skew override leaves no trace in the result payload. Both warnings are
eprintln!(vm/mod.rs:1424,stage.rs:496);RunReportand the MCP result carry no warning field, so a run that booted across a rebuilt base returns an ordinary success to a program. The operator who set the variable is the one reading stderr, which is why this is LOW rather than MEDIUM. → FIX: awarnings: Vec<String>on the report, populated from the same strings. -
[open] LOW — the
WrongFlavorremediation is CLI-shaped and cannot apply where that verdict fires.stage.rs:199advises re-running from--stage base --base <flavor>, but a flavor mismatch means the layers belong to a different root; rebuilding on this flavor is not the fix, and the syntax is wrong for the MCP surface that receives the string verbatim. (TheUnverifiablearm'sisopod image build-rootfs --forceadvice is correctly CLI-shaped — that one is an operator action.) → FIX: reword to describe the remedy rather than spell a command. -
[fixed 2148e6c] MEDIUM — the documented way to "rebase" a stage does not rebase. Three files said forking with
ISOPOD_ALLOW_BASE_SKEW=1and committing is how a stage is moved onto a rebuilt image. The check ranks the worst verdict across every link, and the commit stacks a child without touching its ancestors, so the result refuses the next fork exactly as the original did — verified live end to end. The getting-started diagram drew the override landing on the same node as a clean fork; it now loops back to the stage it started from. -
[open] MEDIUM — "a failed setup command never silently produces a broken stage" is false for a pipeline. Happened this session:
cargo build … | tail -25failed, the shell returnedtail's status, andcommit_ascommitted a stage from a broken build. The MCP surface forces/bin/sh -cwith no argv escape hatch, and the adjacent documentation sells pipes. Nothing incrates/coreorcrates/mcpcan detect this — the shell reports what POSIX says it reports. → FIX (docs): say that a pipeline's status is its last command's, and putset -o pipefailin the recipes that pipe. -
[open] LOW — the refusal prints twelve hex digits and no documented command prints them back.
ImageEntry(image/rootfs.rs:519) carriesproto_version/unstamped/stale/built_unixbut dropsmeta.sha256, whichlist_imageshas already read. (build-rootfswithout--forceshort-circuits and reports the sha, which is an undocumented route to it.) → FIX: addsha256toImageEntry. -
[open] LOW — docs that undersell the blast radius. The troubleshooting table prescribes
isopod image build-allfor three symptoms without saying it invalidates every stamped stage;sandbox-build.md's stage-chain table omits the base rebuild from its "rebuild trigger" column; and it calls label reuse "untested" when the store refuses it outright with two distinct errors. Its everyday check/test loop also never says the crates.io cache only survives if youcommit_as— a fresh fork needed the network twice this session for dependencies a previous uncommitted run had already fetched. -
[fixed] LOW — the changelog's "three cases deliberately do not refuse" lists one that always refuses. The third bullet is the flavor mismatch, which is refused in every case including under the override. The 0.12.0 entry also omits the base-image layout change (
/romdropped, ten baked mountpoints replaced by one) that shipped in the same release. -
[open] LOW —
~/.isopod/m0/holds 891 MiB that no command lists or cleans. The M0 spike scratch — logs, spike images, a Firecracker binary. It is not dead:resolve_fc_binstill falls back to~/.isopod/m0/bin/firecrackeras a last resort, so an operator chasing disk pressure who guesses and deletes it removes that fallback. → FIX: a row in the layout table naming it and the condition under which it is safe to delete. -
[open] LOW — an interrupted commit strands a full-size
layer.ext4.partial.commit_instages the layer beside its destination and renames (stage.rs:526); a kill in between leaves up to a full scratch (1 GiB by default) in the largest store on the host, whichlist_inskips silently andstage rmcannot name. → FIX: report the residue fromlist_in's skip branch instead of continuing past it.
Checked and found sound (recorded because each was a live suspicion this
session, and the answer is load-bearing): the warm pool self-heals after a
rebuild rather than going permanently cold — the first probe's cold path was
caused by --no-network, which disqualifies a run from the warm shape, not by
the rebuild. A stale snapshot can never be resumed onto a new base, because
base_sha256 is a keyed field. publish_image deliberately clears the old
sidecar before the rename, so a failed stamp never leaves an old sidecar
vouching for new bytes. stage rm of a mid-chain stage leaves nothing behind
because it refuses, naming every dependent — removing this session's three probe
stages took a leaf-first walk, which is the documented behaviour and not a gap.
The unstamped-stage exemption and the whole-chain check both behave exactly as
getting-started.md describes. sandbox_run's env is guest environment and
cannot reach the host override. dns_queries: [] is correct in filtered mode:
the guest resolves nothing, because it is handed a proxy and the broker resolves
host-side. And the MCP host-I/O root confining stdin_file/copy_out to the
server's working directory is deliberate and documented — payloads went under
target/, which is gitignored.
Store state at the end: 38 stages (3 stamped, all the isopod-build/0.12.0*
chain on base-alpine), 6.9 GiB allocated; base.sqfs rebuilt several times in
the course of the measurements and now 6398c829…; base-alpine.sqfs untouched,
so it still ships the pre-0.12.0 /rom + /layers/0..9 layout — harmless, since
the guest mounts a tmpfs over /layers, but it should be rebuilt for consistency
once #30 lands, so the rebuild costs nothing.
2026-07-28 (later) — a test-fix wave verified in-sandbox, and the ceiling that found
The work was a one-commit fix to screen_resolved's test (a refusal that fired
correctly could name the wrong address and the suite stayed green). Everything
that verifies it ran inside isopod, which is the point of this entry: the
negative control is deliberately broken source, and running that on the host
means editing the working tree and trusting a sed to put it back.
The verification loop works and is fast. Forked isopod-build/0.12.0-tested,
overlaid HEAD's source as a tar over stdin_file: cargo test -p
isopod-oci-registry 31 passed in 28.8 s wall including the incremental
rebuild. Then the control in a second throwaway VM — same fork, sed the
mutation in, 30 passed / 1 failed, naming the intended test. Host tree never
touched. Then the whole mutation harness in a third: git init over the overlaid
source is enough for git archive HEAD, and scripts/mutation-check.py --only
oci-registry-refusal-names-the-wrong-address reported 1/1 mutations caught
in 155 s from cold. A destructive-by-design harness runs unsupervised in a
sandbox, which is the case it was written for.
What it cost to get there is the finding. The bytes-in channel is the narrow one:
flowchart TB
HF["host file"] -->|"PutFile: base64 inside one JSON frame"| CAP["MAX_FRAME_LEN 8 MiB<br/>so about 6 MiB of raw input"]
CAP --> MCP["MCP: refuses at 4 MiB, before boot,<br/>naming the limit"]
CAP --> CLI["CLI: no check at all —<br/>boots a VM, then dies at the frame layer"]
GF["guest file"] -->|"CopyOut: FileChunk x N, then FileDone"| NOCAP["streamed since proto v3,<br/>no ceiling"]
NOCAP --> HP["host path, 16 GiB per file"]
-
[open] MEDIUM — the host→guest file channel is single-frame and capped while guest→host is streamed and unbounded. Proto v3 gave
CopyOuta streamed channel (FileChunk× N +FileDone) explicitly so artifacts out have "noMAX_FRAME_LENsize ceiling" (proto/src/lib.rs:45). The reverse direction never got the same treatment:PutFileis documented "single-frame; fits withinMAX_FRAME_LEN" (proto/src/msg.rs:58), and since binary rides base64 inside the JSON, an 8 MiB frame cap is a ~6 MiB raw input ceiling. Measured: a 50 995 061-byte tarball produced a 67 993 510-byte frame, exactly the 4/3 inflation plus JSON. That is what stopped this session putting the repo with its.git(49 MiB) into a guest, somutation-check.py— which is built aroundgit archive HEAD— had to be handed a synthesized single-commit repo instead of the real history. Nothing above ~6 MiB gets in without the network: no dataset, no wheelhouse, no repo with history. → FIX: mirror v3'sCopyOutwith a streamedPutFile(chunks + done). Until then the MCP's own error advises "Copy it into a stage instead", which names no command isopod has — the only honest workaround is its second half, splitting the payload across runs andcommit_as-ing between them. -
[open] LOW — the CLI boots a VM before discovering the payload will not fit, and reports the failure in post-base64 bytes.
crates/cli/src/main.rs:506reads--stdin-filewith an unboundedstd::fs::read; the MCP path checks againstMAX_STDIN_FILE_BYTESfirst (mcp/src/main.rs:84). So the same oversized file is a clean pre-boot refusal naming the limit on one surface, and on the other: a booted VM, a wasted boot, andframe length 67993510 exceeds cap 8388608— printed twice, from an error chain that formats its own source. Neither number is one the operator chose; they passed a 49 MiB file and are told about 67 993 510 bytes. → FIX: give the CLI the same pre-flight check and message, expressed in raw input bytes. -
[open] LOW —
docs/sandbox-build.mdbase64s a payload the channel already carries raw, spending a third of a ceiling it never mentions. The "Getting source in" recipe pipes the tar throughbase64 -w0and decodes it guest-side. Unnecessary on both surfaces — binary is base64'd inside the frame either way, so doing it again first is pure inflation, and it moves the effective ceiling from ~6 MiB down to ~4.5 MiB. Verified raw both ways this session:sandbox_runwithstdin_fileand a plain.tgz(31/31 tests green in-guest), andisopod run --stdin-filewith the same file, 644 ms total. → FIX: dropbase64from the recipe and state the ceiling next to it.
This entry's own diagram was proved the same way. Rendering every diagram in the
built site needs jsdom, mermaid, python-markdown and pygments, none of
them installed on this host and none of them wanted there; a sandbox took the
install and the render and was thrown away — 29 diagrams checked, 0 failures,
0 tag warnings, site built with no missing sources and no unresolved links. It
is the smallest possible version of the argument for the whole tool: a check that
would otherwise not have been run, because running it meant polluting a laptop.
Checked and found sound: stdin_file is binary-safe on both the MCP and CLI
surfaces — the doc's encoding step was defensive, not required. The host-I/O root
confinement refused a scratchpad path outside the server's working directory —
deliberate, and already recorded as sound by the previous session; payloads went
under target/ again. Forking the same stage three times concurrently-in-sequence left
the parent untouched, and none of the three throwaway VMs was committed, so the
store gained nothing from a session that ran a full mutation harness inside it.
2026-07-29 — 0.12.2 verified in-sandbox, and a guest that cannot talk to itself
The wave-closing bump had to be run on the exact tree being tagged. The host's
taps were gone — WSL2 had restarted, which destroys them — so the only way to
boot at all was network: false. That is where this entry starts, because the
suite came back 18 failed, and all 18 were broker tests.
A uniform failure is a harness result until something proves otherwise, and the control was already in the same output: 363 tests passed beside the 18, and those same 18 are green on CI. The cause is not the code:
flowchart LR
NF["sandbox_run with network false"] --> NIC["no NIC attached — intended"]
NF --> LO["lo left DOWN — not intended"]
LO --> B["bind on 127.0.0.1 SUCCEEDS<br/>a bind does not need the link up"]
LO --> C["connect on 127.0.0.1 reaches nothing"]
B --> T["a test that binds, then dials itself,<br/>fails far from the actual fault"]
C --> T
-
[fixed in 0.12.3] MEDIUM —
network: falseleaves the loopback interface DOWN, so a guest cannot talk to itself. Booted withnetwork: false, the guest has exactly one interface and it is down:1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN. The failure mode is bad because it is partial —bind()on127.0.0.1succeeds, since binding never required the link to be up, so a workload gets a socket and a port number and only fails later when something tries to reach it. Measured against the workspace suite:network: falsegives isopod-core 363 passed / 18 failed, every failure a broker test that listens and then dials itself; a singleip link set lo upfirst gives 381 passed / 0 failed, and the whole workspace goes to 604 passed / 0 failed / 14 ignored. The flag's documentation says it runs code "with no network at all", which an operator reads as no egress — loopback is not egress, it is the guest's own plumbing, and a test suite, a local server, or a database that binds a port all need it. → FIX: bringloup unconditionally at guest-agent start. It costs nothing on a networked run and removes a class of failure that presents as the workload's bug rather than the sandbox's. -
[open] LOW — the bare
isopod-buildstage is older than the dependency set, so the label the docs tell you to fork no longer builds offline.docs/sandbox-build.mdsays every build "forksisopod-build". That stage was committed beforeflate2entered the tree, so its crates.io cache does not contain it, and an offline build there dies withno matching package named flate2 found— a resolution error that reads like a broken lockfile rather than a stale cache. The versioned siblings are fine:isopod-build/0.12.0-testedcarries the current dependency set and ran the whole suite offline. → FIX: the doc now names the newestisopod-build/*stage rather than the bare label, andstage_listis the way to find it. The deeper issue is that a mutable-looking label pinned to an immutable stage silently rots; label-reuse semantics are still untested (noted indocs/sandbox-build.md). -
[fixed in 0.14.0] HIGH — a coexisting Docker install silently breaks all NAT egress, because its
FORWARDpolicy drops every guest packet. Found by running isopod in CI, and it is not a CI problem: any host with Docker on it is affected. Docker sets the iptablesip filterFORWARD chain policy to DROP and jumps to aDOCKER-USERchain that, by default, contains onlyRETURN— so guest→WAN traffic falls straight through to the drop.isopod setupreports complete success: 12 taps, aninet isopodtable,ip_forward=1, and the guest gets an address. Then nothing works, and the error the workload sees is whatever its own first network call happens to be. Measured on a hosted runner: a guest handed a literal IP — no DNS anywhere in the path — could not open TLS to it, while the host reached both1.1.1.1and8.8.8.8successfully. Host traffic goes through OUTPUT; guest traffic goes through FORWARD, and only one of those is dropped.The failure mode is bad in a specific way: it is invisible to isopod. Nothing in
setuplooks at whether another tool has already claimed the forward hook, so every diagnostic isopod prints says the network is fine. The first symptom is a DNS timeout inside a guest, which reads as a resolver problem — and did: this was diagnosed as a resolver bug first, confidently and wrongly, before the literal-IP control ruled DNS out.Reproduced and settled in throwaway network namespaces (
scripts/diagnose-forward-hook.sh), three namespaces wired client → router → server so traffic is genuinely forwarded both ways:case result plain forwarding (harness control) REACHED Docker shape: FORWARDpolicy DROPBLOCKED + isopod ACCEPTinDOCKER-USERREACHED isopod's own nft DROP alone BLOCKED + isopod's nft DROP on top of the ACCEPT BLOCKED The last row is the one that decides whether the fix is allowed to exist. An
ACCEPTverdict ends traversal of that base chain only; every other base chain registered at the hook still runs. So inserting an accept rule into Docker's chain removes Docker's drop without bypassing isopod's own egress enforcement, which stays authoritative. → FIX:isopod setupinserts an accept rule forisopod-tap*intoDOCKER-USERwhen that chain exists — two rules in fact, because a single inbound accept leaves the reply to die on the same policy DROP, so not even a handshake completes.setupnow reports adocker_userfield so the answer is visible instead of inferred from whether the network happens to work, and--no-docker-useropts out for anyone curating that chain themselves.Known residue, documented rather than fixed: Docker offers no persistence contract for DOCKER-USER, and a daemon restart or network creation may flush or reorder it. The failure is fail-closed — guest egress stops, nothing opens — and the remedy is re-running
sudo isopod setup, which is the same doctrine already published for a flushed nftables ruleset. -
[fixed in 0.18.0] HIGH — every submount of a read-only jail bind was writable. The jail binds
~/.isopodread-only, and that tree holds the stage store and the guest images.bind_mountusesMS_REC, so the bind carries submounts with it — butMS_REMOUNT | MS_RDONLYapplies to exactly one mount, so every submount stayed writable. Measured in a disposable guest rather than reasoned about: bind a tree containing a tmpfs, remount the top read-only, and a write to the top is refused while a write to the submount succeeds and the file's contents change.write to the top of a read-only bind refused write to a submount of it succeeded A submount under
~/.isopodis not exotic — a separate disk for images, a tmpfs, an encrypted volume. The jail exists to contain a compromised Firecracker, and that process could have written to a stage layer every later run forks. → FIX:remount_readonly_recursiveusesmount_setattr(2)withAT_RECURSIVE— the kernel walks the tree itself, in one atomic call — and falls back on a pre-5.12 kernel to reading/proc/self/mountinfoand remounting every mount at or beneath the target, deepest first so a parent cannot shadow a child. Fails closed either way. See #53 for what the first version of that fallback got wrong.Found because the jail's syscall layer had no unit tests at all — 21
unsafeblocks covered only by one#[ignore]d integration test that, until the day before, ran nowhere but a maintainer's laptop. The parser is now pure and unit-tested, including the two ways it can be subtly wrong: matching on raw prefix (which would remount unrelated host mounts read-only) and comparing mountinfo's octal-escaped paths without decoding them (which would miss a submount under any directory with a space in its name). -
[fixed in 0.18.0] HIGH — the fix for #52 could not start a jail on a host whose mount table held a
nosuid/nodev/noexecmount. Caught by the live suite on the branch — not by review, not by the local gate, and not by the eight unit tests written for #52, every one of which passed.isopod-jail: child setup failed: remount <root>/ read-only: remounting <root>/proc/sys/fs/binfmt_misc read-only: Operation not permittedA mount inherited into a new user namespace has its
nosuid,nodevandnoexecbits locked (mount_namespaces(7)). A remount that does not name them again reads as an attempt to clear them, and the kernel answersEPERM. The top-level bind remounts fine because the jail created it and it carries no locked bits — which is exactly why this passed on a maintainer's laptop and failed on a hosted runner. It is a property of the host's mount table, not of the code.Measured in a nested user namespace rather than reasoned about:
remount of a nosuid,nodev,noexecsubmountresult MS_RDONLYaloneEPERMMS_RDONLY+ the three flags passed backok, and the mount is ro→ FIX:
mount_setattr(2)withAT_RECURSIVEonly ever addsMOUNT_ATTR_RDONLYand clears nothing, so it cannot engage the rule at all. Atime needs no help either way —mount(2)preserves it for a remount that names no atime flag, so repeating it could only add a way to get it wrong.The pre-5.12 fallback then failed the same way a second time, on the same mount, for a different reason — and the second reason is the interesting one. Reading each mount's flags out of its mountinfo line looks obviously correct and is not: two mounts can share one mount point, and a remount by path reaches only the topmost.
/proc/sys/fs/binfmt_miscis exactly that shape on a hosted runner — a systemd autofs with the real filesystem mounted over it — so the walk applied the lower mount's flags to the upper mount:/tmp/dst/sub rw,relatime keep=0 <- lower /tmp/dst/sub rw,nosuid,nodev,noexec,relatime keep=14 <- topmost remount /tmp/dst/sub keep=0 -> EPERM remount /tmp/dst/sub keep=14 -> okThe fallback now asks
statvfs(2)for the flags instead. It resolves a path exactly asmount(2)does, so it always describes the mount about to be remounted — the correlation problem cannot arise. mountinfo is left to do the one job it can do reliably: enumerate which paths exist.Reproduced locally by stacking two tmpfs mounts at one path inside a user namespace and running the real jail binary against it: the committed code fails at that path, the fix passes.
Then the fallback was deleted. Three rounds, three defects, every one of them found on a hosted runner and none on a developer's machine — because whether the walk fails is a property of the host's mount table, not of the code. It dropped locked flags; it read flags off the wrong line where two mounts share a mount point; and it could never have reached a shadowed mount at all.
mount_setattr(2)was correct on the first attempt and structurally cannot hit any of the three. The jail now requires Linux 5.12 and refuses to start below it, naming the requirement, this host's kernel, and the fact that droppingISOPOD_JAIL=1starts an unjailed VM. The jail is opt-in, so this floor applies to nothing else.The lesson worth keeping is not about mounts. A second implementation of a security boundary, on a path that only runs where nobody tests, is a liability priced as a feature — it accumulates defects at full rate and reveals them at the rate the untested hosts are exercised.
The fallback is now forced by an environment variable in the live probe, so both paths run on every host that can run the probe at all, instead of the older one running only on machines nobody tests on. The probe also gained the assertion #52 never had: that a submount of the read-only bind is read-only (
/dev/shm, which is both a tmpfs under/andnosuid,nodev, so it covers the recursion and the locked-flag rule at once). Both mechanisms were then broken on purpose to confirm the probe fails — dropping the flags reproduces theEPERMabove, and droppingAT_RECURSIVEreproduces #52's writable hole.
Checked and found sound: the tap teardown was reported exactly right — the
error named WSL2 as the likely cause, named sudo isopod setup as the fix, and
offered --no-network as the alternative, which is what made the offline route
obvious. Forking isopod-build/0.12.0-tested three times left the parent
untouched and nothing was committed, so a session that ran the full suite twice
and a probe VM added nothing to the store.
Rendered from docs/dogfood-findings.md on the main branch.