Skip to content

Linux (Bubblewrap)

wardcell renders the resolved policy to a bwrap invocation inside an unprivileged user namespace.

bwrap builds the sandbox’s filesystem view from bind mounts: allowed paths are mounted in, everything else simply is not there. A denied path is not protected — it is absent.

Rules that name a symlink are mounted at the resolved inode, so a symlinked config cannot be used to reach outside its grant. The granted name is recreated as a symlink to it, so both names work: with ~/.gitconfig pointing at ~/dotfiles/gitconfig, a rule on ~/.gitconfig mounts the real file and links the granted name to it, while the rest of ~/dotfiles stays out.

Your home directory is no exception. ~/.gitconfig granted by a rule appears at ~/.gitconfig inside the sandbox, in a home directory that holds nothing else — ~/.aws, ~/.ssh and the rest are not there unless something grants them.

The space between grants is the sandbox’s own in-memory filesystem, and it is mounted read-only. A write to a path no rule granted fails with Read-only file system, which the command can report and you can act on. Without that it would succeed into memory and be discarded when the run ends — the command reporting success and losing the data, with nothing denied and nothing to search for. Every mount the policy does make keeps the access it was given, so this closes only the gaps.

The working directory is granted by base.sbx, so it is mounted like any other grant. Under a policy that does not grant it, the sandbox still starts, with an empty directory to run in: a process needs somewhere to be, and it sees nothing of the real directory of that name. That holds wherever the working directory is, /tmp included — a working directory under /tmp is not brought in from the host for being there.

The .wardcell.sbx layer 3 came from is mounted read-only, after every mount the policy itself makes, so no grant can leave it writable: a run cannot rewrite the file that decides what the next run in that directory may do. Where the workspace has no policy file yet, /dev/null is mounted read-only at the path one would be written to, so a run cannot create one either.

The mount is made only where the policy would otherwise let the run write there, which is the only case it changes anything. It also means the path is a mount of its own, so renaming onto it or deleting it fails with “Device or resource busy” rather than a permission error. The path is always the file: a workspace policy that is a symlink is refused when the policy is resolved, so there is no chain here for a mount to half-cover.

What a mount cannot do is deny a name wherever it might appear. A sandboxed process on Linux can create sub/.wardcell.sbx in a directory it makes — macOS denies that by name, and there is no equivalent here. It does not become the policy for the run that wrote it, nor for any run started where that one was: the file in force is the one in the directory a command runs in, and that file is mounted read-only. It would apply to a run started in that subdirectory, which is the case trust would close.

Mounting needs the path to be there, and bwrap creates it — an empty file, in the workspace on the host, where the policy file itself would go. The run removes it when it ends, unless something has been written into it in the meantime; a file that was already there is never touched. A run killed outright can leave it behind, and an empty .wardcell.sbx is read as no policy at all, exactly as no file is.

A deny is enforced by mounting over the path: an empty file, or an empty directory. That needs the path to exist. When it does not, and the policy lets the sandbox write to a directory above it, the deny cannot be enforced — the sandboxed process can create the path itself.

wardcell does not create the path for you. bwrap creates mount points inside the directory it binds, so the file would be made in your real home and left there: an empty ~/.ssh file means ssh-keygen afterwards fails with “Not a directory”.

Instead, a run warns when the path is one worth knowing about — ~/.ssh, ~/.gnupg, ~/.aws, ~/.azure, ~/.config/gcloud, and the shell startup files:

wardcell: warning: /home/you/.ssh does not exist, and this policy lets the
sandbox write to /home/you — so a sandboxed process can create it, and a deny
cannot be mounted over a path that is not there. Create it yourself to close
that.

Create the path and the warning goes away, with the deny enforced from then on:

Terminal window
mkdir -m 700 ~/.ssh
touch ~/.zshrc

This comes up when a policy grants write access to a directory above the denied path — most often by running from your home directory, since the working directory is writable and is then home itself. Running from a project directory instead means nothing grants write access to home, and nothing can be created there.

macOS is unaffected: Seatbelt denies a path by rule, whether or not anything is there.

policy draft records a refused access and lets it through, so the tool carries on and what it does next is still observed. That needs the file to be there to pass through to: a read failing with “no such file” would stop the command at its first ungranted path and hide everything it would have done afterwards.

So a draft mounts the host’s filesystem as it is, and an enforcing run mounts only what the policy grants. The policy’s denies are still applied over it in both, so a denied path stays unreadable while drafting and the draft reports it without proposing a grant for it. A draft is a way to find out what a command needs, not a way to contain it — the command can read and write whatever you can, so run it on something you were prepared to run unsandboxed, and read the fragment it prints before folding it into a policy.

The sandbox runs in a network namespace with no route to anything:

