isopod

Security Policy

isopod runs commands inside hardware-isolated Firecracker microVMs. Its whole purpose is to contain code you would not run directly on your host. This document states the security model plainly — what the boundary is, what holds, what the v1 limitations are — and how to report a vulnerability.


Supported versions

isopod is pre-1.0. The supported line is main. Fixes land there; there are no separately maintained release branches yet.


Reporting a vulnerability

Please report security vulnerabilities privately, not in the public issue tracker.

Use GitHub's private vulnerability reporting on this repository:

Security tab → AdvisoriesReport a vulnerability

That opens a private advisory visible only to you and the maintainers. Include what you were able to do, the affected code path if you know it, and a proof of concept or reproduction steps if you have one.

Please do not open a public issue, pull request, or discussion for a security bug, and please give maintainers a reasonable window to ship a fix before disclosing publicly.


Threat model and isolation boundary

Inside an isopod guest, untrusted code runs as root by design — with full capabilities, no in-guest seccomp, and a full device tree. This is intentional: the guest is expendable and fully owned by whatever runs in it. There is nothing to protect inside the VM.

The security boundary is therefore not the inside of the guest. It is:

  1. the Firecracker VMM + KVM (the hardware-virtualization boundary),
  2. the host-side code that ingests guest-controlled bytes (the vsock RPC responses, the committed ext4 stage image, exec and serial output), and
  3. the network fabric (the tap devices and nftables rules).
flowchart TB
    subgraph GUEST["Guest microVM — untrusted, expendable, root inside"]
        CODE["your command / untrusted code<br/>uid 0, full caps"]
    end
    subgraph HOST["Host — the thing being protected"]
        VMM["Firecracker VMM<br/>unprivileged · seccomp on · caps dropped"]
        INGEST["host code ingesting guest bytes<br/>vsock RPC · stage image · exec/serial output"]
        NET["tap devices + nftables NAT"]
        REST["your files · ~/.isopod store · other guests"]
    end
    CODE -.->|"virtio + vsock"| VMM
    CODE -.->|"RPC frames, output, images"| INGEST
    CODE -.->|"packets (if network on)"| NET
    VMM --> REST
    INGEST --> REST
    NET --> REST

A finding only matters if it crosses that boundary: host code execution, host file read/write outside the VM, host denial-of-service, or cross-contamination of another guest or the shared stage/snapshot store.


What holds

The load-bearing controls are configured conservatively:


Optional second isolation layer — the rootless jail

isopod can wrap each Firecracker in a rootless microjail — set ISOPOD_JAIL=1 in the environment of the runtime (CLI or MCP server). With no privileged host component it adds:

Drawn as containment layers — what an escape from the box in the middle would still have to get through:

flowchart TB
    subgraph HOST["Host — your user account"]
        HIDE["not visible from inside<br/>your home · the rest of ~/.isopod · other VMs"]
        subgraph JAIL["isopod-jail — user + pid namespace, single-id map<br/>per-VM cgroup v2 slice with memory.max · cpu.max · pids.max"]
            subgraph CH["minimal chroot — identity bind mounts only"]
                FCP["firecracker<br/>seccomp on · caps dropped · no_new_privs"]
                SEEN["reachable — this VM's own files · /dev/kvm · its tap device"]
            end
        end
    end
    FCP -.->|"a VMM or KVM escape lands as"| ESC["an unprivileged, unmapped uid<br/>with no host capabilities"]

Without ISOPOD_JAIL=1 the two inner boxes do not exist, and that dashed arrow lands in the outermost one — as your own account, with your files and the whole ~/.isopod store in reach.

It requires an environment that supports it: unprivileged user namespaces, a delegated cgroup v2 subtree (a normal systemd user session), and membership in the kvm group. When enabled, isopod runs a preflight and fails closed with a clear message if any prerequisite is missing (it never silently runs unjailed). It is opt-in in this release for portability; enabling it is strongly recommended for untrusted or multi-tenant workloads.


Known limitations (v1)

isopod v1 is honest about its posture. State these before running anything genuinely hostile:


Warm resume — what one memory image resumed many times does and does not share

A qualifying sandbox_run resumes a full-VM memory snapshot of a booted-idle guest instead of cold-booting. One image is resumed by many later sandboxes, which raises a question every snapshot-forking sandbox has to answer: what state do those sandboxes inherit from each other?

What holds

What is explicitly not claimed


Filtered egress — the allowlist the guest cannot rewrite

