Skip to content

macOS (Seatbelt)

wardcell renders the resolved policy to SBPL — Sandbox Profile Language — and execs the command under sandbox-exec.

Terminal window
wardcell policy render -- git push # see the profile it will use

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.

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.

Kernel (Seatbelt)filesystem, process, Mach services, the one network rule
Proxyevery network decision — host, port, request, body

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:

Terminal window
wardcell run -- ./my-agent

Under capture, allow_tcp works for any client and allow_udp works for connected sockets, QUIC included.

Without an extension there is no lever but what the client does, which means the proxy environment variables:

GrantCarried byClient must
allow_httpHTTP_PROXY / HTTPS_PROXYhonour proxy env vars — nearly all do
allow_tcpALL_PROXY (SOCKS5)speak SOCKS5
allow_udpnothing—

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:

Terminal window
WARDCELL_CAPTURE=fallback wardcell run -- ./my-agent

An 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.

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.

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 --resolve and 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
}

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.

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.

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.

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:15432

Any 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.

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.

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.

shared/electron-app covers these, and a fragment that requires it inherits them:

GrantSymptom without it
file-issue-extensionsandbox_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 directoryFailed to create a ProcessSingleton for your profile directory
iokit-open-user-client IOSurfaceRootUserClientFailed to allocate IOSurface of size 1200x800, every frame
ipc-posix-shm-read-data on apple.cfprefs.preference reads fall back or fail
system-infovfs.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 registers com.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.
  • 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-exec profile nests around that is untested.
  • sandbox-exec prints a deprecation warning. It is still the supported interface for this purpose. Noise, not a problem.
  • Some operations have no DSL verb. Use a native block.