The Bubblewrap backend provides unprivileged Linux sandboxing using
Bubblewrap (bwrap). It uses
Linux user namespaces to create isolated sandbox environments without
requiring root privileges or a container runtime.
Status: Experimental — requires the
--experimentalCLI flag.
- Linux host with kernel 3.8+ (user namespace support)
- Bubblewrap installed and on PATH:
The deny-by-default baseline (see How It Works) emits its read-only mounts via
# Debian/Ubuntu sudo apt install bubblewrap # Fedora/RHEL sudo dnf install bubblewrap # Alpine apk add bubblewrap
--ro-bind-try, which requires bwrap 0.3.0+ (released 2017; every currently-supported distro ships a newer version). - User namespaces must be enabled:
# Check: should print "1" cat /proc/sys/kernel/unprivileged_userns_clone
{
"version": "0.6.0-alpha",
"containment": "bubblewrap",
"process": {
"commandLine": "echo 'Hello from Bubblewrap sandbox'"
}
}Run with:
lxc-exec --experimental --config bubblewrap_hello.jsonOr via base64:
lxc-exec --experimental --config-base64 "$(base64 -w0 bubblewrap_hello.json)"Bubblewrap creates a namespace-isolated process by:
- Unsharing user, PID, IPC, and UTS namespaces (
--unshare-*) - Bind-mounting a minimal deny-by-default baseline read-only into the
sandbox (
/bin,/sbin,/lib*,/usr/bin,/usr/sbin,/usr/lib*,/usr/libexec,/usr/share,/etc, plus DNS stub-resolver dirs under/run). Everything else on the host — including the caller's$HOME,/root,/opt,/var,/sys, and/run/user/<uid>— is invisible inside the sandbox. - Layering filesystem policy overrides (read-write, read-only, denied paths)
- Setting up minimal
/dev,/proc, and/tmp - Clearing the environment and applying only requested variables
- Executing the command via
sh -c
The sandboxed process runs as a child of bwrap and dies automatically when
execution completes — no container lifecycle management required.
The baseline mirrors the macOS Seatbelt backend's (deny default) posture:
the sandbox can read the dynamic linker, libc, system tools, and system
configuration — and nothing else — until the caller opts in via
readonlyPaths / readwritePaths. To make a host directory visible inside
the sandbox, list it explicitly:
{
"filesystem": {
"readonlyPaths": ["/home/alice/project", "/usr/local"],
"readwritePaths": ["/tmp/workspace"]
}
}Common consequences of this default:
$HOME(e.g.~/.aws/credentials,~/.ssh/id_*, browser cookies) is not readable from the sandbox./optand/usr/localtooling is not on PATH; list either path underreadonlyPathsif the script depends on it.working_directorymust live under the baseline or a policy path — acwdof~/projectwithout a matchingreadonlyPathsentry will fail.- DNS works on systemd-resolved, NetworkManager, and resolvconf hosts
because the corresponding
/run/...directories are bound. The common symlink targets outside/runare covered too:/var/run/...-routed/etc/resolv.confsymlinks resolve via a synthesised/var/run -> /runcompat symlink, and WSL's/mnt/wsl/resolv.confis bound directly. Neither exposes host/varor/mntcontents. Hosts that point/etc/resolv.confat some other custom location still need that target listed inreadonlyPaths.
Files in /etc that contain secrets (/etc/shadow, /etc/sudoers,
/etc/ssh/ssh_host_*_key) are mode 0400 / 0640 root and remain
unreadable to a non-root caller — user-namespace UID mapping does not
bypass kernel DAC.
Bubblewrap uses the shared cross-backend configuration fields. No backend-specific config block is needed.
| Field | bwrap Mapping | Description |
|---|---|---|
readwritePaths |
--bind <path> <path> |
Read-write bind mount (overrides base RO) |
readonlyPaths |
--ro-bind <path> <path> |
Explicit read-only bind mount |
deniedPaths |
--tmpfs <path> |
Masked with empty tmpfs |
Example:
{
"version": "0.6.0-alpha",
"containment": "bubblewrap",
"process": {
"commandLine": "cat /data/input.txt && echo result > /workspace/output.txt"
},
"filesystem": {
"readonlyPaths": ["/data"],
"readwritePaths": ["/workspace"],
"deniedPaths": ["/secrets"]
}
}Bubblewrap supports two network modes:
Full block (defaultPolicy: "block", no host lists) — uses
--unshare-net for complete network namespace isolation. No network stack
is available inside the sandbox (including loopback). Runs fully
unprivileged.
{
"network": {
"defaultPolicy": "block"
}
}Per-host filtering (allowedHosts/blockedHosts) — shares the host
network namespace and applies iptables rules via NetworkIptablesManager
(the same approach used by the LXC backend). Requires root for
iptables.
IPv4 only. Host names are resolved to IPv4 addresses only; AAAA records and IPv6 literals are silently dropped because
iptables(the IPv4 tool) cannot accept IPv6 destinations. A host with only AAAA records is effectively unreachable under firewall mode. For dual-stack hosts, use proxy mode (below) instead.
{
"network": {
"defaultPolicy": "block",
"enforcementMode": "firewall",
"allowedHosts": ["api.github.com"],
"blockedHosts": ["evil.example.com"]
}
}Full allow (defaultPolicy: "allow", no host lists) — the sandbox
shares the host network namespace with no restrictions.
Standard process fields work as expected:
{
"process": {
"commandLine": "python3 script.py",
"cwd": "/workspace",
"env": ["PATH=/usr/bin", "HOME=/tmp"],
"timeout": 30000
}
}Bubblewrap supports an unprivileged, cooperative network proxy that
enforces allowedHosts / blockedHosts at the proxy layer instead of via
iptables. This is the recommended way to do per-host filtering on
Bubblewrap because it requires no root and no CAP_NET_ADMIN.
- When
network.proxyis set, the runner launches an unprivileged HTTP proxy on loopback (127.0.0.1:N). For tests, the bundledlinux-test-proxybinary is used (builtinTestServer: true, testing-only and gated behind--allow-testing-features); in production callers supply their own proxy vialocalhost: <port>orurl: <url>. - The sandbox is then started without
--unshare-netso the sandbox shares the host network namespace and can reach the loopback proxy. - The command builder sets
HTTP_PROXY,HTTPS_PROXY,http_proxy, andhttps_proxyinside the sandbox viabwrap --setenv(any caller-supplied values for these keys, includingNO_PROXY/no_proxy, are stripped before injection). The runner deliberately does not setNO_PROXY: since the sandbox shares the host netns, aNO_PROXY=localhost,127.0.0.1entry would let cooperating clients bypass the proxy for host-loopback destinations, defeatingallowedHosts/blockedHostsenforcement for those targets. - Cooperative tools (curl, wget, Python
requests, Nodehttps, etc.) honor the env vars and traffic flows through the proxy, which applies theallowedHosts/blockedHostslists.
{
"version": "0.6.0-alpha",
"platform": "linux",
"containment": "bubblewrap",
"process": {
"commandLine": "curl -fsSL https://api.github.com/zen && echo OK"
},
"network": {
"defaultPolicy": "allow",
"proxy": { "builtinTestServer": true },
"allowedHosts": ["api.github.com"]
}
}{
"version": "0.6.0-alpha",
"containment": "bubblewrap",
"process": { "commandLine": "curl -fsSL https://example.com" },
"network": {
"proxy": { "localhost": 8080 }
}
}- Cooperative model: the runner enforces by injecting
HTTP_PROXY/HTTPS_PROXYinto the sandbox environment, so only well-behaved clients that honor those vars are routed through the proxy. Tools that bypass them (raw sockets, custom HTTP clients, statically-linked binaries that ignore the env) are not enforced. This applies to both the builtin test proxy and external (BYO) proxy modes — the limitation is in the env-var injection mechanism, not in the proxy itself; a BYO proxy can do whatever it likes for cooperating clients. For strict whole-network isolation, omitnetwork.proxyso the runner can apply--unshare-netinstead. - Mutually exclusive with iptables enforcement: setting
network.proxytogether withnetwork.enforcementModeof"firewall"or"both"is rejected at config-parse time because iptables-based enforcement requires root and would defeat the proxy's privilege story. - External proxy delegates policy: when
network.proxyuseslocalhost: <port>orurl: <url>(notbuiltinTestServer), the external proxy is responsible for any host filtering. The runner does not forwardallowedHosts/blockedHosts/defaultPolicy: "block"to it, and config combinations that would silently weaken enforcement are rejected at parse time. builtinTestServeris testing-only: gated behind--allow-testing-featuresand never to be used as a real production proxy. It has no auth, no body-size limits, and minimal hop-by-hop header handling. Use a real HTTP proxy for production deployments. (Selecting the Bubblewrap backend itself still also requires--experimental.)- HTTPS via CONNECT: the proxy uses HTTP
CONNECTtunnels for TLS, so certificate validation continues to work end-to-end (the proxy does not see plaintext).
The proxy applies allowedHosts and blockedHosts by case-insensitive
exact host match — there is no subdomain wildcard and no IP-vs-hostname
resolution.
allowedHosts: ["github.com"]does not matchapi.github.com. List each subdomain explicitly (e.g.["api.github.com", "objects.githubusercontent.com"]).allowedHosts: ["api.github.com"]does not match a CONNECT to a raw IP literal such as140.82.114.6:443. If your workload bypasses DNS, include the IPs.allowedHosts: ["localhost"]does not match127.0.0.1; if you need both, list both.- IPv6 literals are normalised: an allowlist entry of
"::1"matches a CONNECT to[::1]:443, but not the unrelated[fe80::1]:443.
| Aspect | LXC | Bubblewrap |
|---|---|---|
| Privileges | Root required | Unprivileged (user namespaces) |
| Rootfs | Downloads distro rootfs | Bind-mounts host filesystem |
| Startup | Create → Start → Attach | Single bwrap exec |
| Network isolation | iptables + veth | --unshare-net or iptables |
| Dependencies | lxc-* tools, templates |
Single bwrap binary |
| Lifecycle | Create/destroy containers | Process dies on exit |
When to use Bubblewrap:
- Quick sandboxing without root access
- Environments where LXC is not available
- Fast iteration (no container create/destroy overhead)
When to use LXC:
- Need a separate rootfs (different distro/packages)
- Need container networking with veth interfaces
- Need persistent containers across executions
# Single basic test
tests/scripts/run_bwrap_basic_test.sh
# All Bubblewrap tests
tests/scripts/run_bwrap_all_tests.shTest configs are in tests/configs/bubblewrap_*.json.
- Experimental — requires
--experimentalflag - Linux only — Bubblewrap requires Linux kernel namespaces
- Deny-by-default filesystem — the sandbox sees a minimal allowlist
of host paths (system binaries, libs,
/etc, DNS stub-resolver dirs) and nothing else.$HOME,/opt,/var,/sys,/run/user/<uid>, and/usr/localare invisible unless explicitly listed inreadonlyPaths/readwritePaths. There is no separate rootfs — the visible paths are bind-mounted from the host. - Network filtering — per-host
allowedHosts/blockedHostsis best done via the cooperative env-var network proxy (no privilege required, see above). The legacy iptables path (network.enforcementMode: "firewall"/"both") still works but requires root and is mutually exclusive with the proxy. - No state-aware lifecycle — Bubblewrap implements
ScriptRunneronly (one-shot), notStatefulSandboxBackend