Sandbox isolation
The layers that contain untrusted code, microVM, namespaces, seccomp, cgroups, and the filesystem.
Isolation here is deliberately layered. No single mechanism is trusted to hold on its own; each exists to contain the failure of the one above it.
The layers
| Layer | Mechanism | Contains |
|---|---|---|
| Hypervisor | libkrun microVM with its own guest kernel | Kernel exploits: they hit the guest, not the host |
| Namespaces | PID, mount, network, user, IPC, UTS, cgroup | Process and filesystem visibility |
| Seccomp-bpf | A Kafel policy built in nsjail.ts | Dangerous syscalls, kernel-control socket families |
| cgroups v2 | Memory, swap, PID limits | Resource exhaustion |
| rlimits | AS, fsize, nofile, nproc, cpu | Per-process resource caps |
| User mapping | UID/GID 65534, or a per-job UID | Privilege escalation |
| Filesystem | Read-only /usr, tmpfs /tmp, writable /mnt/data only | Persistence and tampering |
| Network | Empty network namespace | Outbound connectivity |
The microVM boundary
When KVM_ENABLED=true, launcher/src/main.rs (a small Rust binary) boots a
libkrun microVM and execs into it:
krun_create_ctx()
krun_set_vm_config(ctx, vcpus, ram_mib) // LAUNCHER_VCPUS, LAUNCHER_RAM_MIB
krun_set_root(ctx, root_path)
krun_add_virtiofs(ctx, tag, path) // packages and workspace
krun_set_port_map(ctx, port_map) // egress gateway only
krun_set_rlimits(ctx, rlimits)
krun_set_exec(ctx, exec_path, argv, envp)
krun_start_enter(ctx)The launcher installs a seccomp filter of its own before entering the guest.
NsJail then runs inside that guest. Untrusted code faces the guest kernel, so a kernel-level exploit compromises a disposable VM whose entire purpose was to run that one job.
No privileged mode and no Linux capabilities are required: only access to
/dev/kvm. On Kubernetes a device plugin can provide that without a hostPath
(workerSandbox.kvmDevicePlugin).
With KVM_ENABLED=false this layer is simply absent, and every remaining layer
operates against the host kernel. See
running without KVM.
NsJail configuration
api/config/sandbox.cfg holds the static policy: namespace flags, the mount
table, UID/GID mapping, default cgroup limits. Per-execution values (timeouts,
memory, the seccomp policy, environment) are passed as CLI arguments by
api/src/nsjail.ts.
keep_caps: false # no capabilities retained
clone_newnet: true # empty network namespace
clone_newcgroup: true
mount_proc: false # procfs mounted explicitly, not implicitly
cgroup_mem_max: 536870912
cgroup_mem_swap_max: 0 # no swap: memory limits cannot be evaded
cgroup_pids_max: 64chroot is used rather than pivot_root, for compatibility with the
RuntimeDefault seccomp profile: pivot_root would otherwise force
Unconfined at the container level.
The per-job /mnt/data bind is appended as a config overlay rather than passed
via NsJail's -B flag, because the CLI form cannot carry noexec, nosuid,
and nodev: flags that matter on the one writable mount in the sandbox.
The seccomp policy
Built in api/src/nsjail.ts as a Kafel policy string, in two tiers.
KILL: the process dies with SIGSYS
ptrace, process_vm_readv, process_vm_writev, memfd_create,
personality, userfaultfd
kexec_load, kexec_file_load, bpf, perf_event_open, init_module,
finit_module, delete_module, reboot
mount, umount2, pivot_root, and the Linux 5.2+ mount API:
move_mount, open_tree, fsopen, fsmount, fspick.
The new mount API is orthogonal to mount(2): open_tree + move_mount can
replicate a bind mount on their own, so blocking the old call alone would be
insufficient.
unshare, setns, seccomp.
unshare creates namespaces; setns joins existing ones via a file
descriptor. Blocking both closes each side of that surface.
add_key, request_key, keyctl, acct, quotactl, swapon, swapoff
settimeofday, adjtimex, clock_adjtime, syslog, plus arch-specific
ioperm, iopl, modify_ldt, lookup_dcookie.
The kernel would already return EPERM for most of these without capabilities.
Blocking them explicitly states the intent and guards against future kernel
config drift.
socket(domain) { domain == AF_VSOCK }
ioctl(fd, request) { (request & 0xFF00) == KVM_IOCTL_MAGIC }AF_VSOCK reaches the host hypervisor on KVM-based runners: the guest sees
virtio-vsock. An audit found a VSOCK socket() succeeding and connect()
hanging rather than returning ENETUNREACH, so it is killed explicitly and
coverage sees SIGSYS rather than an ambiguous denial.
ERRNO: the call fails, the process survives
Some things must fail gracefully, because language runtimes probe for them at
startup and would die on SIGSYS.
ERRNO(38) { clone3 } # ENOSYS: runtimes fall back to clone
ERRNO(1) { io_uring_setup, io_uring_enter, io_uring_register,
sched_setaffinity, vmsplice, ... }Nested namespace creation:
clone(flags) { (flags & CLONE_NAMESPACE_FLAGS) != 0 }Signalling the NsJail monitor, PID 1 of the sandbox's PID namespace:
kill(pid) { pid == 0 || pid == 1 || pid == 0xFFFFFFFF }
tkill(tid) { tid == 1 }
tgkill(tgid, tid) { tgid == 1 || tid == 1 }
rt_sigqueueinfo(pid) { pid == 0 || pid == 1 || pid == 0xFFFFFFFF }
pidfd_open(pid) { pid == 1 }
pidfd_send_signalWith clone_newpid a tenant cannot reach other tenants, but killing their own
namespace's PID 1 still races NsJail's cleanup ordering. pid == 0 (process
group) and pid == -1 (everything signalable) are blocked too, since their
fan-out would catch the monitor.
EPERM matches the kernel's standard "you may not signal init" behaviour, so
runtimes probing with kill(pid, 0) get a familiar error instead of dying.
pidfd_send_signal targets a pidfd rather than a numeric pid, so it cannot be
filtered by destination, and a pidfd to PID 1 is obtainable via
openat("/proc/1") since Linux 5.4. It is refused outright; no supported
runtime uses it.
Socket families:
socket(domain) { domain == AF_INET || domain == AF_INET6 || domain == AF_NETLINK
|| domain == AF_KEY || domain == AF_RXRPC || domain == AF_ALG }AF_NETLINK and AF_KEY are kernel-control interfaces. AF_ALG is the kernel
crypto API and AF_RXRPC the RxRPC family: both named in past kernel
vulnerabilities.
The policy ends USE sandbox DEFAULT ALLOW: an explicit deny-list over a
permissive default, since the allowed set for arbitrary language runtimes is
impractical to enumerate.
Why syscall numbers are #defined explicitly
NsJail pins an older Kafel snapshot whose symbol table may not know newer syscall names. The numbers for the new mount API and io_uring are shared between x86_64 and arm64 since Linux 5.2, so they are defined by number rather than relying on the bundled table.
Filesystem layout
/mnt/data/ Working directory: bind mount, writable, noexec/nosuid/nodev
/tmp/ tmpfs (20 MB), writable
/usr/ Host /usr, read-only bind
/bin -> /usr/bin merged-usr symlinks
/lib -> /usr/lib
/lib64 -> /usr/lib64
/proc/ procfs, read-only (Bun requires it)
/pkgs/ Language runtimes, read-only bind
/dev/null, /dev/urandom, /dev/zero/mnt/data is the only writable location that survives the process, and it is
mounted noexec.
procfs masking
Several /proc entries are masked with bind mounts over /dev/null because
they leak host information:
/proc/cgroups,/proc/1/cgroup(cgroup topology and the total cgroup count across the host/proc/self/maps) ASLR layout (further mitigated by seccomp blockingptraceandbpf)
With clone_newnet: true, /proc/net reflects only the sandbox's empty network
namespace, so it needs no masking.
Per-job UID isolation
SANDBOX_PER_JOB_UIDS=true
SANDBOX_JOB_UID_BASE=200000
SANDBOX_JOB_GID_BASE=200000api/src/workspace-isolation.ts leases a distinct UID/GID per concurrent job
from a pool. Two jobs on the same runner are then isolated by ordinary
filesystem permissions as well as by namespaces: a second, independent
mechanism for the same property.
Workspaces are created at mode 0711: not listable, but traversable, which
NsJail's bind-mount setup requires on the source path and its ancestors. Staged
files are 0600; read-only inputs 0444.
Without per-job UIDs, everything runs as nobody (65534).
Resource limits
| Variable | Default | Enforces |
|---|---|---|
SANDBOX_RUN_TIMEOUT | 300000 ms | Wall clock |
SANDBOX_RUN_CPU_TIME | 300000 ms | CPU time (SIGXCPU) |
SANDBOX_RUN_MEMORY_LIMIT | -1 | Memory cgroup (SIGKILL on OOM) |
SANDBOX_MAX_PROCESS_COUNT | 100 | PIDs |
SANDBOX_MAX_OPEN_FILES | 2048 | rlimit nofile |
SANDBOX_MAX_FILE_SIZE | 10000000 | rlimit fsize (SIGXFSZ) |
SANDBOX_RLIMIT_AS | 4096 MB | Address space |
SANDBOX_OUTPUT_MAX_SIZE | 65536 | stdout/stderr truncation |
SANDBOX_MAX_CONCURRENT_JOBS | 8 | Concurrent executions per runner |
cgroup_mem_swap_max: 0 matters more than it looks, with swap available, a
memory limit can be evaded by pushing pages out.
SANDBOX_LIMIT_OVERRIDES accepts a JSON object of per-runtime overrides, for
runtimes with genuinely different needs.
The setup gate
api/src/nsjail-setup-gate.ts serialises sandbox setup behind a watchdog.
Concurrent setup was found to race in ways that could wedge a runner. The
watchdog fires when setup exceeds its budget, and
codeapi_sandbox_nsjail_setup_gate_watchdog_fires_total should stay at zero in
a healthy deployment.
Host-level hardening
Two further layers, both off by default because they need node-level installation:
- seccomp profile:
seccomp/ships a profile applied at the container level, on top of NsJail's inner Kafel policy. Enable withworkerSandbox.seccomp.enabled=true; it must be pre-installed on every node at/var/lib/kubelet/seccomp/profiles/nsjail.json. - AppArmor:
apparmor/sandbox-nsjailconstrains the sandbox runner process itself.
Without the custom profile, KVM-mode pods use RuntimeDefault and non-KVM pods
are forced to Unconfined by NsJail's pivot_root requirement: part of why
the chroot choice above exists.
What is deliberately not claimed
- Not a defence against denial of service by resource exhaustion beyond the configured limits. Set them appropriately.
- Side channels (timing, cache) are not addressed.
- NsJail-only mode does not defend against kernel vulnerabilities.
- The safety of dynamic dependencies rests on wheels containing no install-time code, and on the index you point at.