Isolated, persistent sandboxes built on podman and the krun runtime (libkrun).
A container shares your host kernel. bluebox gives each sandbox its own
kernel, so mount, sysctl, modprobe and rm -rf / act on a machine that
is rebuilt on the next run.
Requirements: podman, libkrun, and Go 1.26+ to build. Linux needs KVM
(/dev/kvm).
0. Or just take the binary
curl -fsSL https://chxperiments.github.io/bluebox/install.sh | shThat fetches the right build for your platform into ~/.local/bin. You still
need the runtime pieces below.
1. Install the runtime pieces
# Fedora / RHEL
sudo dnf install -y podman libkrun golangOn other distros, podman and Go are packaged everywhere; libkrun may need building from libkrun/libkrun.
2. Check that crun has libkrun support
crun --version | grep -o '+LIBKRUN'It must print +LIBKRUN. Without it, crun cannot start microVMs.
3. Create the krun symlink
This is how crun is told to use libkrun. It is required, not optional.
sudo ln -sf $(command -v crun) /usr/local/bin/krun4. Build and install
git clone https://github.com/chxperiments/bluebox
cd bluebox
go build -o bluebox ./cmd/bluebox
install -Dm755 bluebox ~/.local/bin/blueboxMake sure ~/.local/bin is on your PATH.
5. Verify
bluebox new demo
bluebox build demoThe build ends by comparing kernels. Two different versions means real isolation:
isolated: host 6.18.33.2, guest 6.12.91
If they match, you got a plain container and bluebox refuses the sandbox.
Every run and shell re-checks this boundary, but cheaply: the result is
cached against a fingerprint of the runtime (the krun symlink, the crun it
resolves to, and the podman and libkrun versions). While that fingerprint is
unchanged the check is a fast lookup; if it changes — a re-pointed symlink, an
upgraded crun, libkrun support dropped — the full kernel comparison runs
again before the command does, and a sandbox that has quietly become a plain
container is refused rather than run.
bluebox new devbox # scaffold a Bluefile
$EDITOR ~/.bluebox/sandboxes/devbox/Bluefile
bluebox build devbox # build the image, verify isolation
bluebox run devbox -- python3 script.py # one command in a fresh microVM
bluebox shell devbox # interactive sessionReady-made Bluefiles for Python, Node, Go, an AI-agent sandbox, an offline one
and a system-experiments one live in examples/.
One YAML file per sandbox. Unknown keys are rejected, so typos fail loudly.
base: docker.io/library/debian:bookworm-slim
cpus: 4
ram_mib: 4096
network: bridge # bridge = internet access, none = offline
readonly: true # read-only root (/tmp and /data stay writable)
timeout_seconds: 600 # per-run wall clock limit, 0 = unlimited
packages:
- python3
- git
run:
- pip3 install --break-system-packages requests
env:
LANG: C.UTF-8The package manager is chosen from base: Alpine uses apk, Debian and Ubuntu
use apt, Fedora and RHEL-likes use dnf. For any other base, set pkgmgr
explicitly to apk, apt or dnf.
cpus maxes at 16 (a krun limit) and ram_mib is in MiB.
Field constraints, enforced at parse time so a bad value fails loudly instead of leaking into the generated Containerfile:
basemust be an image reference without whitespace.envkeys are identifiers (LANG,CGO_ENABLED); values cannot contain newlines — put multi-step builds inruninstead.packagesentries must be plain package names (no spaces or `; | & $ ``).- blueprint user names are lowercase identifiers,
shellan absolute path, and filemodes octal (e.g."0755"). write_filespaths are absolute and free of shell metacharacters — the path reaches a buildRUN, so it is checked the same way as a mode.
Beyond the implicit /data share, a Bluefile can declare exactly which host
directories a sandbox sees:
mounts:
- host: ~/projects/demo
guest: /work
mode: ro # ro (default) or rwhost must be an absolute path (~ expands to your home directory) with no
:, guest an absolute guest path that cannot shadow /data, and mode is
ro or rw — defaulting to ro, so a mount you forgot to make writable stays
read-only. What a sandbox can touch on the host is now visible in the spec
rather than implicit. Note that a writable mount still exposes that directory
fully: the guest writes through virtiofs with your user's permissions.
Mounts cannot nest inside anything the guest can write. A mount whose host
path is inside an rw mount (~/proj rw plus ~/proj/config ro), inside any
sandbox's /data under ~/.bluebox/data, or an rw mount that contains this
sandbox's /data, is refused at every boot. Otherwise the guest could replace
part of that path with a symlink, and the next run would mount whatever it
points at — ~/.ssh, say — in the declared place.
For cloud-init-style provisioning — users, files and commands:
blueprint:
users:
- name: admin
shell: /bin/bash
sudo: true # passwordless; put sudo in packages
write_files:
- path: /etc/motd
content: |
Welcome.
mode: "0644" # optional
runcmd:
- echo provisioned > /etc/stampUnlike cloud-init this is applied at build time, not first boot. Every run is a fresh VM, so boot-time provisioning would repeat on every command.
write_files contents are copied into the image rather than echoed through a
shell, so quotes, newlines and $variables survive exactly as written. User
creation adapts to the base: useradd on apt/dnf images, adduser on Alpine,
and the sudo group is sudo or wheel as that distro expects.
Sandbox names use letters, digits, ., _ and - (max 64 characters,
starting with a letter or digit). A name is a single path component by
construction, so nothing a sandbox does with its name can reach outside
~/.bluebox.
| Command | What it does |
|---|---|
bluebox new <name> |
scaffold a Bluefile |
bluebox edit <name> [-b|-c] |
open the Bluefile or Containerfile in $EDITOR |
bluebox build <name> |
generate the Containerfile, build, verify isolation |
bluebox run <name> -- <cmd> |
run one command in a fresh microVM |
bluebox shell <name> |
interactive session in one microVM |
bluebox verify <name> |
re-check that the sandbox has its own kernel |
bluebox ls |
list sandboxes |
bluebox env <name> |
print effective settings as KEY=VALUE |
bluebox logs <name> [-n] |
show recent runs (default 200 lines) |
bluebox reset <name> |
empty /data, keeping the sandbox |
bluebox snapshot <name> [label] |
archive /data under a name; -l lists archives |
bluebox restore <name> [snap] |
replace /data from a snapshot (newest by default) |
bluebox rename <old> <new> |
rename, keeping data, logs, snapshots and the built image |
bluebox destroy <name> [--data] |
remove a sandbox; --data also deletes /data |
bluebox nuke [--no-data] |
remove every sandbox; --no-data keeps data |
bluebox edit opens $VISUAL, $EDITOR, or vi. With no flag it asks which
file; -b and -c skip the prompt. Editing the Bluefile re-parses it on save,
so a mistake surfaces immediately rather than at the next build. The
Containerfile is generated, so edits to it are replaced by the next build —
bluebox says so before opening it.
bluebox env is shell-consumable: eval "$(bluebox env devbox)". Every value
is single-quoted, and the Bluefile's own env entries are printed as
BLUEBOX_ENV_<KEY> (e.g. BLUEBOX_ENV_LANG), so evaluating the output of a
Bluefile you did not write only ever assigns BLUEBOX_* variables — it cannot
run a command or replace PATH in your shell.
Anything that deletes data asks first, and -y skips the prompt. destroy
keeps /data unless you pass --data; nuke deletes it unless you pass
--no-data. Without a terminal they refuse rather than assume yes, so a script
cannot wipe your work by accident.
Snapshots are plain tarballs under ~/.bluebox/snapshots/<name>/. Give one a
label and you restore it by that label, instead of looking up a timestamp:
bluebox snapshot devbox before-upgrade # archive /data as before-upgrade
bluebox restore devbox before-upgrade # go back to itWithout a label the archive is named for the time it was taken:
bluebox snapshot devbox # archive /data
bluebox snapshot devbox -l # list archives, newest last
bluebox restore devbox # roll back to the most recent
bluebox restore devbox 20260823T150405Z # or to a specific oneLabels are reusable — bluebox snapshot devbox nightly replaces the previous
nightly rather than piling up archives — so it asks before overwriting, and
-y skips the prompt. The new archive is written beside the old one and
renamed into place, so an interrupted snapshot never destroys the one it was
replacing.
restore names a snapshot by its label or stamp, or takes a path to an archive
kept elsewhere. Entries are checked before anything is unpacked — an archive
that would write outside /data is refused — and the new data is swapped in
only once it is complete, so a failed restore leaves /data as it was.
bluebox run behaves like any subprocess: stdout, stderr and exit codes pass
straight through, so it scripts and automates cleanly. A run stopped by
timeout_seconds exits 124, matching timeout(1).
bluebox completion bash > ~/.local/share/bash-completion/completions/bluebox
bluebox completion zsh > "${fpath[1]}/_bluebox"
bluebox completion fish > ~/.config/fish/completions/bluebox.fishSandbox names complete after every command that takes one, and restore
completes that sandbox's snapshots by stamp, newest first. Both are read from
disk as you type, so a sandbox created in another terminal completes right
away. Where an argument is invented rather than chosen — new, the target of a
rename, a command passed to run — completion stays quiet instead of
offering host file names, which are never what belongs there.
Only /data, which lives at ~/.bluebox/data/<name>/ and is a normal
directory you can open from the host.
Everything else is discarded. Each bluebox run is a new microVM, so no
working directory, environment change, or background process carries from one
command to the next. Use bluebox shell when you need a session that holds
state, and keep anything worth saving in /data.
This is a consequence of the design rather than a limitation to work around:
podman exec does not work with krun (the handler does not support exec),
because a microVM has its own kernel and there is no host-side namespace to
step into. Booting per command means there is no path that can quietly land
back on your host.
~/.bluebox/
sandboxes/<name>/Bluefile the spec you edit
sandboxes/<name>/Containerfile generated on build
snapshots/<name>/ /data archives
logs/<name>.log run history
data/<name>/ mounted at /data -- the only persistent part
Override the root with BLUEBOX_HOME.
readonly and seccomp apply on the host side. The workload inside the guest
runs unconfined against its own throwaway kernel, which is the point — but it
means a seccomp profile filters the VMM process, not the guest. Operations the
VMM performs on the host (file I/O, which goes through virtiofs) are filterable;
operations the guest kernel answers alone are not.
go build -o bluebox ./cmd/bluebox
go test ./... # covers the Bluefile parser and Containerfile generatorcmd/bluebox/ entrypoint
internal/bluefile/ Bluefile parser + Containerfile generator
internal/sandbox/ on-disk layout
internal/runtime/ podman + krun driver -- the only backend-aware code
internal/cli/ cobra commands (root.go, commands.go)
MIT