Skip to content

Releases: me1iissa/isopod

v0.20.0

Choose a tag to compare

@github-actions github-actions released this 03 Aug 16:59
6e5010e

[0.20.0] — 2026-08-03

Two findings from a competitive audit against another Firecracker sandbox, both
against isopod rather than the competitor. The audit's headline was that isopod
escapes the identical-entropy-after-fork bug that sandbox ships — but only by
a property it inherits and never asserted, and one that would disappear silently.

Added — the guest kernel must be able to reseed after a fork

isopod's warm pool resumes one memory image many times. What stops those
sandboxes sharing a CSPRNG is not isopod's code — there is none — but
Firecracker's VMGenID device plus a CONFIG_VMGENID guest kernel, which reseeds
when the generation counter changes. Measured across three warm resumes of a
five-day-old snapshot: boot_id, /dev/urandom, stack ASLR, and Python's
random, os.urandom and ssl.RAND_bytes all differed.

The problem was that nothing said so. A kernel without the option would resume
every warm sandbox with the CSPRNG state frozen into the snapshot, every resume
would still succeed and no test would fail
— and fetch-kernel --allow-unpinned
selects whatever the CI bucket currently offers, so this was a live path.

fetch-kernel now refuses to install a kernel with no VMGenID reseed path, naming
CONFIG_VMGENID and the consequence. A live test asserts it for every installed
kernel and carries a control that must be rejected — a substring search over a
40 MB binary is exactly the shape that quietly matches everything. SECURITY.md
gains a Warm resume section stating what one memory image resumed many times
does and does not share.

Fixed — a deep $ISOPOD_HOME failed as an unexplained ten-second timeout

A Unix socket path cannot exceed 107 bytes, and isopod puts Firecracker's API
socket and the guest-agent vsock inside $ISOPOD_HOME/vms/<vm-id>/. Past that
depth bind fails inside Firecracker, the process exits 1, and isopod waited the
full ten seconds for a socket that would never appear before reporting a
timeout that named the path but not the reason:

Firecracker API socket …/vms/dev-96965fa0/api.sock did not become ready within
10000 ms (process exited: exit status: 1)

Nothing in that says "your home directory is too long", and the run costs ten
seconds to find out. Both spawn paths now check before creating anything and
refuse in 6 ms with the length, the limit, and how many bytes to remove:

the VM directory … is too deep for a Unix socket: its vsock path is 170 bytes and
the kernel's limit is 107 … Shorten $ISOPOD_HOME by at least 63 bytes (it is
currently 142 bytes) or leave it unset to use ~/.isopod

The limit was measured rather than read off a header — binding at successively
deeper paths fails at exactly 108 — and the test asserts both sides of the
boundary. Found by the competitive audit's dogfood pass.

Added — snapshots are integrity-checked before they are resumed

