A from-scratch Rust reimplementation of bubblewrap (bwrap) — a sandboxing tool that runs a command in a restricted environment using Linux namespaces and bind mounts.
Licensed under the MIT License.
cargo build --release
Release and distro packaging guidance is in docs/packaging.md.
make dist-check verifies deterministic source archives, while
make test-packaging exercises staged install, collision refusal, and rollback.
The trust model, invariants, and explicit exclusions are documented in
SECURITY.md. make security-check runs the dependency advisory
gate and prevents unreviewed growth of the unsafe-code inventory.
Install pipe-wrap and a bwrap symlink, plus AppArmor profiles on Ubuntu:
cargo build --release
make install DESTDIR= PREFIX=/usr/local # as root if writing to system paths
make install-apparmor # as root; userns profilesWhen argv0 is bwrap (or PIPE_WRAP_AS_BWRAP=1):
| Behavior | Value |
|---|---|
--version |
bubblewrap 0.11.x (declared compatibility target) |
| Errors / warnings | bwrap: ... prefix |
| Help | usage: bwrap ... |
Personal PATH swap without system install:
ln -s "$(pwd)/target/release/pipe-wrap" ~/.local/bin/bwrap
# put ~/.local/bin on PATH; load AppArmor for that path or use aa-execNot a supported Flatpak replacement until you validate against your workloads; the current compatibility target is bubblewrap's v0.11.x CLI and unprivileged execution model. See Parity notes for residual differences.
To replace system bwrap with pipe-wrap for Flatpak / Buildah / rpm-ostree:
- Install (creates
bwrap→pipe-wrapsymlink + man page + completions):make && sudo make install-native sudo make install-bwrap-compat # refuses an existing foreign bwrap
- AppArmor (Ubuntu/Debian — unprivileged userns needs a profile):
sudo make install-apparmor
- Verify the swap is active:
bwrap --version # → bubblewrap 0.11.x which bwrap # → /usr/local/bin/bwrap (symlink)
- Test with real consumer workflows:
This creates an isolated
make test-consumers
bwrap -> pipe-wrapsymlink and exercises Flatpak through it when Flatpak is installed. Buildah is also tested as a rootless namespace/capability workflow whenPIPE_WRAP_BUILDAH_IMAGEnames a pinned local image; Buildah normally uses an OCI runtime rather thanbwrap, so that result is not presented as proof that Buildah invoked pipe-wrap.make test-consumers-strictrequires both tools and the local image. - Roll back: run
sudo make uninstall-bwrap-compatbefore removing the native package. Existing foreignbwrapfiles are never overwritten or removed by the compatibility targets.
What works today: all 68 CLI flags, namespace setup, mount/pivot dance,
/dev + /proc construction, overlays, seccomp, caps, SELinux labels,
env-op ordering, lock-file, sync-fd, die-with-parent, info/json-status-fd.
Compatibility audit priorities still requiring broader coverage are inbound signal forwarding, SELinux label behavior on enforcing hosts, and additional kernel/LSM combinations. Cgroup support means cgroup namespace unsharing; it does not configure controllers, delegation, systemd scopes, or resource limits.
Release gates are available as make check (including warning-free Clippy),
make security-check, strict make test-e2e, named cgroup namespace gates,
and
make test-differential BWRAP_REFERENCE=/path/to/pinned/bwrap. Consumer hosts
can additionally run make test-consumers-strict with a pinned local Buildah
image. The setuid
launcher, s390x clone ABI quirk, and SELinux MCS translation remain explicit
unsupported platform modes; do not replace a distro bwrap that requires them.
The required hosted gates cover Ubuntu 24.04 on x86_64 with strict rootless sandbox tests. Native aarch64, Ubuntu with AppArmor userns restriction enabled, Fedora with SELinux enforcing, privileged nested mounts, and LTS/current kernel plus cgroup v1/v2 cells are opt-in self-hosted release gates. They run only when their corresponding repository runner variable is enabled; an absent runner is not counted as tested coverage.
Use make platform-info to record kernel, architecture, cgroup filesystem and
active LSM details. make test-e2e-apparmor requires AppArmor and aa-exec;
make test-e2e-selinux requires an enforcing SELinux host. Containers may be
used for userspace compatibility checks, but are never described as alternate
kernel or LSM coverage.
What is not reimplemented (rarely needed by consumers):
- setuid launcher (
bwrapis normally unprivileged; the setuid path is for hosts without unprivileged userns) - s390x clone argument ordering quirk
- libselinux MCS category translation (raw labels still work)
pipe-wrap [OPTION...] COMMAND [ARGS...]
Run a shell in a sandbox with a read-only /usr and a private /tmp:
pipe-wrap --unshare-all \
--ro-bind /usr /usr \
--ro-bind /lib /lib \
--ro-bind /lib64 /lib64 \
--dir /tmp \
--proc /proc \
-- /bin/sh
Set a custom hostname and environment:
pipe-wrap --unshare-all --unshare-uts --hostname sandbox \
--clearenv --setenv PATH /usr/bin:/bin \
--ro-bind /usr /usr -- /bin/env
| Option | Description |
|---|---|
--ro-bind SRC DEST |
Bind-mount SRC at DEST read-only |
--ro-bind-try SRC DEST |
Like --ro-bind, non-fatal if source missing |
--bind SRC DEST |
Bind-mount SRC at DEST read-write |
--bind-try SRC DEST |
Like --bind, non-fatal if source missing |
--dev-bind SRC DEST |
Bind-mount SRC at DEST (devices allowed) |
--dev-bind-try SRC DEST |
Like --dev-bind, non-fatal if source missing |
--tmpfs DEST |
Mount a fresh tmpfs at DEST |
--proc DEST |
Mount a fresh procfs at DEST |
--dev DEST |
Mount a fresh devtmpfs at DEST with basic device nodes |
--mqueue DEST |
Mount a fresh mqueue at DEST |
--dir DEST |
Create a directory inside the sandbox |
--file FD DEST |
Copy FD contents to a file at DEST |
--bind-data FD DEST |
Create file from FD, bind-mount at DEST |
--ro-bind-data FD DEST |
Create file from FD, ro bind-mount at DEST |
--symlink SRC DEST |
Create a symlink DEST → SRC |
--remount-ro DEST |
Remount DEST read-only |
--bind-fd FD DEST / --ro-bind-fd FD DEST |
Bind-mount open fd (optionally read-only) |
--chmod OCTAL PATH |
Change permissions of an existing path |
--size BYTES |
Size limit for the next --tmpfs |
--perms OCTAL |
Permissions for the next file-creating op |
--setenv VAR VALUE |
Set an environment variable |
--unsetenv VAR |
Remove an environment variable |
--clearenv |
Clear all environment variables (keeps PWD) |
--chdir DIR |
Change to DIR before exec |
--new-session |
Create a new terminal session (setsid) |
--die-with-parent |
Kill the sandbox when the parent exits |
--argv0 VALUE |
Set argv[0] to VALUE before exec |
--uid UID |
Run as this uid inside the sandbox |
--gid GID |
Run as this gid inside the sandbox |
--as-pid-1 |
Run the command as PID 1 (no init wrapper) |
--unshare-all |
Unshare all namespaces (user, pid, net, ipc, uts, cgroup, mount) |
--unshare-user / --unshare-user-try |
Unshare user namespace (try = non-fatal) |
--unshare-pid / --unshare-net / --unshare-ipc / --unshare-uts |
Unshare individual namespaces |
--unshare-cgroup / --unshare-cgroup-try |
Unshare cgroup namespace (try = non-fatal) |
--share-net |
Keep sharing the network namespace |
--userns FD |
Use an existing user namespace via setns |
--userns2 FD |
Switch into this user namespace after setup |
--pidns FD |
Use an existing pid namespace via setns |
--sync-fd FD |
Keep FD open for the lifetime of the sandbox |
--block-fd FD |
Block until a byte is read from FD before setup |
--seccomp FD |
Apply a seccomp filter from FD (not with --add-seccomp-fd) |
--add-seccomp-fd FD |
Apply additional seccomp filter (repeatable) |
--disable-userns |
Prevent sandbox from creating further user namespaces |
--assert-userns-disabled |
Assert userns creation is already disabled |
--not-a-security-boundary |
Soft-fail some setup steps instead of aborting |
--overlay UPPER WORK DEST |
RW overlay (requires prior --overlay-src) |
--overlay-src SRC |
Add lower layer for the next overlay |
--tmp-overlay DEST |
Mount tmpfs-backed overlay at DEST |
--ro-overlay DEST |
Mount read-only overlay at DEST |
--lock-file DEST |
Hold file open on init to prevent deletion (not with --as-pid-1) |
--info-fd FD |
Write sandbox info as JSON (child-pid + namespace inodes) |
--json-status-fd FD |
Write sandbox status as JSON when ready (and on exit) |
--userns-block-fd FD |
Parent blocks until a byte is readable (helper finished userns) |
--exec-label LABEL |
Set SELinux exec label (errors if SELinux off) |
--file-label LABEL |
Set SELinux file-create label (errors if SELinux off) |
--hostname NAME |
Set the hostname (requires --unshare-uts) |
--cap-drop CAP / --cap-add CAP |
Drop/add a Linux capability |
--args FD |
Parse NUL-separated arguments from FD |
--level-prefix |
Prefix diagnostics with syslog severity levels |
--help / --version |
Show help / version |
- Fork a child process that will become the sandboxed process.
- Unshare the requested namespaces in one combined
unshare()(like bwrap'scloneflags). The child signals the parent; the parent writesuid_map/gid_map(mapping sandbox uid 0 to the caller) and optional--info-fd/--json-status-fdoutput. - Bring up loopback when a private net ns was created.
- Make mounts slave (
MS_SLAVE | MS_REC) so changes don't propagate to the host. - Apply operations (recursive binds, tmpfs, proc, dev, overlays, ...) under a temporary workdir.
pivot_rootinto the workdir so it becomes the new root filesystem.- Drop capabilities, close stray fds, optionally fork a pid-1 init, then exec the target.
-
Linux with user namespace support (
CONFIG_USER_NS). -
On Ubuntu with
kernel.apparmor_restrict_unprivileged_userns=1, unprivileged sandboxes need an AppArmor profile that allowsuserns(same pattern as flatpak/buildah):cp apparmor/pipe-wrap /etc/apparmor.d/pipe-wrap # as root apparmor_parser -r /etc/apparmor.d/pipe-wrap # as rootOr temporarily disable the restriction by writing
0to/proc/sys/kernel/apparmor_restrict_unprivileged_usernsas root.
CLI surface matches bwrap. Runtime now uses clone(CLONE_NEW*|SIGCHLD)
(bwrap raw_clone) so the child is born into the requested namespaces,
then follows the classic setup order: root tmpfs on /tmp, first
pivot_root (newroot/oldroot), binds from /oldroot, second pivot,
nested userns remap to the caller's identity (devpts-friendly outer root).
| Area | Notes |
|---|---|
| Namespace entry | clone with combined flags; --unshare-user-try retries without NEWUSER |
| Mount setup | Post-pivot /oldroot → /newroot (bwrap setup_newroot) |
| RO binds | Recursive + mountinfo submount walk; BIND_FAIL_OPEN with --not-a-security-boundary; bwrap ensure_file/ensure_dir dests |
| Nested userns | Outer uid 0 for setup/devpts; nest to real/--uid before exec |
| uid/gid maps | write_uid_gid_map with optional map_root + overflowuid/gid |
| Monitor | eventfd (command exit) + signalfd/poll |
| Caps / seccomp / NO_NEW_PRIVS | Requested-caps bitmask (unknown names fatal), BPF FD filters |
| SELinux labels | /proc/self/attr/{exec,fscreate} with NUL terminator; reject when SELinux off |
| Locks / size / modifiers | F_RDLCK held on init (bwrap do_init); MAX_TMPFS_BYTES; dangling modifiers rejected; --perms restricts parent dir modes |
| FD setup ops | --file/--bind-data/--bind-fd close FDs after use; non-recursive file binds |
| Proc cover dirs | After --proc, remount sys/sysrq-trigger/irq/bus RO when writable |
| Non-root DWIM | Always unshare user ns unless --userns FD (bwrap getuid()!=0 path) |
| Option combos | --userns≠--unshare-user; --disable-userns needs --unshare-user; ≠--userns-block-fd; --ro-overlay needs ≥2 srcs |
| Area | Status |
|---|---|
| setuid / file-caps launcher | Not supported (modern bwrap rejects setuid too) |
| libselinux | Attr + tmpfs context=; reject labels when SELinux off (bwrap); no MCS translation |
| s390/cris clone arg order | Not implemented (bwrap has a special case) |
| Packaging install | Man/completions/AppArmor files exist; no distro package yet |
--prio-prefix |
Not a bwrap CLI flag (man page only mentions logger); we have --level-prefix |
Recent parity fixes: /proc cover dirs RO after --proc; /dev/shm mode 0755; --ro-overlay ≥2 srcs; --userns≠--unshare-user; --lock-file held on init (O_RDONLY existing-only); unknown --cap-* names fatal; --disable-userns sysctl write hard-fails; --die-with-parent PDEATHSIG + parent-alive recheck; --info-fd JSON contract documented; --sync-fd alone forces intermediate init (bwrap); --userns2 setns in launcher (parent) post-clone; child setup order matches bwrap (mounts → block-fd → hostname → setsid → nested-userns → caps → seccomp → chdir → exec); env ops (--setenv/--unsetenv/--clearenv) applied in command-line order; --version reports bubblewrap 0.11.x under bwrap brand; EINTR-safe write loops in --file/--bind-data/--args/--json-status-fd.
These are kernel/LSM policy issues, not missing pipe-wrap flags:
-
AppArmor userns restriction (
kernel.apparmor_restrict_unprivileged_userns=1on Ubuntu ≥23.10)- Unprivileged processes may
unshare/clone(NEWUSER)but mounts inside the userns get EACCES unless the binary has an AppArmor profile withuserns,(or runs under e.g.aa-exec -p buildah). - Mitigation: install
apparmor/pipe-wrap, oraa-exec -p buildah -- pipe-wrap .... E2E tests auto-tryaa-exec.
- Unprivileged processes may
-
max_user_namespaces/ nesting depthclone(NEWUSER)fails withENOSPCwhen the hierarchy is exhausted or the sysctl is 0.- Nested userns (our post-setup remap) counts as another level — deep container-in-container stacks can fail here.
-
CLONE_NEWPIDand the first process- Only the first task created in a new pidns is PID 1.
unshare(NEWPID)does not move the caller;clone(NEWPID)does create the child as PID 1. We use clone so/procmounts and reaping match bwrap. If clone is unavailable, a legacy fork-after-unshare path remains for pidns.
- Only the first task created in a new pidns is PID 1.
-
idmapped mounts / uid_map single-entry rule
- Unprivileged writers may only map their own uid/gid (one line). Mapping
0 overflowuid 1+uid parent 1(map_root) requires privileges the parent usually lacks — we expose the format for privileged parents; the default outer map is still the single line0 <caller> 1.
- Unprivileged writers may only map their own uid/gid (one line). Mapping
-
devpts and userns root
- Mounting
devptswithnewinstancetypically needs CAP_SYS_ADMIN and a mapped root in the userns. That is why setup runs as userns 0 then nests back to the real uid.
- Mounting
-
Seccomp + NO_NEW_PRIVS
- Installing
SECCOMP_MODE_FILTERwithout CAP_SYS_ADMIN requiresPR_SET_NO_NEW_PRIVSfirst (we set it early and again before filters). Filters that blockclone/mountwill brick setup if applied too early — load only after mounts (bwrap order).
- Installing
-
File bind of
/proc/self/fd/N- After pivot, bare
/proc/self/fd/Nis wrong; binding the fd symlink often returnsEINVAL. We resolve the symlink via/oldroot/proc/self/fd/Nand bind the real host path under/oldroot.
- After pivot, bare
cargo test # unit + always-on CLI integration
cargo test --test integration -- --ignored --nocapture # full sandbox E2E
PIPE_WRAP_REQUIRE_SANDBOX=1 cargo test --test integration -- --ignored
BWRAP_REFERENCE=/path/to/bwrap make test-differential
make test-consumers # skips unavailable consumers
PIPE_WRAP_BUILDAH_IMAGE=localhost/test:tag make test-consumers-strict
E2E (ignored by default; auto aa-exec -p buildah when needed): nested uid,
RO binds, dev/proc, pidns exit codes, hostname, clearenv/setenv, net loopback,
--unshare-all kitchen-sink, bind-fd / ro-bind-fd, file / bind-data /
ro-bind-data, remount-ro, json-status-fd, info-fd, argv0, NO_NEW_PRIVS, sized
tmpfs, dangling --size, symlink/dir, die-with-parent (run + kill-on-parent-death),
lock-file (held + missing fatal), unknown cap rejection, disable-userns,
ro/tmp/rw overlay, seccomp deny, sync-fd EOF lifetime. Always-on unit coverage
also checks --args recursion, empty arguments, and invalid UTF-8 rejection.
man/pipe-wrap.1— man pagecompletions/bash/pipe-wrap— bash completioncompletions/zsh/_pipe-wrap— zsh completionapparmor/pipe-wrap— AppArmor userns profile