Skip to content

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):

  1. Locate runsc (env DOCBUNKER_RUNSC_BIN or PATH); verify runsc --version.
  2. Write an OCI bundle per session (OciBundle in crates/sandbox/src/runsc_bundle.rs, unit-tested on every platform) with the hardening profile below; the rootfs is shared read-only.
  3. 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).
  4. Host-side wall-clock timeouts on every request/response; on timeout or crash the sandbox is terminated and deleted.
  5. 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.
  • runsc is invoked with std::process::Command using 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 — -qmp is not passed and -nodefaults skips the default monitor chardev; -qmp none is 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 a virtserialport, and guest /init redirects runsc stdio to /dev/vport0p1. The guest still has no network device. Validation, timeouts and cleanup remain in ProcessTransport.
  • Env: DOCBUNKER_QEMU_BIN (architecture-specific default), DOCBUNKER_VM_KERNEL, DOCBUNKER_VM_INITRD, DOCBUNKER_QEMU_ACCEL (default whpx/hvf/kvm by host), DOCBUNKER_QEMU_CPU (qemu64,svm=off on Windows x86_64, otherwise host), and DOCBUNKER_QEMU_MACHINE (q35 on x86_64, virt on 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.