wardcell0 10.255.255.1/32 arp off
fd00:a11c::1/128
default via wardcell0 (IPv4)
default dev wardcell0 (IPv6)

A dummy interface, a default route pointing at itself, and arp off. Nothing can be delivered. The device exists only so the kernel has somewhere to route, which is what makes the nat/OUTPUT redirect fire.

The IPv6 default is a device route rather than a gateway one because the kernel refuses a v6 default whose gateway is a local address — which is exactly what the v4 route does.

From there:

  • TCP is DNAT’d to an in-namespace listener, which reads the original destination with SO_ORIGINAL_DST.
  • UDP uses TPROXY in mangle/PREROUTING with an fwmark, which preserves the original destination where DNAT would have overwritten it.
  • DNS is redirected to a host-side resolver that gates names and mints fake IPs, so a later connection can be attributed to the exact name that was looked up. Both A and AAAA are answered, from one allocation per name, so a client’s choice of address family does not change what it may reach.

Every one of those applies to IPv6 as well as IPv4, through the same rules in ip6tables and the same listener ports. Where a kernel has IPv6 compiled out, or has no ip6tables or no nat table for it, wardcell withdraws the v6 address and route again so the kernel refuses a connection immediately, adds a reject rule as well, and says so on stderr. The sandbox still works; it just cannot reach a destination that is IPv6-only, and it finds that out at once rather than by waiting.

The single channel out of the namespace is a bind-mounted Unix socket served by wardcell on the host. If it is absent or the server dies, there is no fallback path — because there is no network.

The sandbox has its own loopback — the namespace’s lo, brought up at setup and separate from your machine’s 127.0.0.1. Loopback is deliberately left out of the redirect (-p tcp ! -d 127.0.0.0/8), so:

  • A sandbox reaches its own processes over loopback on any port, with nothing in the policy. A build tool and the daemon it starts — a dev server on a random port, a language server — keep working exactly as they do outside, and no grant is involved. This is sandbox-to-sandbox and has nothing to do with expose, which is the inbound direction (something outside reaching a listener inside).

  • A service on your machine’s loopback is not reached by 127.0.0.1. The namespace has no route off the box, so 127.0.0.1:5432 inside the sandbox is the sandbox’s loopback, not the Postgres running on your machine. Unlike macOS, a literal 127.0.0.1 grant can never reach the host here — the isolation is topological, not a policy decision. To reach a host service, name it with the reserved host.internal, which the proxy dials at the host’s own 127.0.0.1 on the sandbox’s behalf:

    net {
    allow_tcp ["host.internal:5432"] # the host's Postgres, not the sandbox's
    }

    This is the same spelling that works on macOS, so a policy and a client connection string are portable across both.

The whole thing runs as you:

unshare --user --map-root-user
→ bwrap --unshare-net --cap-add CAP_NET_ADMIN --cap-add CAP_NET_RAW
→ capabilities that exist only inside the namespace

CAP_NET_ADMIN inside a user namespace lets wardcell configure that namespace’s networking and nothing else. No veth pair on the host, no MASQUERADE rule, no ip netns, no sysctl changes, no setuid binary.

Capabilities are then dropped irreversibly — bounding set first, then the permitted and effective sets, plus no_new_privs — before your command execs.

Unprivileged user namespaces, and AppArmor

Section titled “Unprivileged user namespaces, and AppArmor”

Ubuntu 23.10 and later refuse an unprivileged user namespace to any process that is not confined by an AppArmor profile carrying the userns rule. Since wardcell builds every sandbox inside one, a stock Ubuntu 24.04 machine refuses to run it until a profile is installed.

The refusal is not clean, which is what makes it confusing. The namespace is created and then stripped of capabilities, so nothing complains about namespaces at all — the failure surfaces one step later, as

unshare: write failed /proc/self/uid_map: Operation not permitted

The profile is four lines of substance:

Terminal window
sudo tee /etc/apparmor.d/wardcell >/dev/null <<'EOF'
abi <abi/4.0>,
include <tunables/global>
profile wardcell /usr/{bin,local/bin}/wardcell flags=(unconfined) {
userns,
/usr/bin/unshare ix,
/usr/bin/bwrap ix,
}
EOF
sudo apparmor_parser -r /etc/apparmor.d/wardcell

(From a source checkout, the same file with its reasoning in the header is at releases/linux/apparmor/wardcell.)

userns is the grant. The two ix rules carry it across the execs that actually create the namespace — unshare and bwrap — which would otherwise be matched as unconfined binaries and lose it. flags=(unconfined) means the profile confines nothing else: it exists to name one program and give it one permission.

Check the attachment path. A profile attaches to an executable. The two paths above are the two the packages use: /usr/bin/wardcell from the .deb or .rpm, /usr/local/bin/wardcell from a manual install. If yours lives anywhere else — ~/.cargo/bin, /opt — edit that path or the profile silently never applies.

