Seatbelt and bubblewrap came first
I spent the last stretch of work writing a small launcher, kgate, that runs one command inside a tighter box than the shell I typed it from. The box is not mine. On a Mac it is Seatbelt, the kernel policy behind sandbox-exec. On Linux it is bubblewrap, the program Flatpak uses to build a private mount namespace.
Those two are older than the launcher, and they are not a pair someone designed together. They are the answer each kernel already had for the same request: run this process, and do not let it see the rest of my home directory.
This post is the history of that answer, the part I got wrong until I measured it, and the commands you can run without installing anything I wrote.
Mac OS X 10.5 adds an opt-in sandbox, codenamed Seatbelt. Apple uses it for system processes. The policy language is a small Scheme dialect, later called SBPL. Third-party apps mostly ignore it.
Same request, different kernel#
Both tools start from a process you were about to run anyway. Neither creates a user account. Neither is a virtual machine. The process keeps your uid.
What they differ on is where the decision lives.
Seatbelt is a policy module inside XNU. sandbox-exec compiles a small profile and the kernel checks it on the operations that matter: reading a file, writing a file, making a network connection, executing a binary. Dionysus Blazakis described this at Black Hat DC in 2011. The module implements the TrustedBSD mandatory access control hooks that XNU already had. The codename before "sandbox" was Seatbelt. The profile language is still the Scheme-like dialect from that era.
bubblewrap never asks the kernel to evaluate a policy file. It builds a new view of the filesystem with bind mounts, then execs your program inside that view. If a directory was not mounted, the process cannot open it, because it is not there. Alexander Larsson wrote this down in April 2016, when the helper inside xdg-app became its own project. The LWN piece the following month traces the code further back, to Colin Walters's linux-user-chroot.
A launcher's job is to pick one of those two and hand it a description of the box. The description is not portable. A Seatbelt profile does nothing on Linux. A bwrap argument list does nothing on a Mac.
Seatbelt, from Leopard to a command you can still type#
Mac OS X 10.5 Leopard, in 2007, is where Seatbelt shows up as something Apple itself uses. It is opt-in. A system daemon can be confined. A normal app is not, unless someone goes looking for the undocumented profile language.
Lion, 10.7 in 2011, is the turn. App Sandbox is the supported face of the same mechanism: a container directory under ~/Library/Containers, entitlements instead of a hand-written profile, and a dialog when an app wants a file outside that container. From 2012 the Mac App Store requires it. Software you download from a project's own site still does not have to use it.
sandbox-exec is the other face. You pass it a profile and a command. The man page has called it deprecated for years. As of this month it is still the documented way to put an arbitrary process, one you did not ship through the App Store, under that kernel policy. I am writing this on macOS 27, on Apple Silicon, and sandbox-exec is on the machine.
The profile I keep coming back to is short enough to read:
(version 1)
(allow default)
(deny file-write*)
(deny file-read* (subpath "/Users/you"))
(allow file-write* (subpath "/private/tmp"))
(deny file-write* (subpath "/Users/you"))
(allow file-read* (subpath "/Users/you/proj"))
(allow file-write* (subpath "/Users/you/proj"))
(deny network-outbound)
(allow network-outbound (remote ip "localhost:*"))Two facts about this language took me longer than the syntax.
The first is evaluation order. Seatbelt does not stop at the first match. The last matching rule wins. (allow default) really does allow the operation, and then a later (deny ...) takes it back if that later rule also matches. This is why the home write deny appears twice in the profile above. The allow for /private/tmp has to come after the blanket write deny, or /tmp stays unwritable. And the home deny has to come after the /tmp allow. On this Mac, /tmp is a firmlink of /private/tmp, and a home directory created under /private/var/folders can sit inside a tree you just opened. If the home deny is only above the temp allow, opening temp reopens home.
Walking the profile from top to bottom.
The second fact is that the kernel sees a different path than the one you typed. /etc/passwd is /private/etc/passwd. /tmp and /private/tmp are the same directory. A rule written against the spelling you used in the shell can fail to match the spelling the kernel checks, and the last match is then some broader allow you did not think was relevant. I resolve the path with realpath before it goes into a profile. A symlink whose resolved path is a different place than the path I was handed is a reason to refuse the launch, not a path to quietly rewrite.
The network rule I had to throw away#
network: off sounds like it should mean no network. The profile I tried first did this:
(deny network-outbound)
(allow network-outbound (local ip))DNS names failed. google.com came back as "nodename nor servname provided, or not known." I treated that as the network being off. It was not. A TCP connection to 142.250.134.139:443, an address Google was answering on, succeeded from inside the sandbox. (local ip) with no address matched the public local address of the machine, not only the loopback interface.
A deny with no allow after it does block that address. It also blocks 127.0.0.1. I wanted loopback, because tools talk to themselves there, and I did not want the public internet.
Seatbelt will not take an arbitrary host. sandbox-exec exits 65 with host must be * or localhost if you write (remote ip "127.0.0.1:*") or a hostname. The address filter that it does accept, and that I checked on this machine, is:
(deny network-outbound)
(allow network-outbound (remote ip "localhost:*"))127.0.0.1:9 and [::1]:9 then return connection refused, which means the sandbox let the packet out and nothing was listening. The same public Google address, and 2001:4860:4860::8888 port 443, return operation not permitted. (local ip "localhost:*") does not do this. The public IPv4 address still connected.
Allowed does not mean something is listening. 127.0.0.1:9 comes back connection refused. The public address comes back operation not permitted.
That is also why a per-host allow list is not a macOS feature you can switch on. The filter has two legal hosts, * and localhost. "Only api.github.com" cannot be said in a profile this kernel will install. On a Mac the choice I can actually enforce is off, or open.
Try it without a launcher#
A file named tight.sb with the profile above, paths edited to your machine:
sandbox-exec -f tight.sb /usr/bin/true
sandbox-exec -f tight.sb /usr/bin/touch "$HOME/should-fail"true exits 0. touch in your home directory exits non-zero and prints Operation not permitted. The file is not created. That denial comes from the kernel. Nothing in user space has to notice.
A few things this does not do:
- It does not hide a binary you only denied by path if the program can already read a copy of that binary from somewhere the profile allows. Path rules match paths.
- It does not apply to a process that is already running. The profile is installed at
exec. - It is not App Sandbox. You do not get a container, a TCC prompt, or an entitlement. You get the rules you wrote.
bubblewrap, from a helper to a command#
Linux has had pieces of this for a long time: chroot, mount namespaces, seccomp, SELinux, AppArmor. The piece that made a desktop sandbox practical for someone who is not root was user namespaces. Linux 3.8, in 2013, lets a process be root inside a namespace it created, and still be itself outside. Inside, it can bind-mount. Outside, it cannot grant itself anything it did not already have. PR_SET_NO_NEW_PRIVS closes the usual way out of a chroot, the setuid binary.
xdg-app, which became Flatpak, used that. The first helper was tied to xdg-app's runtime layout. In February 2016 the same developers pulled it out. Larsson's announcement post shows the shape that is still the whole interface: an empty root, then flags that mount the pieces you want, then the command.
bwrap --ro-bind / / --tmpfs /tmp touch "$HOME/foo"That touch fails because / was mounted read-only. No policy file. The mount is the policy.
Flatpak 0.6 in May 2016 is the release that depends on this split. Where user namespaces are disabled, bubblewrap can be built setuid instead, and it still only exposes the operations it was written to expose. It does not hand you a general root shell. Larsson's 2017 note on Flatpak's security model is the one I wish I had read first: bubblewrap builds the filesystem and the namespaces. Flatpak then adds a seccomp filter and portals. If you invoke bwrap yourself, you do not get the filter unless you add one.
The mount rule is the mirror of Seatbelt's last match. Later mounts cover earlier ones.
The process can read the host, and cannot write anywhere. The project is in there too.
A layout that hides a home directory and then gives back one project looks like this:
bwrap \
--ro-bind / / \
--dev /dev \
--proc /proc \
--tmpfs /tmp \
--unshare-user \
--unshare-pid \
--die-with-parent \
--tmpfs "$HOME" \
--bind "$PWD" "$PWD" \
--ro-bind /dev/null "$PWD/.env" \
-- "$PWD/your-program"--ro-bind / / is the floor: read the system, write nothing. --tmpfs "$HOME" hides the real home, including the project, so the --bind of $PWD has to come after it. --ro-bind /dev/null on one file covers that file even though the directory around it is writable. --die-with-parent is there so the sandbox does not outlive the launcher. The -- is the end of bwrap's own flags.
--unshare-net is the network switch. With it, the process has a network namespace with no interfaces that reach the host. That is the Linux version of network off, and it is enforceable. Names fail to resolve because there is no resolver, and a raw IP has nowhere to go.
A host allow list is the part bubblewrap will not do alone. The usual construction, the one rootless containers use, is slirp4netns: a user-space TCP/IP stack attached to the sandboxed process, guest address 10.0.2.100, DNS at 10.0.2.3. You then put an iptables rule in that namespace which allows DNS only to 10.0.2.3 and allows outbound traffic only to the addresses you resolved for the names you meant to allow. The resolve has to happen inside the namespace. A name resolved on the host can be a different address by the time the guest connects, and the filter would be checking the wrong one.
I have not executed that path on this Mac. bubblewrap is a Linux program. The argument list is tested. The packets are not, until the command runs on a machine that has bwrap, slirp4netns, and iptables.
Try it on Linux#
The read-only root from Larsson's post is enough to see the mechanism:
bwrap --ro-bind / / --dev /dev --proc /proc --tmpfs /tmp \
--unshare-user --unshare-pid --unshare-net --die-with-parent \
-- /usr/bin/truetrue exits 0. Add a write under $HOME and it fails with a read-only filesystem, which is a different errno from Seatbelt's EPERM and the same outcome. Add --bind "$PWD" "$PWD" after a --tmpfs on the home directory and the project is writable again.
What this does not do:
- It does not stop a program from using a capability you mounted in. If you
--bind /writable, you have a chroot-shaped process with your files. - It does not filter system calls. That is seccomp, and it is a separate program or a library.
- It does not survive a kernel bug in user namespaces. The namespace is the boundary. Flatpak's setuid fallback exists because some distributions decided that boundary was too wide and turned unprivileged user namespaces off.
Where a launcher fits#
kgate is a policy file plus the two commands above. ~/.kgate/config.yaml says which directories are visible, which commands should be refused before exec, and whether the network is off, open, or a host list. A project .kgate.yaml can only make that smaller. The launch refuses to start if the working directory is your home directory, because a sandbox whose writable root is $HOME is not a sandbox.
On this Mac the commands I actually run are:
kgate init
kgate network off
kgate doctor
cd ~/proj
kgate run -- git statusdoctor is the part that knows the kernel. With the config still on restricted, it exits 1 and says a per-host list cannot be installed by sandbox-exec. network off is the mode that produces the localhost rule from earlier. network open runs, and doctor calls that degraded, because the filesystem box is still there and the network box is not.
kgate run -- git status is the whole product at the call site. The -- is the same -- you would pass to sandbox-exec or bwrap. Everything after it is the command. An agent harness wraps the shell with that prefix. If the model is the one expected to remember to type kgate, it is also the one that can forget.
A command denied by the policy never starts. curl is on that list here, so kgate run -- curl https://example.com exits before sandbox-exec, and the audit line says block. A command that starts and then touches a file the kernel denies is different. The audit line says launch. The Operation not permitted is the kernel, after the process existed. I do not turn that into a second log event. The profile already decided.
Secrets in the output are a user-space problem. The kernel does not know what an API key looks like. The launcher masks a few known prefixes on the way to the terminal. That is not Seatbelt and it is not bubblewrap. It is a filter on a pipe.
What I would not claim#
Seatbelt does not know about bubblewrap. bubblewrap does not know about Seatbelt. A config key with the same name on both sides is a convenience in the launcher, and on macOS one of those values, a list of hosts, is a value the kernel will refuse to load.
The measurements in the network section are from one Apple Silicon Mac running macOS 27, in October 2026, against one Google address that answered that day. The profile rules are the thing to copy, not the address.
If you only take one command from this page, make it the one for the kernel you have. sandbox-exec -f tight.sb on a Mac. bwrap --ro-bind / / on Linux. The launcher is a way to stop writing those flags by hand every time. The flags are the feature.
The next post follows one command through that launcher. What is refused before exec, what the kernel does after, and the Linux host list this page only sketches.
Sources#
- Dionysus Blazakis, The Apple Sandbox, Black Hat DC, January 2011. TrustedBSD MAC, the Seatbelt codename, SBPL.
- Maximilian Blochberger, State of the Sandbox, 2019. Leopard in 2007, Lion in 2011, the Mac App Store requirement from 2012.
- Jonathan Levin, Hack in the (sand)Box, 2016. The 10.5 to 10.7 history in one slide.
- Alexander Larsson, Using bubblewrap in xdg-app, 29 April 2016.
- Jake Edge, Sandboxing for the unprivileged with bubblewrap, LWN, May 2016. linux-user-chroot, February 2016, why it is not a general user namespace.
- Alexander Larsson, The flatpak security model, part 1, 18 January 2017. Namespaces here, seccomp and portals above.
- Alexander Larsson, Flatpak, a history, 20 June 2018. xdg-app in 2014, the Bubblewrap split at Flatpak 0.6 in May 2016.