Skip to content

Repository files navigation

pipe-wrap

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.

Build

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.

Drop-in replacement for bwrap

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 profiles

When 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-exec

Not 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.

Drop-in swap checklist

To replace system bwrap with pipe-wrap for Flatpak / Buildah / rpm-ostree:

  1. Install (creates bwrappipe-wrap symlink + man page + completions):
    make && sudo make install-native
    sudo make install-bwrap-compat   # refuses an existing foreign bwrap
  2. AppArmor (Ubuntu/Debian — unprivileged userns needs a profile):
    sudo make install-apparmor
  3. Verify the swap is active:
    bwrap --version          # → bubblewrap 0.11.x
    which bwrap              # → /usr/local/bin/bwrap (symlink)
  4. Test with real consumer workflows:
    make test-consumers
    This creates an isolated bwrap -> pipe-wrap symlink and exercises Flatpak through it when Flatpak is installed. Buildah is also tested as a rootless namespace/capability workflow when PIPE_WRAP_BUILDAH_IMAGE names a pinned local image; Buildah normally uses an OCI runtime rather than bwrap, so that result is not presented as proof that Buildah invoked pipe-wrap. make test-consumers-strict requires both tools and the local image.
  5. Roll back: run sudo make uninstall-bwrap-compat before removing the native package. Existing foreign bwrap files 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.

Platform test matrix

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 (bwrap is normally unprivileged; the setuid path is for hosts without unprivileged userns)
  • s390x clone argument ordering quirk
  • libselinux MCS category translation (raw labels still work)

Usage

pipe-wrap [OPTION...] COMMAND [ARGS...]

Examples

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

Options

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

How it works

  1. Fork a child process that will become the sandboxed process.
  2. Unshare the requested namespaces in one combined unshare() (like bwrap's clone flags). The child signals the parent; the parent writes uid_map/gid_map (mapping sandbox uid 0 to the caller) and optional --info-fd / --json-status-fd output.
  3. Bring up loopback when a private net ns was created.
  4. Make mounts slave (MS_SLAVE | MS_REC) so changes don't propagate to the host.
  5. Apply operations (recursive binds, tmpfs, proc, dev, overlays, ...) under a temporary workdir.
  6. pivot_root into the workdir so it becomes the new root filesystem.
  7. Drop capabilities, close stray fds, optionally fork a pid-1 init, then exec the target.

Requirements

  • Linux with user namespace support (CONFIG_USER_NS).

  • On Ubuntu with kernel.apparmor_restrict_unprivileged_userns=1, unprivileged sandboxes need an AppArmor profile that allows userns (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 root
    

    Or temporarily disable the restriction by writing 0 to /proc/sys/kernel/apparmor_restrict_unprivileged_userns as root.

Parity notes vs bubblewrap

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).

Implemented

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

Remaining differences (intentional or niche)

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.

Kernel edge cases (why sandboxes fail in the wild)

These are kernel/LSM policy issues, not missing pipe-wrap flags:

  1. AppArmor userns restriction (kernel.apparmor_restrict_unprivileged_userns=1 on Ubuntu ≥23.10)

    • Unprivileged processes may unshare/clone(NEWUSER) but mounts inside the userns get EACCES unless the binary has an AppArmor profile with userns, (or runs under e.g. aa-exec -p buildah).
    • Mitigation: install apparmor/pipe-wrap, or aa-exec -p buildah -- pipe-wrap .... E2E tests auto-try aa-exec.
  2. max_user_namespaces / nesting depth

    • clone(NEWUSER) fails with ENOSPC when 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.
  3. CLONE_NEWPID and 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 /proc mounts and reaping match bwrap. If clone is unavailable, a legacy fork-after-unshare path remains for pidns.
  4. 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 line 0 <caller> 1.
  5. devpts and userns root

    • Mounting devpts with newinstance typically 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.
  6. Seccomp + NO_NEW_PRIVS

    • Installing SECCOMP_MODE_FILTER without CAP_SYS_ADMIN requires PR_SET_NO_NEW_PRIVS first (we set it early and again before filters). Filters that block clone/mount will brick setup if applied too early — load only after mounts (bwrap order).
  7. File bind of /proc/self/fd/N

    • After pivot, bare /proc/self/fd/N is wrong; binding the fd symlink often returns EINVAL. We resolve the symlink via /oldroot/proc/self/fd/N and bind the real host path under /oldroot.

Tests

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.

Install helpers

  • man/pipe-wrap.1 — man page
  • completions/bash/pipe-wrap — bash completion
  • completions/zsh/_pipe-wrap — zsh completion
  • apparmor/pipe-wrap — AppArmor userns profile

About

No description, website, or topics provided.

Resources

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages