Skip to content
AstroPaper
Go back

Bubblewrap: Linux Sandboxing with Namespaces and sysctl

Edit page

Bubblewrap: Linux Sandboxing with Namespaces and sysctl

🧭 What is Bubblewrap?

Bubblewrap (bwrap) is a lightweight, unprivileged sandboxing tool for Linux. It creates isolated environments using Linux kernel namespaces β€” no root required. It is the core sandboxing mechanism behind Flatpak and is used by AI coding agents like OpenAI’s Codex to run untrusted code safely.

Key characteristics:


πŸ“œ History

Bubblewrap grew out of work on GNOME/Flatpak. Before bubblewrap, Flatpak relied on a setuid helper binary for sandboxing β€” a security concern since running setuid code is inherently risky.

Around 2016, bubblewrap was extracted as a standalone project by Alexander Larsson and Colin Walters (both Red Hat). The key insight was using unprivileged user namespaces (available since Linux kernel 3.8+), which allowed sandboxing without root or setuid binaries.

The design philosophy was intentionally minimal β€” just a small C program that sets up namespaces and execs a process. This simplicity was a direct reaction to the complexity of other sandboxing/container tools at the time.

Today bubblewrap is maintained under the containers GitHub org and remains true to its original minimal design.


πŸ”§ The Tech: Linux Namespaces

What is a Namespace?

A namespace is a β€œview” or β€œlens” that a process uses to see system resources. Normally all processes share the same view β€” the same filesystem, process list, and network interfaces. A namespace gives a process its own private copy of one of these views.

Analogy: Imagine an office building. Without namespaces, everyone works in one big open room β€” you can see everyone, access every filing cabinet, use every phone line. With namespaces, each team gets their own room with its own filing cabinets, phone system, and directory of people. They don’t know the other rooms exist.

Namespaces don’t block access through rules or permissions. They remove the resource from existence for that process. You can’t access what doesn’t exist in your view.

Namespace Types

Linux provides several namespace types, each isolating a different resource:

NamespaceFlagWhat it Isolates
UserCLONE_NEWUSERUID/GID mapping β€” lets unprivileged users β€œbecome root” inside
MountCLONE_NEWNSFilesystem mount points β€” the process gets its own mount table
PIDCLONE_NEWPIDProcess IDs β€” PID 1 inside is not PID 1 on the host
NetworkCLONE_NEWNETNetwork stack β€” no access to host interfaces
IPCCLONE_NEWIPCSystem V IPC, POSIX message queues
UTSCLONE_NEWUTSHostname

Concrete Example: PID Namespace

On the host:

PID 1   - systemd
PID 42  - sshd
PID 100 - your-app       ← sandboxed process
PID 101 - child-of-app

What the sandboxed process sees inside its PID namespace:

PID 1 - your-app          ← thinks it's PID 1
PID 2 - child-of-app

It literally cannot see systemd or sshd. They don’t exist in its world.

User Namespace: The Gateway

User namespace is the namespace that isolates UID/GID β€” who you β€œare.” A process inside a user namespace can have a different identity than on the host.

This is the critical piece because creating other namespaces (mount, PID, network) are privileged operations β€” normally only root can create them. User namespace solves this:

Normal user (uid=1000)
  β†’ creates user namespace
  β†’ becomes "root" inside (uid=0)
  β†’ can now create mount/PID/network namespaces
  β†’ but has zero extra privilege on the host
graph TD
    A[Normal User uid=1000] -->|creates| B[User Namespace]
    B -->|"root" inside| C[Mount Namespace]
    B -->|"root" inside| D[PID Namespace]
    B -->|"root" inside| E[Network Namespace]
    B -->|"root" inside| F[IPC Namespace]
    B -->|"root" inside| G[UTS Namespace]

    style B fill:#f9f,stroke:#333,stroke-width:2px

Without user namespaces, an unprivileged user can’t create any of the others. That’s why it’s the foundation of bubblewrap.


βš™οΈ How Bubblewrap Works

The Execution Flow

