# Seatbelt and bubblewrap came first

> Seatbelt and bubblewrap are not ideas from a launcher. One is a macOS kernel policy from 2007. The other is a Linux namespace tool from 2016. How each one decides, what I measured on this Mac, and the commands to try them yourself.

- URL: https://avikmukherjee.com/blog/seatbelt-and-bubblewrap
- Author: Avik Mukherjee
- Published: 2026-10-25
- Updated: 2026-10-26
- Tags: macos, linux, sandbox, seatbelt, bubblewrap, operating-systems

import {
  SandboxHistory,
  LastMatch,
  MountOrder,
  LocalhostOnly,
} from '@/components/blog/SandboxBackends'

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.

<SandboxHistory />

## 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 `exec`s 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:

```scheme
(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.

<LastMatch />

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:

```scheme
(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:

```scheme
(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.

<LocalhostOnly />

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:

```bash
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.

```bash
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.

<MountOrder />

A layout that hides a home directory and then gives back one project looks like this:

```bash
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:

```bash
bwrap --ro-bind / / --dev /dev --proc /proc --tmpfs /tmp \
  --unshare-user --unshare-pid --unshare-net --die-with-parent \
  -- /usr/bin/true
```

`true` 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:

```bash
kgate init
kgate network off
kgate doctor
cd ~/proj
kgate run -- git status
```

`doctor` 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](/blog/what-kgate-run-does) 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](https://media.blackhat.com/bh-dc-11/Blazakis/BlackHat_DC_2011_Blazakis_Apple_Sandbox-wp.pdf), Black Hat DC, January 2011. TrustedBSD MAC, the Seatbelt codename, SBPL.
- Maximilian Blochberger, [State of the Sandbox](https://svs.informatik.uni-hamburg.de/publications/2019/2019-11-Blochberger-State-of-the-Sandbox.pdf), 2019. Leopard in 2007, Lion in 2011, the Mac App Store requirement from 2012.
- Jonathan Levin, [Hack in the (sand)Box](https://papers.put.as/papers/macosx/2016/D1-JonathanLevin-TheAppleSandbox-DeeperintotheQuagmire.pdf), 2016. The 10.5 to 10.7 history in one slide.
- Alexander Larsson, [Using bubblewrap in xdg-app](https://blogs.gnome.org/alexl/2016/04/29/using-bubblewrap-in-xdg-app/), 29 April 2016.
- Jake Edge, [Sandboxing for the unprivileged with bubblewrap](https://lwn.net/Articles/686113/), LWN, May 2016. linux-user-chroot, February 2016, why it is not a general user namespace.
- Alexander Larsson, [The flatpak security model, part 1](https://blogs.gnome.org/alexl/2017/01/18/the-flatpak-security-model-part-1-the-basics/), 18 January 2017. Namespaces here, seccomp and portals above.
- Alexander Larsson, [Flatpak, a history](https://blogs.gnome.org/alexl/2018/06/20/flatpak-a-history/), 20 June 2018. xdg-app in 2014, the Bubblewrap split at Flatpak 0.6 in May 2016.
