macOS (Seatbelt)
wardcell renders the resolved policy to SBPL — Sandbox Profile Language — and
execs the command under sandbox-exec.
wardcell policy render -- git push # see the profile it will useConfinement by denial
Section titled “Confinement by denial”The profile opens (deny default) and then grants. Anything not named is
refused by the kernel.
The network half is a single rule: outbound to the proxy’s address, supplied as an SBPL parameter at exec time. No hostname ever appears in the profile — the kernel filters addresses, and host decisions belong to the proxy.
Rule ordering matters
Section titled “Rule ordering matters”Seatbelt applies the last matching rule, so order is semantics rather than presentation. wardcell emits broadest first and narrowest last, driven by the same path-specificity algorithm that resolves merge conflicts.
That is why you never hand-order rules in a fragment: the resolver sorts them.
What enforces what
Section titled “What enforces what”| Kernel (Seatbelt) | filesystem, process, Mach services, the one network rule |
| Proxy | every network decision — host, port, request, body |
Denial reporting
Section titled “Denial reporting”Seatbelt denials are written to the system log. During a policy draft run
wardcell reads them with log stream, filtered to Sandbox: … deny(, and turns
them into allow_read/allow_write entries carrying Seatbelt’s own operation
name (file-read-data).
This sees only what was denied. Linux’s tracer sees every attempt — see Linux.
Two modes: transparent capture, or proxy variables
Section titled “Two modes: transparent capture, or proxy variables”macOS gives an unprivileged process no way to redirect traffic the way Linux
does with iptables inside a namespace the sandbox already owns. PF needs
root and its rules are system-wide, not per-process. NetworkExtension is the
exception, and it is what transparent capture uses: a signed system extension,
installed once and approved once, that hands a sandboxed process’s flows to
wardcell directly. A client that ignores every proxy variable is intercepted
anyway.
Capture is the default. Install and approve Wardcell.app, and every run
after that is captured with nothing set:
wardcell run -- ./my-agentUnder capture, allow_tcp works for any client and allow_udp works for
connected sockets, QUIC included.
The fallback
Section titled “The fallback”Without an extension there is no lever but what the client does, which means the proxy environment variables:
| Grant | Carried by | Client must |
|---|---|---|
allow_http | HTTP_PROXY / HTTPS_PROXY | honour proxy env vars — nearly all do |
allow_tcp | ALL_PROXY (SOCKS5) | speak SOCKS5 |
allow_udp | nothing | — |
A run takes the fallback in exactly two cases: nothing answered the extension’s control endpoint, which settles that none is installed, or it was asked for by name:
WARDCELL_CAPTURE=fallback wardcell run -- ./my-agentAn extension that is installed and is not admitting runs refuses the run instead of degrading to it. Silently taking a weaker floor because the stronger one is broken is how a fault survives a working day — the run believes it is captured, and nothing says otherwise. Where the fault is recoverable wardcell restarts capture and says so; where it is not, the error names it.
A policy the fallback cannot carry is also refused rather than run: allow_udp
is absent there rather than weaker, so a run granting it would report success
for something it did not do.
The same rule covers one more case, which you will only meet on an out-of-date
install. Every port the capture floor opens at the kernel is that port on
every address, loopback included, and Seatbelt has no way to say otherwise —
a deny for localhost:80 after the allow does not take it back. That is
harmless while the extension is capturing loopback, because the flow is offered
to it and gated by host like any other. An extension too old to capture loopback
would leave those ports reachable locally with nothing deciding them, so a run
under one is refused, naming what it would have opened. Updating Wardcell.app
fixes it; WARDCELL_CAPTURE=fallback runs without capture instead.
Upgrading, removing, and the reboot
Section titled “Upgrading, removing, and the reboot”Wardcell carries two version numbers that move independently. The app and its
CLI have one; the system extension has its own CFBundleVersion, bumped only
when the extension’s compiled code changes. Most releases change the CLI or the
bundled policies and leave the extension untouched, so they ship the same
extension version — macOS sees nothing new to activate, and a newer .pkg
installs over the old one in place, no reboot. Keeping that version steady is
deliberate: a routine upgrade is a plain reinstall, not a restart.
When a release does change the extension its version goes up, and macOS
activates the new copy beside the old one, which it holds terminated waiting to uninstall on reboot until you restart. Until then the stale copy shadows the
new one: the new control endpoint does not answer, wardcell doctor reports the
extension “not answering” with both versions present — one [activated enabled], one [terminated waiting to uninstall on reboot] — and every run
quietly takes the fallback. A reboot clears it.
So reboot after an upgrade only when the extension itself changed. After
installing, wardcell doctor (or systemextensionsctl list) tells you which
case you are in: a single [activated enabled] version means capture is live
with nothing pending; a second version waiting to uninstall on reboot means
restart to finish the swap.
Removal is the same lifecycle. Wardcell --deactivate, or deleting
Wardcell.app, marks the extension to uninstall on the next reboot — its copy
under /Library/SystemExtensions is not gone until you restart. The
step-by-step uninstall, and clearing the config and logs, is under
Uninstall.
How a captured flow is matched to a rule
Section titled “How a captured flow is matched to a rule”Linux answers the sandbox’s own DNS and mints an address per name, in both address families, so a later connection names exactly one host. macOS cannot: every process resolves through one shared daemon, and an answer given to a sandbox is an answer given to the whole machine — measured, for about ten seconds, even with the record’s TTL set to zero.
So capture uses the name macOS itself attached to the flow, from the resolution it performed. Three properties are what make that usable:
- it arrives before any payload, so the flow is decided before the program has sent a byte;
- it survives caching — present even when no query happened at all;
- a client cannot assert it.
curl --resolveand anything else that goes around the resolver produces a flow with no name.
A name that matches a rule is then re-resolved by wardcell before anything is dialled, so the address the sandbox saw is never the one used.
None of this is per-address-family. A flow’s name is its name whichever family it resolved to, and a flow that has none is matched on its address — IPv6 included, carrying its zone, since a link-local address without one names a different destination on every interface.
Lookups are relayed, not gated. Every query a sandboxed program makes goes to your machine’s resolver untouched, whether or not your policy mentions the name. Resolving a name reaches nothing on its own — what stops the program is the connection gate, which refuses the traffic and names the rule:
wardcell: denied network-outbound on 'blocked.example.com:443'That is deliberate. Because every process on a Mac shares one resolver, a refusal wardcell invented would be the machine’s refusal: measured, a refused name is unresolvable for everything on the Mac for about a minute. Relaying costs the machine nothing and gives the program a clearer, faster answer.
A flow counts as a lookup only if what it sends is a well-formed DNS query —
parsed and length-checked, not merely addressed to port 53. Traffic to port 53
that is not a query is ordinary traffic and needs an ordinary rule, so
allow_http on some name cannot be turned into an opaque tunnel by pointing it
at that port.
What is checked about a lookup is where it is going. Your machine’s own
resolver needs no rule. A lookup may reach any address listed
in /etc/resolv.conf, which is how a resolver on the local network —
192.168.1.1 on a home router — works without every policy naming it. Any
other resolver must be named by an IP or CIDR rule, public ones included: a
query the policy allows still may not be sent wherever the program likes. A
deny_udp/deny_tcp rule overrides both. A VPN’s split-DNS resolver is the
usual case for naming one, since macOS keeps those outside that file:
net { allow_udp ["10.0.0.53:53"] allow_tcp ["10.0.0.53:53"] # for answers too large for a datagram}When a name is not available
Section titled “When a name is not available”A program that resolves for itself — a Go binary built CGO_ENABLED=0, or
anything on c-ares — connects to a bare address, and macOS attaches no name to
that flow. allow_http cannot match it. Such a flow is matched against IP and
CIDR rules instead, and denied if none match:
net { allow_tcp ["203.0.113.10:443", "203.0.113.0/24:5432"]}Scatter-send UDP — a socket that sendto()s several peers rather than
connecting to one — has no single name either, and each datagram is matched on
its own address for the same reason.
Services on your Mac’s loopback
Section titled “Services on your Mac’s loopback”A sandbox on macOS shares your Mac’s loopback — there is no separate network
namespace — so 127.0.0.1 inside it is the same 127.0.0.1 your local
databases and dev servers listen on. Under transparent capture:
- a sandbox reaches its own processes over TCP loopback on any port, with nothing in the policy. A build tool and the daemon it starts — Gradle binds a random port every time — keep working;
- everything else on loopback is denied unless the policy names it, by
address, since nothing resolves a name to reach
127.0.0.1:
net { allow_tcp ["127.0.0.1:5432"] # the Postgres running on your Mac}A refusal names the address: denied network-outbound on '127.0.0.1:5432'.
For a policy that also runs on Linux, prefer the reserved
host.internal over a literal 127.0.0.1:
allow_tcp ["host.internal:5432"]. The macOS installer adds a static
127.0.0.1 host.internal line to /etc/hosts (a plain alias, not a DNS
proxy), so a client resolves the name to loopback and the flow is gated and
dialled exactly as a literal 127.0.0.1 would be — the difference is only that
the same spelling reaches the host’s loopback on Linux too, where 127.0.0.1
cannot.
Two consequences worth knowing. A daemon you started outside the sandbox —
say a Gradle daemon left running by an ordinary build — is not the sandbox’s own,
so the sandboxed build starts a fresh one. And a run’s own UDP traffic on
loopback is gated like anyone else’s, so a UDP helper needs an allow_udp
grant. In the fallback, loopback is closed except for the proxy itself.
UDP in the fallback
Section titled “UDP in the fallback”HTTP_PROXY is an HTTP-level protocol — CONNECT opens a TCP tunnel and
there is no UDP verb, so it cannot carry UDP at all.
SOCKS5 does have a UDP ASSOCIATE command that could in principle, but
wardcell’s SOCKS5 listener answers command unsupported to it, and client
support is close to nonexistent (curl, for one, does not implement it).
Answering “unsupported” is better than a tunnel that silently drops datagrams.
So allow_udp grants nothing in the fallback — a run that grants it is refused
there rather than run without it. Transparent capture is what carries it.
Non-SOCKS TCP clients
Section titled “Non-SOCKS TCP clients”libpq, psycopg, and most MySQL and Redis drivers do not speak SOCKS5, so
ALL_PROXY does nothing for them and they hang in the fallback. Under
transparent capture they need no cooperation and simply work.
A declared forward would fix this without capture — wardcell binds a
host-loopback port per allow_tcp destination and the client connects there
instead:
db.internal:5432 → 127.0.0.1:15432Any client can do that, with no SOCKS support. The cost is that it is not transparent: the connection string has to change, because the destination is now encoded in which port you connected to rather than in the connection itself. Two destinations on the same port need two local ports.
This is designed but not implemented today — policy explain names it in
its coverage notes.
Chromium and Electron applications
Section titled “Chromium and Electron applications”wardcell run -- /Applications/Claude.app works, and so does the same command
for Codex and VS Code. Getting there needs three things a fragment for a CLI
never does. All of it was measured on macOS 27.0; the shipped
policies/shared/electron-app.sbx carries the common parts, so a new
application’s fragment only adds what is specific to it.
Why it needs --no-sandbox and a CA switch
Section titled “Why it needs --no-sandbox and a CA switch”Two command-line switches are the Seatbelt-specific part. The how-to — the
args block that appends them safely, the per-run SPKI token, and the one
application (Claude Desktop) that refuses to start with the cert switch present
— lives in Program Arguments; the reasons are here.
--no-sandbox, to start at all. macOS permits sandbox_init once per
process, and Chromium gives each child a Seatbelt profile of its own by calling
it after the child starts — which fails inside wardcell’s own profile:
sandbox initialization failed: Operation not permitted[…] GPU process isn't usable. Goodbye.shared/electron-app appends --no-sandbox (and sets ELECTRON_DISABLE_SANDBOX=1,
enough for some apps; VS Code and Codex call app.enableSandbox() and need the
switch) so a Chromium app that requires it starts with nothing on the command
line.
A CA switch, to inspect traffic. Chromium verifies TLS with its own bundled
verifier, which reads no environment variable for a CA — not SSL_CERT_FILE,
nor NODE_EXTRA_CA_CERTS (which Electron’s node side does honour), so the main
process can reach a host the window cannot. Grant such a host with allow_http
and every page load fails ERR_CERT_AUTHORITY_INVALID while the run reports no
denials at all — the policy looks correct and the UI will not load. Pass the
run’s CA fingerprint with --ignore-certificate-errors-spki-list (see Program
Arguments) and allow_http works normally. For an app
that refuses the switch, use allow_tcp, which
splices to the real server — you keep host scoping, you lose per-request gating:
net { allow_tcp ["api.anthropic.com:443", "claude.ai:443"]}--ca-certificates-file, which circulates in examples online, is not a
Chromium flag — passing it changes nothing, the handshake still fails.
Grants a Chromium application needs
Section titled “Grants a Chromium application needs”shared/electron-app covers these, and a fragment that requires it inherits
them:
| Grant | Symptom without it |
|---|---|
file-issue-extension | sandbox_extension_issue_file_to_process failed for <bundle> |
mach-register on org.chromium.crashpad. | bootstrap_check_in …crashpad…: Permission denied (1100) |
network-bind under the temp directory | Failed to create a ProcessSingleton for your profile directory |
iokit-open-user-client IOSurfaceRootUserClient | Failed to allocate IOSurface of size 1200x800, every frame |
ipc-posix-shm-read-data on apple.cfprefs. | preference reads fall back or fail |
system-info | vfs.disk-space denied before a cache write |
Two things stay per application, because they cannot be derived:
- The Mach rendezvous name. Claude registers
Q6L2SF6YDW.com.anthropic.claudefordesktop.MachPortRendezvousServer.<pid>— team identifier first. Codex registerscom.openai.codex.MachPortRendezvousServer.<pid>with none. Whether the prefix is there depends on the application’s own entitlements, so read it off a failing run rather than guessing. - Support and cache directory names. Codex uses
~/Library/Caches/Codex, named for the application rather than for its bundle identifier.
What is not covered
Section titled “What is not covered”- Updaters, XPC services and login items. A flow is attributed to a run by
asking the kernel about the source process’s Seatbelt profile, which is
inherited across
fork. An application’s own helpers — renderers, the GPU process, the network service — are therefore members automatically. Anything launchd starts is not, so its traffic is not governed by the policy. No bundled fragment has exercised an update. - TCC-gated features. Screen recording, camera, microphone and Accessibility were not exercised. A direct exec changes the responsible process, so grants you already gave the application may not apply, and new prompts may be attributed to wardcell.
- App Store applications. An App-Store-sandboxed application already has a
kernel-applied profile from its entitlements. Whether a
sandbox-execprofile nests around that is untested.
Other limitations
Section titled “Other limitations”sandbox-execprints a deprecation warning. It is still the supported interface for this purpose. Noise, not a problem.- Some operations have no DSL verb. Use a
nativeblock.