How bundle-runtime isolates an app, what an app can and cannot reach, and
which parts of the design are load-bearing for security.
Companion to BundleFormat.md, which specifies the bundle format and the (unsandboxed) launch contract.
Inside the sandbox the filesystem is exactly:
| Path | Contents | Access |
|---|---|---|
/app |
The bundle | read-only |
/data |
The app's own container | read-write |
/tmp |
Private tmpfs (XDG_RUNTIME_DIR at /tmp/run, mode 0700) |
read-write |
/usr /lib /lib64 /bin /sbin /etc |
The OS | read-only |
/proc |
The app's own PID namespace | — |
/dev |
tmpfs: null zero full random urandom tty, plus capability-gated nodes |
— |
Nothing else on the host is reachable: the old root is detached after
pivot_root, so there is no path back out of the new root.
| Privileged | User namespace | |
|---|---|---|
| Trigger | launcher runs as root | launcher runs as any user |
| Privilege source | real CAP_SYS_ADMIN |
CLONE_NEWUSER grants capabilities over the namespace |
| App uid | per-app uid (20000+) | the invoking user |
| Container | /data/containers/<id> |
~/.local/share/bundle-runtime/containers/<id> |
| Device gating | kernel-enforced per app | limited to what the invoking user can already open |
| Intended for | the appliance OS | development, desktop Linux |
Both modes run the same mount, pivot_root, and privilege-drop sequence.
If user namespaces are unavailable (kernel without CONFIG_USER_NS), the
launcher warns and execs unsandboxed — acceptable for development, never for
the shipping OS.
- Resource limits (privileged only) — create
/sys/fs/cgroup/apps/<id>, writememory.max/pids.max/ optionalcpu.max, and join the group before fork so the app and its descendants inherit the caps. - Container — create the app's data directory; in privileged mode chown it to the app's uid.
- Unshare — mount, UTS, IPC, PID, and cgroup namespaces; network too
unless the app declares the
Networkcapability; user namespace in unprivileged mode (then write identity uid/gid maps,setgroupsdenied first as the kernel requires). - Fork — a PID namespace only applies to children, so the child becomes
PID 1 and the parent supervises. The parent immediately drops to the app
uid and sets
no_new_privs: no root process remains for the app's lifetime. It forwards the exit status, mapping death-by-signal to128+n. - Build root — mark
/recursively private (so nothing propagates to the host), tmpfs at the stage, bind the OS directories read-only (merged-usr symlinks are recreated, not bound), bind the bundle at/appread-only and the container at/data, mount private/tmpand a fresh/proc. - Devices — tmpfs
/devwith only the base nodes, plus/dev/driand/dev/inputforDisplay,/dev/sndforAudio. - Services — bind the Wayland socket into
/tmp/runforDisplay. - pivot_root into the stage; detach the old root (
MNT_DETACH). - Drop privileges —
PR_SET_NO_NEW_PRIVS;setgroups(0),setgid,setuidto the app uid; empty the capability bounding set; verify the drop by assertingsetuid(0)now fails. - Seccomp — install the BPF denylist (§5), last so it covers the app and anything it execs.
- Environment —
clearenv, then setPATH,HOME=/data,TMPDIR,XDG_RUNTIME_DIR,BUNDLE_PATH=/app,BUNDLE_ID,LD_LIBRARY_PATH(when the bundle ships private libraries), andWAYLAND_DISPLAYforDisplay. - chdir
/appand exec, translator-prefixed when the architecture selection chose one.
Manifest capabilities are enforced, not advisory:
| Capability | Effect when present | Effect when absent |
|---|---|---|
Network |
shares the host network namespace | CLONE_NEWNET: no interfaces at all |
Display |
/dev/dri, /dev/input, Wayland socket, WAYLAND_DISPLAY |
none of those exist |
Audio |
/dev/snd |
no sound devices |
Bluetooth, NFC |
reserved — no enforcement yet | — |
A BPF denylist returning EPERM, installed after no_new_privs and the uid
drop. It blocks sandbox-escape and kernel-attack-surface calls: mount,
umount2, pivot_root, chroot, setns, unshare, ptrace,
process_vm_readv/writev, bpf, perf_event_open, userfaultfd,
init_module/finit_module/delete_module, kexec_load/kexec_file_load,
open_by_handle_at, add_key/request_key/keyctl, acct, reboot,
swapon/swapoff, settimeofday, clock_settime, quotactl.
Each entry is #ifdef-guarded on its __NR_*, so an architecture lacking a
syscall simply omits it. The filter first checks seccomp_data.arch and
kills the process on mismatch, rather than risking syscall-number aliasing
under a different personality.
pivot_root, notchroot—chrootis escapable by a process holding a directory fd;pivot_rootplus detaching the old root leaves no route out.- Persistent uid map, not a hash — the original design hashed the bundle id
into 10,000 uid slots. By the birthday bound that collides ~50 % of the time
at ~118 apps, and two apps sharing a uid can read each other's containers.
/data/containers/.uid-map.jsonassigns each id a distinct uid permanently. - Deny-network by default — absence of a capability must mean absence of
access, so no
Networkyields an empty network namespace rather than a filtered one. - Verified privilege drop —
setuidcan fail silently in edge cases; assertingsetuid(0)fails turns a silent security hole into a crash. - Seccomp last — it must not block the mount work the sandbox itself does,
and installing it after
no_new_privslets an unprivileged process apply it. - Supervisor drops privileges — the parent only waits; leaving it as root for the app's lifetime would be needless attack surface.
- Not verified on hardware. The security properties are compiled and logic-tested but await on-target confirmation (see §8).
- Unprivileged mode is weaker. Apps share the invoking user's uid, so
per-app data isolation is by directory, not ownership; device access is
whatever that user already has. Distinct host uids per app would need
newuidmap/newgidmapwith a/etc/subuidrange. - No bundle integrity checking. Nothing verifies a bundle before launch; a signature or hash check belongs between resolve and parse.
BluetoothandNFCare unenforced, and audio has no sound-server socket path (only raw/dev/snd).- No seccomp in the unprivileged fallback path when user namespaces are missing entirely — that path has no isolation by definition.
# The app sees only the sandbox root
bundle-runtime com.example.app -- ls / # app, data, tmp, usr, proc, dev...
# Privilege drop is irreversible
id -u # 20000+, not 0
# No network without the capability
ip link # only lo, or nothing
# Seccomp denies escapes
mount -t tmpfs none /mnt # EPERM
# Resource caps are live
cat /sys/fs/cgroup/apps/<id>/memory.maxKernel requirements: CONFIG_USER_NS (unprivileged mode), CONFIG_SECCOMP_FILTER,
cgroup v2 mounted at /sys/fs/cgroup, and squashfuse in the rootfs for
unprivileged mounting of self-executing images.
- Namespace/mount/
pivot_root/privilege/seccomp shim:Sources/CBundleRuntime/ - Sandbox sequence:
Sources/BundleRuntime/Sandbox.swift - Containers and the uid map:
Sources/BundleRuntime/Container.swift - cgroup limits:
Sources/BundleRuntime/ResourceLimits.swift - Mode selection and fallbacks:
Sources/BundleRuntime/main.swift