Sandbox design¶
This document covers the isolation layer. The normative code lives in
crates/sandbox; the Linux runtime artifacts live in sandbox/ at the
repository root.
Backends¶
| Backend | Isolation | When |
|---|---|---|
MockBackend |
none (in-process) | development/tests; mock pages, parses nothing |
SubprocessBackend |
none (bare worker child) | development only; real decoding, no isolation |
RunscBackend |
gVisor/runsc |
Linux production path (Phase 4) |
QemuVmBackend |
QEMU VM + native accelerator (outer) + gVisor/runsc (inner) |
Windows, macOS, Linux production |
DOCBUNKER_BACKEND=mock|subprocess|runsc|vm selects the backend. Debug builds
default to mock. Production builds default to vm and reject mock and
subprocess; they never silently fall back to an unisolated backend. A release
build intended only for development can opt in at compile time with the
non-default docbunker-app/development-backends Cargo feature.
Linux runsc backend (Phase 4)¶
The Sandbox Manager drives runsc directly (no Docker/Podman):
- Locate
runsc(envDOCBUNKER_RUNSC_BINor PATH); verifyrunsc --version. - Write an OCI bundle per session (
OciBundleincrates/sandbox/src/runsc_bundle.rs, unit-tested on every platform) with the hardening profile below; the rootfs is shared read-only. runsc --network=none --root=<state> run --bundle=<dir> <container-id>with piped stdio — the worker is the container init and speaks the binary protocol over the pipes (ProcessTransport).- Host-side wall-clock timeouts on every request/response; on timeout or crash the sandbox is terminated and deleted.
- Cleanup:
runsc delete --force <id>+ bundle removal on close and on drop.
Hardening profile (OCI config generated by OciBundle)¶
| Property | Setting |
|---|---|
| User | unprivileged uid/gid 65534, no_new_privileges |
| Rootfs | read-only (readonlyRootfs: true); no host mounts |
| /tmp | private tmpfs, fixed 256 MiB cap (size=262144k), mode=0700 |
| Network | --network=none (empty netns in config) |
| Devices | empty device list |
| Capabilities | none (all dropped) |
| Memory | cgroup memory.limit (from SandboxConfig.memory_limit_bytes) |
| CPU | cfs quota/period (from cpu_limit_millicpus) |
| PIDs | pids.limit (from max_processes) |
| Timeouts | ProcessTransport per-operation deadline; expiry → kill/delete/cleanup → Timeout |
| Filesystem | no host paths; document bytes over IPC only |
| Environment | worker spawned with env_clear() |
Threat-relevant details¶
- The document is never written into the sandbox as a named file; bytes cross the IPC channel only.
- Bundle/container ids are program-generated, never derived from filenames.
runscis invoked withstd::process::Commandusing separated arguments.- A gVisor escape still faces: unprivileged user, no capabilities, no network, read-only rootfs, cgroup limits, empty env. An escape is treated as a critical incident.
Escape tests (adversarial verification)¶
runsc_escape_test (crates/sandbox/src/platforms/linux.rs, opt-in) verifies
the hardening claims by running escape-worker
(crates/sandbox/src/bin/escape-worker.rs) — a deliberately compromised
renderer — as container init inside the unchanged OCI hardening profile
and asserts that every break-out attempt fails:
| Attack attempted from inside the sandbox | Expected outcome |
|---|---|
| Read a host file (host secret path passed via argv) | ENOENT — no host mounts |
Write the rootfs (/, /bin, /etc, /usr) |
rejected — read-only rootfs |
Write /proc/sysrq-trigger, /proc/kcore, … |
blocked — masked/read-only paths |
Read /proc/self/status |
uid 65534, CapEff/CapBnd 0, NoNewPrivs 1 |
| Read the environment | exactly PATH=/bin — no host variables |
Connect to a host TCP listener (--network=none) |
no network-backed socket |
Spawn /bin/sh, busybox, python, … |
ENOENT — rootfs has no shell |
| Inspect the mount table | no host path visible |
Write a file to /tmp/<sentinel> |
never appears on the host filesystem |
| Thread/fork bomb (256 concurrent tasks) | bounded by pids.max |
The host side additionally proves the marker file is byte-for-byte unchanged
and the host TCP listener never accepted a connection. Requires Linux + root,
DOCBUNKER_RUNSC_BIN, DOCBUNKER_ROOTFS and DOCBUNKER_ESCAPE_WORKER (static
escape-worker built with the musl target):
cargo build -p docbunker-sandbox --bin escape-worker --target x86_64-unknown-linux-musl --release
sudo env DOCBUNKER_RUNSC_BIN=runsc DOCBUNKER_ROOTFS="$(pwd)/sandbox/rootfs" \
DOCBUNKER_ESCAPE_WORKER="$(pwd)/target/x86_64-unknown-linux-musl/release/escape-worker" \
cargo test -p docbunker-sandbox runsc_escape_test -- --ignored
CI runs it in the runsc-smoke job (main branch). The same harness pattern
applies to the VM backend: a malicious init built into the guest image
exercises the identical OCI config through the QEMU boundary.
Rootfs¶
sandbox/scripts/build-rootfs.sh (Linux) produces a rootfs containing only:
/bin/renderer-worker (static musl binary)
/usr/share/fonts/ (minimal fonts, when needed)
/etc/ /tmp/ /proc/ /dev/ (scaffolding)
No shell, package manager, network tools, compilers, Python/Node, or daemons.
The reviewed Alpine 3.20.3 SHA-256 values for x86_64 and aarch64 are embedded
in build-rootfs.sh; the build never downloads checksum trust material.
Cross-platform VM backend (Phase 6)¶
QemuVmBackend (crates/sandbox/src/platforms/vm/) runs the same gVisor
stack inside a minimal Linux VM. The outer boundary is the host hypervisor and
the inner boundary is runsc:
Windows/macOS/Linux host
└─ QEMU -accel whpx|hvf|kvm (outer boundary)
└─ Alpine initramfs (kernel + busybox + runsc + rootfs)
└─ runsc → renderer-worker (inner boundary, ADR-003)
- Each session is a fresh disposable VM, booted from a prebuilt initramfs
(
sandbox/scripts/build-vm-image.sh, Linux CI) — no runtime image tooling. - QEMU is launched with every host-visible surface disabled:
-nodefaults -display none -monitor none -nic none -no-reboot(QMP is never enabled —-qmpis not passed and-nodefaultsskips the default monitor chardev;-qmp noneis rejected by QEMU 11+), plus QEMU's seccomp sandbox (-sandbox on) on Unix hosts; the only device is the virtio-serial port that carries the protocol. The exact flag set is enforced by a unit test (vm/command.rs). - The binary protocol crosses
virtio-serial: a host-only loopback TCP chardev is wired to avirtserialport, and guest/initredirectsrunscstdio to/dev/vport0p1. The guest still has no network device. Validation, timeouts and cleanup remain inProcessTransport. - Env:
DOCBUNKER_QEMU_BIN(architecture-specific default),DOCBUNKER_VM_KERNEL,DOCBUNKER_VM_INITRD,DOCBUNKER_QEMU_ACCEL(defaultwhpx/hvf/kvmby host),DOCBUNKER_QEMU_CPU(qemu64,svm=offon Windows x86_64, otherwisehost), andDOCBUNKER_QEMU_MACHINE(q35on x86_64,virton aarch64). - Requirements: QEMU and WHPX, Hypervisor.framework, or KVM enabled on the
corresponding host. End-to-end verification is the opt-in test
qemu_vm_end_to_end.
Image build¶
The normal entry point is:
DOCBUNKER_VM_KERNEL_SOURCE=/path/to/kernel \
DOCBUNKER_VM_KERNEL_SHA256=<reviewed-kernel-sha256> \
DOCBUNKER_RUNSC_BIN=/path/to/runsc \
DOCBUNKER_RUNSC_SHA256=<reviewed-runsc-sha256> \
DOCBUNKER_BUSYBOX_BIN=/path/to/busybox-static \
DOCBUNKER_BUSYBOX_SHA256=<reviewed-busybox-sha256> \
sandbox/scripts/build-vm-assets.sh
The wrapper verifies all three supplied artifacts before use and writes
SHA256SUMS for the generated kernel and initramfs.cpio.gz beside them.
Expected hashes must come from reviewed upstream release provenance, not from
the same transport used to fetch an artifact.
Distribution builds embed those output hashes in the executable and verify the
bundled kernel and initramfs before QEMU is queried or launched. Release-mode
path overrides require explicit hash environment variables.
Set DOCBUNKER_VM_E2E=1 to run the host VM integration test after generation.
The wrapper builds the worker and delegates to build-rootfs.sh and
build-vm-image.sh, which remain independently testable stages.
build-vm-image.sh <out> <kernel> <runsc> <busybox-static> <rootfs> [modules-dir]
packs: the Alpine rootfs from build-rootfs.sh (as /bundle/rootfs), the
static runsc binary, a static busybox (only for /init), and a hardened
OCI config.json rendered by the vm-image-builder helper (same profile as
the Linux backend). The kernel must provide virtio_console (built-in or as a
module via modules-dir).
Release bundles include architecture-matched vm/kernel and
vm/initramfs.cpio.gz resources. Environment paths override bundled files for
development and host smoke tests.