Linux (Bubblewrap)
wardcell renders the resolved policy to a bwrap invocation inside an
unprivileged user namespace.
Filesystem
Section titled “Filesystem”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 workspace policy
Section titled “The workspace policy”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 denied path that does not exist yet
Section titled “A denied path that does not exist yet”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 thesandbox write to /home/you — so a sandboxed process can create it, and a denycannot be mounted over a path that is not there. Create it yourself to closethat.Create the path and the warning goes away, with the deny enforced from then on:
mkdir -m 700 ~/.sshtouch ~/.zshrcThis 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.
Learn mode sees the whole filesystem
Section titled “Learn mode sees the whole filesystem”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.
Network: confinement by topology
Section titled “Network: confinement by topology”The sandbox runs in a network namespace with no route to anything:
wardcell0 10.255.255.1/32 arp off fd00:a11c::1/128default 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/PREROUTINGwith 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
AandAAAAare 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.
Services on loopback
Section titled “Services on loopback”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, so127.0.0.1:5432inside the sandbox is the sandbox’s loopback, not the Postgres running on your machine. Unlike macOS, a literal127.0.0.1grant can never reach the host here — the isolation is topological, not a policy decision. To reach a host service, name it with the reservedhost.internal, which the proxy dials at the host’s own127.0.0.1on 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.
No host privilege
Section titled “No host privilege”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 namespaceCAP_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 permittedThe profile is four lines of substance:
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,}EOFsudo 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:
sysctl user.max_user_namespaces # want > 0Debian’s old kernel.unprivileged_userns_clone is gone from current Ubuntu;
if your kernel still has it, it has to be 1.
Requirements
Section titled “Requirements”| Missing means | |
|---|---|
bwrap | bwrap: command not found |
iptables | network setup fails; the run is refused |
| unprivileged user namespaces | bwrap: No permissions to create new namespace, or on Ubuntu the uid_map failure above |
xt_TPROXY | allow_udp grants are rejected rather than silently unenforced |
Every one fails closed. See Troubleshooting.
Learn mode
Section titled “Learn mode”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 running | open openat openat2 creat execve execveat connect |
| Listing a directory | getdents64 getdents |
| Creating a name | mkdir(at) mknod(at) symlink(at) link(at) |
| Removing one | unlink(at) rmdir |
| Moving one | rename renameat renameat2 |
| Changing a file in place | truncate chmod fchmodat chown lchown fchownat utimensat futimesat setxattr lsetxattr removexattr lremovexattr |
| Looking without reading | stat 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 busyif the name is itself a mount point. So arenameinto 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 -pon an existing directory, orecho > configon 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/atodst/bdrafts 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 withInvalid 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 underCONFLICTS WITH THE CURRENT POLICYwith the fragment it is in: removing it, as named, is part of applying the draft. - A write probe is a write.
test -wandaccess(W_OK)draft write access: a read-only mount fails the check withEROFS, even for root. - Looking needs a read. A path only stat’d drafts as
allow_read, notallow_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. /devand/procare never drafted. Every sandbox gets its own, and a policy grant there is mountednodev: a grant on/dev/nullwould 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.