1. Parse arguments (what to mount, what to unshare)
2. clone() with CLONE_NEWUSER (+ other namespace flags)
3. In child:
   a. Set up UID/GID mappings (write to /proc/self/uid_map)
   b. Create mount namespace
   c. Build new root filesystem (bind mounts, tmpfs, proc, dev)
   d. pivot_root() to the new root
   e. Optionally apply seccomp filters
   f. Drop any remaining capabilities
   g. exec() the target program
4. Parent: optionally holds a reference to namespaces, then exits

Most of the actual work is done by the kernel. Bubblewrap is just a thin wrapper that calls the right syscalls in the right order:

// Simplified version of what bwrap does
unshare(CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWPID | ...);  // one syscall
mount("/usr", "/usr", NULL, MS_BIND | MS_RDONLY, NULL);       // one syscall per mount
pivot_root(new_root, old_root);                                // one syscall
execvp(argv[0], argv);                                         // one syscall

Where the 4000 Lines Go

Area~%Purpose
Argument parsing30%All those --ro-bind, --tmpfs, --unshare-* flags
Setup logic30%Ordering mounts, pivot_root, UID mapping
Error handling20%What if a mount fails? No user namespace support?
Edge cases & portability20%Different kernel versions, different distros

The complexity lives where it belongs β€” the kernel does the isolation (millions of lines of code), bubblewrap provides a thin auditable interface, and higher-level tools (Flatpak, Codex) handle policy and configuration.


πŸ§ͺ Practical Example

Running git status in a Sandbox

bwrap \
  --ro-bind /usr /usr \
  --ro-bind /lib /lib \
  --ro-bind /lib64 /lib64 \
  --ro-bind /bin /bin \
  --proc /proc \
  --dev /dev \
  --ro-bind /path/to/repo /path/to/repo \
  --unshare-all \
  --share-net \
  -- git -C /path/to/repo status

Breaking down the arguments:

ArgumentPurpose
--ro-bind /usr /usrMount host /usr into sandbox, read-only (binaries)
--ro-bind /lib /libMount shared libraries, read-only
--ro-bind /lib64 /lib64Mount 64-bit shared libraries, read-only
--ro-bind /bin /binMount binaries, read-only
--proc /procMount a fresh /proc filesystem
--dev /devMount minimal /dev (/dev/null, /dev/zero, etc.)
--ro-bind /path/to/repo ...Mount the repo, read-only (git status only reads)
--unshare-allCreate new namespaces for everything (user, mount, PID, net, IPC, UTS)
--share-netRe-enable network (undoes the network part of --unshare-all)
--Separator: everything after is the command to run

The sandbox sees only these mounted paths. Everything else β€” /home, /etc, /var, /tmp β€” doesn’t exist inside.

Figuring Out What a Process Needs

The main practical challenge: you need to know your process’s dependencies upfront because anything you don’t provide won’t exist.


πŸ”’ Limitations: What Bubblewrap Cannot Do

Bubblewrap controls what resources are visible, not how they’re used. If a process needs write access to a resource, bubblewrap can’t protect that resource from the process.

Process needs read/write to /project
  β†’ bubblewrap gives it read/write to /project
  β†’ a bug does "rm -rf /project"
  β†’ bubblewrap can't stop this

It’s all-or-nothing per resource: visible or not, read-only or read-write. There’s no way to say β€œyou can write to this directory, but not delete files.”

For finer-grained control, you need additional tools:

ProtectionTool
Limit which syscalls can be madeseccomp-BPF (e.g., block unlink)
Fine-grained file access rulesAppArmor or SELinux
Filesystem-level protectionSnapshots / copy-on-write (btrfs, ZFS)
Immutable fileschattr +i
Recovery from damageBackups, git (re-clone)

Real sandboxing solutions layer these together. Flatpak uses bubblewrap + seccomp + portals. Defense-in-depth is the standard approach.


πŸ€– Bubblewrap in AI Agents: Codex vs Claude Code

AI coding agents generate and execute code β€” essentially running untrusted code. This makes sandboxing critical.