Stages were content-addressed with blake3; snapshots recorded sizes and nothing
else, and the resume path checked only that three files existed. On the unjailed
default path, anything able to write ~/.isopod/snapshots/*/memfile had code
execution in every later warm run.

meta.json now records a blake3 of both artifacts. vmstate — which carries the
vCPU register state the guest resumes at — is digested in full on every resume.
The memory file is checked for identity rather than content, and the reason is
measured, not assumed: blake3 runs at 1.59 GiB/s here, so digesting a 512 MiB
memory file costs 315 ms and a 3 GiB one about 1.9 s, against a ~49 ms
resume and the ~410 ms cold boot that resume exists to beat. Verifying every byte
on the hot path would make the warm path slower than the path it replaces.

ISOPOD_VERIFY_SNAPSHOT=1 digests both files in full for anyone who wants to pay
that. The honest boundary is asserted rather than described: one test proves a
restored mtime defeats the fast check while the full check still catches it.

Verification failures are not run failures. An unverifiable or legacy
snapshot is rebuilt and the run cold-boots, so the fail-closed direction costs one
cold boot rather than an error.

v0.19.0

Choose a tag to compare

@github-actions github-actions released this 03 Aug 14:54
54cbc1b

[0.19.0] — 2026-08-03

Releases 0.15.0 through 0.18.0 were never tagged or published. They landed
on main as steps of one piece of work and are kept below as the record of what
changed when. This is the version that ships them, so upgrading from 0.14.0
means taking all of it
— read those sections too, they describe behaviour in
this release.

0.18.0 in particular shipped a fallback that let the jail run on kernels older
than 5.12. It is removed here, and because 0.18.0 was never published, no
released version ever offered it.

Changed — the rootless jail requires Linux 5.12 (ISOPOD_JAIL=1 only)

Breaking for jail users on older kernels; nothing else changes. The jail is
opt-in, so isopod itself gains no kernel floor — an unjailed run is unaffected,
and a host below 5.12 keeps everything except the optional second isolation layer.

mount_setattr(2) is the only mechanism that makes a bind read-only including
every mount beneath it and actually holds: the kernel walks the tree in one call,
so nothing can appear between reading a mount table and acting on it; it adds
MOUNT_ATTR_RDONLY and clears nothing, so it cannot trip the rule that a
nosuid/nodev/noexec bit locked into a user namespace may not be cleared; and
it reaches a mount that another mount is stacked over, which nothing driven by
path can do.

0.18.0 shipped a hand-rolled walk over /proc/self/mountinfo for older kernels.
It is removed. It failed all three ways above, in three successive rounds, each
one found only by the live suite on a hosted runner and never on a developer's
machine
— because whether it fails at all is a property of the host's mount
table, not of the code. It dropped the locked flags and earned EPERM; it then
read flags off the wrong mountinfo line where two mounts share a mount point
(/proc/sys/fs/binfmt_misc is a systemd autofs with the real filesystem mounted
over it) and earned the same EPERM again; and it could never have reached a
shadowed mount at all, since a remount by path only ever finds the topmost.

A second implementation of a security boundary, on a path that only runs where
nobody tests, accumulates defects at full rate and reveals them at the rate the
untested hosts are exercised. It is worth less than the hosts it buys. Dogfood
finding #53.

On an older kernel the jail now refuses to start, naming the requirement, this
host's kernel release, and the fact that dropping ISOPOD_JAIL=1 starts an
unjailed VM.

Changed — the jail's tests follow the code they protected

The mountinfo parser and its unit tests are gone with the fallback they served.
What remains is tested where the guarantee now lives: the live probe's assertion
that a submount of a read-only bind is read-only, and the unsupported-kernel
path — both the message and, separately, the decision to use it. That second test
exists because a mutation proved the first was not enough: the message was
covered, the call site was not, so deleting the call left every test passing.

v0.14.0

Choose a tag to compare

@github-actions github-actions released this 30 Jul 13:02

[0.14.0] — 2026-07-30

Fixed — a coexisting Docker install silently swallowed all guest egress

Docker sets the iptables ip filter FORWARD policy to DROP and jumps to a
DOCKER-USER chain containing only RETURN, so every guest→WAN packet fell
through to that drop. Any host running Docker had broken NAT egress, and
nothing said so:
isopod setup reported complete success throughout — taps
created, nft table installed, ip_forward=1, guest addressed — because nothing
in setup looked at whether another tool had already claimed the forward hook.
Dogfood finding #51.

The first symptom is a timeout inside a guest, usually a DNS lookup, which reads
as a resolver problem. It read as one here, and was diagnosed as one — a
hardcoded-public-resolver bug — confidently and wrongly, until a guest handed a
literal IP with no DNS anywhere in the path failed too, while the host reached
both 1.1.1.1 and 8.8.8.8. Host traffic goes through OUTPUT and guest traffic
through FORWARD; only one of those was dropped.

setup now inserts two accept rules into DOCKER-USER when that chain exists.
Two, not one: the reply arrives on the WAN interface and dies on the same policy
DROP, so a single inbound accept never completes a TCP handshake.

Why this cannot weaken the sandbox. Per nft(8), an accept verdict ends
evaluation of the current base chain and the packet advances to the next base
chain, whereas a drop ends the whole ruleset. inet isopod's forward chain is a
separate base chain at the same hook, so accepting in Docker's table removes
Docker's drop and none of isopod's — tap↔tap isolation, anti-spoof, the IPv6
deny, the RFC1918 guard, the filtered-slot drop and the closing
iifname "isopod-tap*" drop default-deny all still apply. Measured in throwaway
network namespaces rather than assumed: with the accepts live, a drop in a
separate inet chain still blocked the connection and its counter showed the
packets arriving. The rules are accept-only and scoped to isopod's own taps and
its own 10.107.0.0/16.

Docker publishes no persistence contract for that chain, so a daemon restart or
a network creation may flush it. That is fail-closed — egress stops, nothing
opens — and the remedy is re-running sudo isopod setup, the same doctrine
already published for a flushed nftables ruleset.

Added — setup reports what it did about the forward hook

isopod setup's JSON gains docker_user: installed, already-present,
chain-absent, iptables-missing, lock-busy, skipped or removed. The
failure this addresses is invisible to every other field in that report, so the
answer is stated rather than left to be inferred from whether the network happens
to work. A lock timeout reports lock-busy and not chain-absent, because
conflating them would read as "nothing to do" on precisely the busy Docker hosts
where there is most to do.

--no-docker-user declines the mechanism for anyone curating that chain
themselves. A kernel without xt_comment falls back to unmarked rules rather
than failing a setup that succeeds today, and teardown matches both spellings
so neither can orphan.

v0.13.2

Choose a tag to compare

@github-actions github-actions released this 29 Jul 17:53

[0.13.2] — 2026-07-29

Fixed — a tag on the wrong commit published packages declaring the wrong version

v0.13.1 was created with a bare git tag -a v0.13.1, which tags whatever HEAD
happens to be. Work at the time spanned several git worktrees and the primary
checkout was on another branch, so the tag landed on v0.13.0's commit. The
release built from that tree and published isopod_0.13.0-1_amd64.deb and
isopod-0.13.0-1.x86_64.rpm inside a tarball named isopod-0.13.1. A binary
that reports a version it is not is a support problem, so the release and the
tag were deleted and re-cut at the commit carrying the bump.

The Version guard was right and was disbelieved. It failed four consecutive
pushes with "code changed since v0.13.1 but the workspace version was not
bumped", which was true — it diffs against the tag's commit, and the tag was a
commit early. The message sent the reader looking for a missing bump that had
already been made. The guard now checks tag placement directly: if the workspace
version is already tagged, the tagged commit's own Cargo.toml must declare that
version, and the failure names the offending sha and what it actually declares.

Added — scripts/bump-version.py and a release skill, so this is mechanical

Three things the guard enforces were being done by hand and by memory: the
workspace version and .claude-plugin/plugin.json moving together, the version
going up by one step of the right level, and the tag landing on the commit that
carries the bump.

scripts/bump-version.py patch|minor|major performs the first two and refreshes
Cargo.lock. --tag performs the third: it refuses unless HEAD's own
Cargo.toml declares the version being tagged, passes an explicit sha rather
than relying on HEAD, and re-reads the tag afterwards to confirm where it
landed. --check verifies the whole state, including tag placement, and is what
to run first when the guard goes red.

A pre-release resolves to its own release rather than stepping past it —
0.13.0-preview.1 --patch gives 0.13.0, not 0.13.1 — because the preview
line depends on that and anything else skips the version it was previewing.

The release skill in .claude/skills/ documents the sequence, which level to
pick, and the two commands that diagnose a red Version guard. What neither buys:
the script does not commit, does not push, and does not write the changelog
entry — those stay deliberate.

v0.13.1

Choose a tag to compare

@github-actions github-actions released this 29 Jul 17:50

[0.13.1] — 2026-07-29

Fixed — 0.13.0 shipped unformatted

One Attr::Bucket call in the new copy-out instrumentation exceeded the line
width, so cargo fmt --check failed on the release commit. The tag built and
released green — Release and CI are separate workflows, and formatting has
no bearing on a binary — but main was red from the moment 0.13.0 landed.

The gap was procedural: the branch was rebased onto a main that had moved, and
the merge was verified with clippy and the test suite but not with
cargo fmt --check. Rebasing reflows nothing by itself; the offending line was
inside a hunk the rebase carried across, and only the formatter's line-width
rule noticed.

v0.13.0

Choose a tag to compare

@github-actions github-actions released this 29 Jul 16:25

[0.13.0] — 2026-07-29

Added — spans over the phases nobody could see, and six report fields to match

Internal tracing instrumentation over the run path, and six additive
RunReport fields: boot_ms, teardown_ms, copy_out_ms,
snapshot_build_ms, and the split of the existing commit_ms into
commit_hash_ms + commit_copy_ms. Each field is absent when its phase did
not happen, so every existing consumer sees the exact JSON it always did.
resume_ms and commit_ms themselves are unchanged.

What the numbers showed once they existed:

  • Teardown costs ~140 ms on every run — 27% of a 509 ms cold run, second
    only to the kernel boot — and had been folded into a "boot" figure nobody
    computed directly. Of ~470 ms non-exec time the kernel boot proper is
    ~210 ms: the number an operator would have read as boot was about twice the
    real thing.
  • Copy-out moves ~52 MB/s over the base64-in-JSON vsock path, and runs
    after the timeout_s budget has stopped protecting the caller.
  • commit_ms is now split, not diagnosed. The hash and copy passes are
    separately visible; on real commits they are the same order of magnitude,
    with end-to-end time dominated by writeback of the just-written scratch (see
    0.12.5 below for the measured numbers and the buffered-read fix they led to).

What this does NOT buy: it is instrumentation, not telemetry. There is no
exporter, no network path, and no opentelemetry dependency in any build.
With RUST_LOG unset the binaries install no subscriber and write zero bytes;
RUST_LOG=isopod=debug prints the spans to stderr and nowhere else. Span
attributes pass through a sealed Attr type whose only string carriers are
&'static str and the host-minted vm_id, and guest-influenced magnitudes
appear only as log2 buckets. exec_ms still folds vsock output streaming into
compute time. The two spans written blind while the host's taps were down —
isopod.run.snapshot_ensure and the warm isopod.run.resume — have since run
live: a first-warm run reported snapshot_build_ms: 4238 / resume_ms: 72
with both spans on stderr, and a second run of the same shape reported
resume_ms: 61 with snapshot_build_ms correctly absent.

v0.12.5

Choose a tag to compare

@github-actions github-actions released this 29 Jul 15:46

[0.12.5] — 2026-07-29

Fixed — the commit hash pass read a gigabyte 8 KiB at a time

stage_id_for streamed layer.ext4 through std::io::copy, whose stack buffer
is 8 KiB: 131 072 read() calls per apparent GiB, each also zero-filling its
slice wherever the sparse scratch has a hole. It now reads through a 4 MiB heap
buffer — 256 reads per GiB — retrying EINTR as io::copy did.

The measurement that motivated this did not reproduce, and the claim is
corrected rather than repeated.
One instrumented run showed the hash pass at
10.81 s against 0.38 s for the sparse copy of the same file, and that state did
not appear again. Real end-to-end commits of the same shape ran 987/881/1018 ms
before and 923/888/992 ms after: indistinguishable, and dominated by writeback
of the scratch that had just been written.

What survives measurement, on a real committed layer of 1 GiB apparent and
64 MiB allocated, warm cache, isolated pass: 0.90 s before, 0.47 s after.
So the claim is 1.9× on the pass, plus a 512× cut in read() calls — which is
what bounds the pathological case. Per-call overhead has only to reach ~80 µs
for the old loop to cost 10 s/GiB, where the new loop stays under a second.
That is worth having on a contended machine even though today's commits do not
show it.

The digest does not change, and that is the whole constraint. Stage ids are
content-addressed, and the id is BLAKE3 over the file's full apparent bytes,
holes included as the zeros they read back as. Skipping the holes would have
been faster again — and would have silently re-identified every stage in every
existing store, breaking forks with no error anywhere. A new test keeps the old
std::io::copy implementation in place as the definition and asserts the
buffered pass is byte-identical to it, over a fixture shaped for every loop
boundary and over a real make_scratch_ext4 image.

A plain revert to io::copy is invisible to any test by design, since identical
bytes is the contract; so mutation stage-hash-feeds-the-whole-buffer breaks
the seam the loop actually has — feeding the hasher its whole buffer rather than
the bytes read — and two tests catch it from different directions.

blake3's mmap and rayon features cut the isolated pass a further ~4×,
measured, and are declined: six crates and a thread pool in a sandbox tool, none
of them in the guest build stage's cargo cache, so offline in-guest builds would
stop working. The stake is ~0.35 s per apparent GiB that writeback absorbs
today.

v0.12.4

Choose a tag to compare

@github-actions github-actions released this 29 Jul 15:46

[0.12.4] — 2026-07-29

Security — the S3 XML parser carried two denial-of-service defects in its dependency

quick-xml 0.39.4 is subject to two RustSec advisories, and isopod's one use
of it — quick_xml::de::from_str parsing S3 ListObjectsV2 responses during
kernel selection (crates/core/src/image/s3.rs) — reaches both defective
paths. That was established by reading the 0.39.4 source, not by assuming the
serde surface was insulated:

  • RUSTSEC-2026-0194, quadratic attribute duplicate checking. The serde
    deserializer probes every start tag for xsi:nil through the default,
    checks-on attribute iterator, which compares each attribute name against
    every previous one in the same tag — O(N²), pure computation, so no I/O
    timeout on the consumer can interrupt it. A single crafted tag with enough
    attributes stalls the parse for minutes.
  • RUSTSEC-2026-0195, unbounded namespace allocation. de::from_str is
    built on NsReader, which copies every xmlns declaration into resolver
    heap before the consumer ever sees the event; a crafted start tag forces
    allocations at a multiple of its own size, with no cap and no knob to add
    one.

What reachable means here, and what it does not: the only bytes that parser
ever sees come from https://s3.amazonaws.com/spec.ccfc.min — Firecracker's
public kernel bucket — over TLS, on the blocking one-shot image import path.
Exploiting either defect requires that endpoint, or the TLS path to it, to
turn hostile, and the blast radius is a hung or OOM-killed isopod image
command in the operator's terminal. No sandbox, no guest, and no long-lived
process parses this XML. This was a real defect in a parser of remote input,
not a reachable compromise of anything isopod isolates.

quick-xml moves to 0.41.0, which fixes both: a hash pre-filter replaces the
quadratic scan, and a start tag declaring more than 256 namespace bindings is
rejected instead of allocated. No isopod source changed — the crate crosses
two 0.x minors, but the API churn was elsewhere; the de surface and the
serialize feature are intact, and the lockfile holds exactly one copy of the
crate. cargo deny check advisories fails on the tree before this commit and
passes after it.

The upgrade's own risk is behavioural drift in deserialization, and the
fixtures in s3.rs stand guard over every shape isopod parses — the
pagination fields (including the Option continuation token), the
CommonPrefixes roll-up, the Contents keys, each under the real bucket's
namespace declaration. All pass unchanged. The one behaviour 0.41 adds —
rejecting more than 256 namespace declarations on one element — cannot fire on
a well-formed S3 listing, which declares one.

v0.12.3

Choose a tag to compare

@github-actions github-actions released this 29 Jul 13:19

[0.12.3] — 2026-07-29

Fixed — a guest booted with no NIC left loopback down, so it could not talk to itself

The only loopback bring-up lived inside the network-config apply(), which is
reached only after configure_if_requested finds an isopod.net token on the
kernel command line — and the whole point of --no-network / network: false
is that there is none. The guest's one interface stayed state DOWN, so lo
came up in every boot except the one that had nothing else.

The failure is expensive because it is partial. bind() on 127.0.0.1
succeeds — binding never required the link to be up — so a workload gets a
socket and a port number and fails later, far from the cause, when something
dials it. Measured with isopod's own suite as the workload (finding #49):
network: false gave isopod-core 363 passed / 18 failed, every failure a
broker test that listens and then dials itself; one ip link set lo up first
gave 381 / 0.

Loopback is now a boot duty, not network configuration: the agent's main()
brings lo up unconditionally (net::ensure_loopback_up), before any network
decision, and apply() shares the helper so a runtime reconfigure is still a
full replacement on its own. configure_if_requested keeps its contract of
being a no-op absent the token. --no-network still means what it says about
egress — no NIC is attached and nothing leaves the guest; loopback is the
guest's own plumbing, not a way out.

This changes the agent binary, not the protocol: PROTO_VERSION stays 3, and
an existing guest image keeps the old agent until rebuilt. The agent-hash
freshness check exists for exactly this shape of change — once the host
binaries are rebuilt, every agent-carrying image reports stale and the run
path refuses it, naming the fix: isopod image build-all for the built
flavors, a re-import (local, from cached blobs) for OCI bases.

Mutation loopback-left-down-without-a-nic deletes the unconditional call and
pairs with the boot-order assertion in the agent's tests, so the duty cannot
be refactored away in silence.

v0.12.2

Choose a tag to compare

@github-actions github-actions released this 29 Jul 12:33

[0.12.2] — 2026-07-29

Fixed — the refusal named an address, but nothing made it name the right one

screen_resolved refuses a name when any address it resolves to is floored, and
that function's own doc calls naming the address load-bearing: the message
reaches an operator's terminal about their own machine, and it is the only thing
that tells them whether they hit a rebinding payload or their own split-horizon
DNS.

Nothing pinned it. Replacing bad.ip() with addrs[0].ip() — a refusal that
fires correctly and then points at the wrong record — left all 31 tests green.
That was established by applying the change and running the suite, not by
reading it.

The unit level is the only level that reaches it. The integration test
asserts against the addresses the host actually resolved, which is right for
what it covers and cannot cover this: localhost answers with loopback and
nothing else, so every address it resolves is itself an offender and the match
holds under either implementation. A record that passes and a record that is
floored only coexist in the unit test's pairs.

The assertion now has two halves and the second is the load-bearing one: the
message must name the offender, and must not name the record that passed.
Naming addrs[0] satisfies the first half in three of the four pairs — only the
good-record-first case catches it, and only the negative half catches it there.
Put in operator terms, what addrs[0] does is send someone debugging a
split-horizon resolver to look at the one record that is fine.

Addresses are compared by parsing the literal and printing it back, rather than
by searching for the spelling the table typed, because the two differ: std
prints an IPv4-mapped address in mixed notation and a NAT64 one in hex, so a
table's 64:ff9b::169.254.169.254 would never be found in a message that says
64:ff9b::a9fe:a9fe.

Mutation oci-registry-refusal-names-the-wrong-address pairs with the assertion
so it cannot be refactored away in silence.

Fixed — the in-sandbox build recipe encoded a payload that was already encoded

docs/sandbox-build.md told you to base64 -w0 a source tarball before handing
it to --stdin-file. The channel is binary-safe on both the CLI and the MCP
surface — the bytes are base64'd inside the protocol frame either way — so
encoding them first only inflated the payload by a third, against a ceiling that
is already the binding constraint. The recipe now sends the tarball raw and
states that ceiling: PutFile is a single frame capped at MAX_FRAME_LEN, so
roughly 6 MiB of raw input, and there is no inbound equivalent to the streamed,
unbounded copy_out.