Other distributions restrict the same thing with a different knob. On RHEL-family kernels it is a count rather than a policy:

Terminal window
sysctl user.max_user_namespaces # want > 0

Debian’s old kernel.unprivileged_userns_clone is gone from current Ubuntu; if your kernel still has it, it has to be 1.

Missing means
bwrapbwrap: command not found
iptablesnetwork setup fails; the run is refused
unprivileged user namespacesbwrap: No permissions to create new namespace, or on Ubuntu the uid_map failure above
xt_TPROXYallow_udp grants are rejected rather than silently unenforced

Every one fails closed. See Troubleshooting.

policy draft installs a seccomp-unotify filter in the sandboxed child before execve, and the trusted parent serves the notification fd. Watched syscalls:

Opening and runningopen openat openat2 creat execve execveat connect
Listing a directorygetdents64 getdents
Creating a namemkdir(at) mknod(at) symlink(at) link(at)
Removing oneunlink(at) rmdir
Moving onerename renameat renameat2
Changing a file in placetruncate chmod fchmodat chown lchown fchownat utimensat futimesat setxattr lsetxattr removexattr lremovexattr
Looking without readingstat lstat newfstatat statx access faccessat(2) readlink(at) getxattr lgetxattr listxattr llistxattr

Opening a file is the smaller half of what a tool does to save one. The usual shape is: create the directory, write a temporary file, rename it into place — and only the temporary file is ever opened. A draft that watched opens alone would grant a path that no longer exists when the command finishes, and leave out both the directory and the destination.

A draft has to propose grants that work once applied, and on Linux that shapes several of them:

  • A new or removed name is granted by its directory. Creating, removing or renaming a file writes the directory that holds it — and fails with Device or resource busy if the name is itself a mount point. So a rename into place drafts write access to the directory, never a grant on the file: that would make the file a mount point, and the rename that worked while drafting would be refused in the run the draft was for. Creating something that already exists is not a new name — mkdir -p on an existing directory, or echo > config on an existing file — so it needs no directory access.
  • A move between directories is granted by one directory holding both names. Neither a rename nor a hard link can cross a mount, and every grant is its own, so renaming or linking src/a to dst/b drafts write access to the directory holding both — noted in the draft, since it can be broader than either name. If the two share nothing short of /, it is flagged for review instead of granted.
  • Nothing is granted inside another grant. A path the draft also covers with a broader grant is folded into it, its operations kept in the comment, because a second mount inside the first pins that path in the same way. On Linux a read folds into a covering write too, since a writable mount is readable.
  • Mounts the policy already has are named, not ignored. A file the current policy grants on its own is a separate mount, and a drafted writable directory around it does not change that — saving over it still fails with Device or resource busy, and hard-linking from it fails with Invalid cross-device link, even to a name right beside it: sharing a directory is not sharing a mount. The draft cannot remove a grant from another fragment, so it lists each one under CONFLICTS WITH THE CURRENT POLICY with the fragment it is in: removing it, as named, is part of applying the draft.
  • A write probe is a write. test -w and access(W_OK) draft write access: a read-only mount fails the check with EROFS, even for root.
  • Looking needs a read. A path only stat’d drafts as allow_read, not allow_read_metadata: bwrap can only mount a path or leave it out, and a metadata-only rule mounts nothing.
  • Directories on the way are free; listed directories are not. bwrap creates every directory leading to a mounted path, so one only opened or stat’d on the way to another grant is not granted — that would mount everything in it — and it is listed commented instead. Listing a directory (getdents64, traced separately from opening it) is a read of what is in it, and is granted: without that, the listing would silently show only the entries mounted on their own.
  • /dev and /proc are never drafted. Every sandbox gets its own, and a policy grant there is mounted nodev: a grant on /dev/null would leave a node that refuses to open.

A syscall this architecture does not have is skipped rather than refused — aarch64 has only the *at forms.

The filter is installed after the user namespace exists — that is what supplies the CAP_SYS_ADMIN SECCOMP_FILTER_FLAG_NEW_LISTENER needs — and every call is allowed through after being recorded.

A draft mounts the host’s filesystem as it is, /tmp included — tools build there, and a fresh empty /tmp would mean a command’s own scratch files were missing, so it would take a different path through its code and the draft would record what it did rather than what it does. /run is the one exception: it stays a filesystem of the sandbox’s own in both modes, because wardcell mounts the proxy’s CA certificate and its management socket beneath it and creating those mount points on the real /run would need privilege you may not have.

Unlike the macOS log scrape, this sees every attempt, not just the denied ones, so a draft run reports what a program reached for even where policy would have permitted it.