OpenAI Codex: Sandbox-First

Codex runs commands through bubblewrap with a pre-defined sandbox configuration:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Codex infrastructure (OpenAI)   β”‚
β”‚                                 β”‚
β”‚  Hardcoded bwrap config:        β”‚
β”‚  β”œβ”€ --ro-bind /usr /usr         β”‚
β”‚  β”œβ”€ --ro-bind /lib /lib         β”‚
β”‚  β”œβ”€ --bind /workspace /workspaceβ”‚
β”‚  β”œβ”€ --unshare-net               β”‚
β”‚  └─ etc.                        β”‚
β”‚                                 β”‚
β”‚  Model only controls:           β”‚
β”‚  └─ the command after "--"      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The bwrap arguments are hardcoded by engineers, not generated by the AI β€” letting the model define its own constraints would defeat the purpose. The model just generates the command to run; the infrastructure wraps it.

Claude Code: Direct Execution with Permission Gates

Claude Code runs commands directly on the host, with the user approving or denying each action.

Comparison

Codex (bwrap)Claude Code (direct)
Trust modelTrust the sandboxTrust the user
Safety mechanismTechnical constraintHuman judgment
Default postureSafe but limitedPowerful but risky
Failure mode”I can’t do that""I shouldn’t have done that”
Environment mismatchPossibleNone
Engineering burdenMaintain sandbox configMaintain permission UX

Codex’s tradeoff: Engineers must build and maintain complex bwrap argument logic for every possible user task. Too tight and legitimate operations fail; too loose and the sandbox is meaningless. Every β€œworks on my machine but not in Codex” bug could be a sandbox misconfiguration.

Claude Code’s tradeoff: Safety depends on users paying attention. After approving 50 commands, fatigue sets in and users start clicking β€œyes” without reading β€” at which point you have no sandbox AND no real human review.

Neither is strictly better. They optimize for different things β€” containment vs capability.


πŸ› οΈ Setup: Enabling Unprivileged User Namespaces

The Irony of Unprivileged User Namespaces

A feature designed to improve security (sandboxing without root) also weakens security (exposes more kernel attack surface). When a normal user creates a user namespace and becomes β€œroot” inside, they can reach kernel code paths previously only reachable by actual root β€” more code paths means more potential vulnerabilities1.

Some distros disable this feature by default. Ubuntu uses an AppArmor-based middle ground: allow it for specific trusted programs (like Flatpak) but block it for everything else.

Checking and Enabling

Check if AppArmor restricts unprivileged user namespaces:

cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns
# 1 = restricted (bwrap will fail)
# 0 = allowed

Enable temporarily (resets on reboot):

sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0

Enable permanently:

echo "kernel.apparmor_restrict_unprivileged_userns=0" | sudo tee /etc/sysctl.d/99-userns.conf

Understanding sysctl

sysctl reads and writes kernel parameters at runtime. These parameters live under /proc/sys/. The sysctl command is just a nicer interface:

# These are equivalent:
cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns
sysctl kernel.apparmor_restrict_unprivileged_userns

Changes via sysctl -w are temporary β€” they reset on reboot. For persistence, place config files in /etc/sysctl.d/.

The /etc/sysctl.d/ Drop-In Pattern

On boot, systemd loads sysctl settings in order:

/usr/lib/sysctl.d/*.conf    ← distro defaults (don't touch)
/etc/sysctl.conf             ← system-wide config (legacy)
/etc/sysctl.d/*.conf         ← your custom overrides (use this)

Files load in alphabetical order; higher numbers win. The 99- prefix ensures your settings override everything else. This drop-in directory pattern is common across Linux (/etc/sudoers.d/, /etc/apt/sources.list.d/, etc.) β€” clean, reversible, and survives package updates.


Footnotes

  1. There have been real kernel privilege escalation CVEs that were only exploitable because unprivileged user namespaces were enabled. ↩


Edit page
Share this post on:

Previous Post
Jekyll, Static Site Generators, and Static Site Hosting
Next Post
Markdown Standards and the GitHub Convergence Problem