The Seatbelt profile: an object policy in a name language
Seatbelt judges names. ral judges objects
(object-not-name). Every line of
core/src/sandbox/macos.rs that is not a straight rendering of a grant is the
cost of saying the second in the first:
- Two spellings per path.
/tmpis a firmlink to/private/tmp, and the kernel presents a lookup under whichever spelling the process used, so every rendered name (render_paths) carries both — denies included. - No case or normalisation spellings. Measured (macOS 26.5),
literal,subpathandregexall match modulo canonical caseless equivalence, for absent names as for existing ones, on case-insensitive and case-sensitive APFS alike: a deny onSecretsrefusesmkdir secrets, and an NFC deny an NFD create. On APFS that is all the guard’s deny relation holds (denies-hold-under-every-spelling), the classes it adds for ZFS (ı ≡ I, compatibility forms) being distinct objects there, so a deny is rendered once. The residual is on the allow side: on a case-sensitive volume an allow onWorkalso admits the distinctworkto a child, which the guard refuses; SBPL has no exact match to say otherwise. - Ancestor chains.
(allow file-read* (subpath P))does not makePreachable: Seatbelt gates each directory lookup on the way, so every proper ancestor of every granted name gets(allow file-read-metadata (literal …))(emit_ancestor_metadata, overrendered_ancestors). Metadata suffices because search is all a resolver holds on an ancestor — the kernel’s,posix_spawn’s, and ral’s own walk, which opens its intermediatesO_SEARCHfor exactly this reason (path/walk.rs::open_search_dir). A read handle there would needfile-read-data, and a readable ancestor is a listable one:/Users, a home directory’s dotfile names. - Pins. A deny names a path, so renaming its parent would carry the bytes
to a name the deny does not cover; every proper ancestor of a deny within a
write prefix is
(deny file-write-unlink (literal …))—literal, neversubpath, so its entries stay mutable (holding-a-deny). Linux pays nothing here: a mount is anchored to an inode. - Order. Seatbelt is last-match-wins, so every deny is emitted after every
allow in the profile, not merely after its own.
Profileis a record whose field order is that precedence — fs allows, exec rules, exec ancestors, fs denies, pins, net — and itsDisplaywrites the sections in it. - ral’s own file. A sandboxed command must not swap the ral that confines its children, so in every profile ral’s file is write-denied and its ancestors unlink-denied, whatever the grant.
The lens this gives: when the profile and ral disagree, ask first whether ral is asking for more than the kernel’s resolver would — the walk bug was exactly that — before making the profile say more.
What build_profile renders, rule by rule
fs. Restricted renders the grant’s read prefixes — which already carry
the read floor, joined by the fold — and its write prefixes as file-read* subpaths with
their ancestor chains, the write prefixes as file-write* subpaths, and then
the denies: each exclusion denies reading, writing, linking and running, and
(deny process-exec (require-not (require-any (subpath …) …))) over the read
region refuses every program the fs does not read (plain (deny process-exec)
when it reads nothing, which SBPL cannot say as an empty require-any).
Seatbelt itself starts a program it may not read: measured on Darwin 25.5, a
Mach-O, and a script’s interpreter, need process-exec alone. So running
reads is a clause here, not a kernel property; without it a descendant ran
anything every admits, and an fs exclusion stopped nothing running. Unrestricted passes both through — (allow file-read*),
(allow file-write*) — so an exec-only grant can enter the sandbox for exec
confinement without clamping the agent’s cwd or HOME.
The macOS floor is wholesale where cherry-picking breaks tools mysteriously:
/private/etc (gitconfig, paths.d, zshenv, nix.conf; nothing user-secret sits
there unprotected — master.passwd is 0600, and Seatbelt enforces inode
permissions atop the profile), /private/var/select (xcode-select’s
developer_dir and sh; denied, build drivers misreport the EPERM as a broken
install), /private/var/run (resolv.conf’s target and the mDNSResponder
socket). User temp and workspace paths are absent by design: they arrive
through the grant.
exec. Unrestricted is (allow process-exec), so an fs-only grant does
not attenuate exec at the OS layer; Any (every) renders the same way, with
the table’s exclusions after it, and the fs section’s read bound below them.
The backend admits nothing of its own: an Intel program under Rosetta needs
process-exec on itself alone (measured), so /Library/Apple/usr is only read,
through the floor. ral’s own binary is not admitted:
it is an ordinary file, allowed where a policy clause allows it and reached by
the denies after the positives. Then every positive exec clause, then every
negative one. Positive clauses are process-exec alone, (allow process-exec (subpath|literal …)); reads, run places and support among them are the fs
section’s, which renders them whether exec is restricted or not. Support
(carriers, exec-carriers) arrives among the
positives as File allows. A negative clause is (deny process-exec …), and a
veto is (deny process-exec (regex #"/name$")), the name wherever it resolves;
an entry with both bits emits both clauses. Seatbelt matches each form
caselessly, as the guard reads an exclusion. Ordering within each group is
for stable output only; depth grants no exception, so last-match-wins puts
every exclusion over every allowance, which is the guard’s rule
(authority-is-belnap-evidence).
Exec exclusions deny no reads: reading is fs’s to decide, and a veto would
otherwise hide every same-named file. Allowed files and directories get their
ancestor chains, computed from live() so that a suppressed scope never becomes
an admitted root. Bundled tools run in the confined child, so no profile
clause admits ral for them; they read ral’s binary through fs. This layer still
cannot see argv; its job is the interpreter-bypass class the guard never sees
(sh -c, env, xargs, find -exec). A program that runs only through
every, or with exec omitted, starts only where the fs reads it, by the read
bound above. The confined child applies its profile after it has started, so its own
image is already mapped when the profile takes force.
Allowances render frozen. Every read, write and run allowance is rendered as
its frozen real path, with its firmlink twins, and as nothing else: a surface
spelling is never a predicate of its own. Seatbelt checks the resolved path of
each access against it, so a symlink put in an allowance’s place reaches
nothing. It does gate the lstat of each link a lookup passes, so the surface
an allowance was granted by is stat-able and no more: Object::walk names each
component of its lookup under its parent’s real path, admitted as
file-read-metadata literals. The spelling reaches the object while it leads
there, and nothing beneath it once retargeted. Exclusions are rendered from their re-resolved names.
There is no write freeze. An admitted directory a broad write covers stays
writable, with or without a veto, under any fs; a name veto there is advisory to
descendants, and capability::deputy names such folders
(authority-is-belnap-evidence).
net. Enforced by absence. The base admits network-outbound for nothing;
only a grant that does not say net: `offline appends macos-net.sbpl — the wholesale (allow network*),
Seatbelt having no per-address rules, and the Mach doors that are the
network: configd (proxies, DNS servers), the dnssd port, trustd. That silence
is what closes DNS: getaddrinfo reaches mDNSResponder over the UNIX socket
/private/var/run/mDNSResponder, which Seatbelt gates as network-outbound,
not mach-lookup (measured on Darwin 25.5.0: denying mach-lookup outright
still resolves names; admitting that socket alone resolves them with
mach-lookup denied). Admitting the socket for any local reason would make a
hostname an egress channel — an attacker-chosen query label leaves through a
daemon outside the sandbox — while net: `offline still read as closed.
mac_profile_denies_network_when_disabled asserts this over every rendered
rule, with an open-network positive control so it cannot pass vacuously.
The base, macos-base.sbpl
Policy-independent: true of every entry, whatever the grant. Its shape is
adapted from BrianSwift/macOSSandboxBuild’s confined.sb — deny-default plus
selective allows, the folded file-read* process-exec idiom, process-fork
beside process-exec since Seatbelt gates the two separately — with paths
inlined at build time rather than passed as scalar (param …)s, a grant’s
admits being variable-length.
- Mach doors are named, never wholesale. A Mach name is a door to a daemon
that acts on your behalf outside the profile. Named:
com.apple.dyld(dyld locates the shared cache through launchd; without it the child aborts beforemain), the notification center (libnotify, on the firstnotify_register_*), opendirectoryd’s libinfo and membership (getpwuid,~,whoami,id), and the loggers (os_log; a denial is survivable, but every libSystem process hits it and would drown the denial logsandbox::diagreads). Withheld, each with its reason as data inwithheld_doorsso the denial hint can quote it — a comment cannot reach the agent holding the EPERM, and a deliberatenoread as an accident is chased instead of worked around:launchservicesd(andlsd.*) would spawn whatever/usr/bin/opennames as an unconfined child of launchd —open -a Terminal ./payload.commandis a full escape,open https://…egress undernet: `offline; the pasteboard is a clipboard no grant mentions;SecurityServerhands out keychain items (security find-generic-password). Withholding securityd has one measured cost: cargo’s bundled libgit2 speaks TLS through SecureTransport, whose handshake reaches securityd and, denied, fails asssl handshake -9808and blames a missing revision; Apple’scurlandgitreach trust through trustd alone. So exarch tells cargo to fetch through thegitbinary (app::CONFINED_TOOL_SETTINGS) rather than admit a door that hands the agent the login keychain.mac_profile_names_every_mach_serviceholds the shape. (allow signal (target same-sandbox))binds sending only: a timeout’s kill into the sandbox lands,kill -STOPback out at the host ral does not. Descendants share the instance; two per-command children of one grant do not, so signalling between sibling jobs is denied with everything else.sysctl-read: libdispatch and libmalloc probe on init and abort if denied.(allow file-read* (literal "/")): dyld reads the root inode beforemain. ral’s walk no longer needs it, and its root too would be a search handle once cap-primitives spellsO_SEARCHinopen_ambient_dir.- Device writes —
/dev/null,/dev/zero,/dev/tty,/dev/dtracehelper(and its ioctl):2>/dev/nulland libc paths open these for write even though/devis readable. /private/var/db/timezone/icutz: libc’s firstlocaltime_rreads the ICU timezone database; it lies outside every grant prefix, and rustc, cc and ld SIGABRT intzsetwall_basicwithout it.ipc-posix-shm, and the notification center by its POSIX shared-memory name rather than a Mach one: CoreFoundation and libdispatch use both during framework init.(allow file-ioctl), unfiltered as in Xcode’s, Bazel’s and Chromium’s build sandboxes, then(deny file-ioctl (ioctl-command TIOCSTI)). The allow is there fortcsetattr: without it a full-screen child cannot raise raw mode (tcgetattrandTIOCGWINSZpass either way). It is also the known hole/dev/ttysits in: the projection carries fs, net and exec but not terminal-loan state, so the profile cannot deny tty ioctls to an ordinary Denied-terminal child while admitting them to a full-screen one. The open-time gate does not cover it, since an inherited terminal needs no open, andioctl(fd, TIOCSTI, …)would type into the unconfined parent’s input. Measured on macOS 26, Seatbelt refuses that underhid-control, notfile-ioctl: a(deny default)profile that never allowshid-controlreturnsEPERMwhatever it says aboutfile-ioctl, and allowinghid-controlalone lifts it. The base never does; the explicit deny, after the unfiltered allow, makes the refusal ours should that gate move.ioctl-commandtakes a symbol (hex is an unbound variable) and, given a number, matches only its low 16 bits — group and number, not direction or size — so write the name. ANewSessionlaunch has no controlling terminal, and unconfined XNU refuses TIOCSTI itself (EACCES,iscttyintty.c); confined, Seatbelt answers first. The foreground launch keeps the terminal, so there the profile is the only wall.
Diagnosis
sandbox::diag turns a kernel denial into a hint per operand class — a path
to read, write or exec, a network operand to the net: bit, a Mach or IPC
operand to nothing, since a door is the base’s to decide and no grant widens one
(capabilities).
Entry has one diagnosis of its own. A lineage already inside a profile —
a per-command child of a confined runner — gets EPERM from sandbox_init,
since profiles do not stack, and apply_profile reports it as such: the launch
is refused rather than run under the wider profile
(two-enforcers).