A run started with --allow-host / --allow-cidr / --deny-egress (or MCP allow_hosts / allow_cidrs) claims a filtered network slot instead of a public one. Two controls apply, both on the host, outside the guest boundary:

  1. The packet filter. sudo isopod setup bakes iifname "isopod-tap<i>" drop into the forward chain for every filtered slot — all forwarding, not just to the WAN. The only rule that lets anything in from a filtered tap is a narrow input accept for the broker's own ports on that slot's own gateway, pinned on arrival interface, destination address, and port. This is set once, as root, at provisioning time; the unprivileged runtime never edits nftables — so a port a given host was not provisioned with is unreachable, and a run that needs one fails closed with the re-provisioning command rather than hanging.
  2. The kernel's own forwarding flag. setup also clears net.ipv4.conf.isopod-tap<i>.forwarding for every filtered tap, so the routing layer refuses to forward what arrives there whether or not the ruleset is loaded. This is deliberately redundant with (1), and it is the one part of a filtered slot's enforcement an unprivileged process can read back: reading the live nftables ruleset needs CAP_NET_ADMIN, that file is world-readable. Every filtered run verifies it before booting and fails closed with the re-provisioning command if it is not in place.
  3. The egress broker. A host-side SOCKS5 / HTTP-proxy / DNS responder on the slot gateway, running as tokio tasks inside the VM supervisor process. It resolves and dials on the guest's behalf, only for destinations on the run's allowlist, and records every decision. It serves only its own slot's guest address — the listeners are bound to a host address, so without a peer check every process on the machine could drive them.

What holds

What is explicitly not claimed


Injected credentials — spent by the run, never held by it

A run started with --inject <alias> (or MCP inject) can authorise specific requests against one host without ever receiving the token. The alias, the secret's source, the single permitted host, and the exact set of permitted requests are all declared host-side in ~/.isopod/credentials.json (mode 0600); the run names only the alias. A fourth broker listener on the slot gateway (port 3129) accepts a stated intent and constructs a new request from its own parts — it is not a reverse proxy, because the guest does not compose the request. What does cross to the upstream is a path the normaliser accepted and an allow rule matched, that path's query verbatim, at most two allowlisted header values, and a bounded body; what never crosses is anything deciding where the request goes or who it is from. Full design: docs/credentials.md.

What holds

What is explicitly not claimed

The parent chain is not covered by that, and is not claimed to be. O_NOFOLLOW applies to the final component; the destination's parent directories are still created with create_dir_all, which follows symlinks and is not re-checked against the root. So a directory in the already-resolved prefix that is replaced by a symlink after the check and before the copy would relocate the write — a window as long as the run. copy_out cannot create a host symlink and stdin_file only reads, so nothing the sandbox does opens that window; it needs something else on the host writing into the root while a run is in flight, which the default root (the server's working directory) does not rule out. Closing it properly means resolving the whole path from a directory descriptor taken at check time (openat2(RESOLVE_BENEATH)), which is the fix on the list rather than one that has shipped. - A host-I/O root is not a sandbox. Confining stdin_file and copy_out[].host to a directory keeps the credential store and its sources out of reach; it does not make the files inside that root safe. copy_out writes guest-authored bytes to a path the caller names, so anything under the root — your source tree, by default — can be overwritten by the sandbox. Modes are masked (never setuid/setgid/sticky, never group- or world-writable), which stops the write from becoming a privilege escalation, not from being a write. A failed copy no longer destroys the file it named — bytes are staged beside the destination and renamed onto it only on success — but a successful copy replaces it, which is the point of the feature.

The executable bit is kept, and that reaches further than overwriting a file. Carrying it is deliberate: a binary or script built in the sandbox should arrive runnable, which is what artifact extraction is for. But the default root is the server's working directory — a project directory, which contains .git/hooks/. A copy_out to .git/hooks/pre-commit lands executable, and the operator's next git commit runs guest-authored code on the host, outside any VM. This has been demonstrated against a running server; it is not theoretical. The same shape applies to anything else in a project that something else later executes — .envrc, a Makefile, a CI config, a node_modules/.bin entry. Nothing in a writable root is safe from being made executable, and isopod does not try to enumerate which files a project treats as code. If the root is a tree whose contents you or your tooling will execute, that is the boundary you are relying on, and it is not one isopod enforces: point the root somewhere disposable, or set ISOPOD_MCP_COPY_OUT=off.


Importing an OCI image — code that runs before any VM

isopod image import is the one part of isopod that processes attacker-authored bytes on the host, as the operator's user, with no VM between them. Every other guarantee in this document is about confining a guest; this is outside that boundary by construction. The registry is assumed hostile: the challenge, every redirect Location, every media type, every declared size and every byte is its text.

What holds

What is explicitly not claimed

Guidance for operators

The filtered-egress claims above are exercised against a real VM and recorded, attempt by attempt, in docs/egress-ledger.md — including which of the two layers caught each one.

For the full design rationale behind these controls, see PLAN.md (the "Security posture" section) and the milestone log. The pre-publication breakout assessment — live escape attempts plus an adversarially-verified static review of every host-side code path that ingests guest-controlled data, and the origin of the hardening items above — is published in full at docs/security-assessment.md.

Rendered from SECURITY.md on the main branch.