SBPL is a Scheme-derived DSL. Every rule is a parenthesized expression. Comments begin with ;. The basic structure of a profile is:
(version 1)
; rules follow
(version 1) is the required version directive. As of the sources available for this book, version 1 is the only documented version. Whether newer macOS releases introduce additional versions is unspecified; verify against your target OS. Last-match semantics. This is the most important behavioral property of SBPL for profile authors. When the kernel evaluates a profile for a given operation, the last matching rule wins. An earlier rule can be overridden by a later, more specific rule for the same operation. If you write:
(allow file-read* (subpath "/etc"))
(deny file-read* (subpath "/etc/resolv.conf")) ; deny wins — it is the last matching rule
...the allow wins because it appears last. If you intend to deny a specific path within a broadly allowed subtree, the deny must come last. Default decisions. Most profiles begin with a default posture:
(deny default) ; deny-by-default: everything is blocked unless explicitly allowed
(allow default) ; allow-by-default: everything is permitted unless explicitly denied
(deny default) is the correct posture for least-privilege profiles. It means: deny every operation not covered by an explicit allow. This is a whitelist model. (allow default) is a blacklist model. It is useful for observation — run the process, see what gets denied, then whittle that list into real rules.
SBPL defines operations that correspond to categories of kernel activity. The full set is large and evolves across macOS versions. The commonly used ones:
| Operation | What it controls |
|---|---|
| file-read* | All file read operations (wildcard covers read-data, read-metadata, etc.) |
| file-read-data | Reading file contents |
| file-read-metadata | Reading file attributes, stat, directory listings |
| file-write* | All file write operations |
| file-write-data | Writing file contents |
| file-write-create | Creating new files |
| file-write-unlink | Deleting files |
| network-outbound | Initiating outbound connections |
| network-inbound | Accepting inbound connections |
| network-bind | Binding a socket to a port |
| process-exec | Executing a new process image |
| process-fork | Forking a child process |
| signal | Sending signals |
| sysctl* | Reading or writing sysctl values |
| mach-lookup | Looking up a Mach service by name |
The * wildcard in operation names matches sub-operations: file-read* covers file-read-data, file-read-metadata, and others. Be careful with wildcards in deny rules — (deny file-write*) will block more operations than you might expect.
Operations are narrowed by filters. Without a filter, a rule applies to all instances of that operation. Filesystem filters:
(literal "/etc/resolv.conf") ; exact path match
(subpath "/usr/lib") ; path and everything under it
(regex #"^/private/tmp/myapp.*") ; POSIX regex
Use literal for exact files. Use subpath for directory trees. Use regex only when the first two are insufficient. Note: macOS uses /private/tmp and /private/var as the real paths; /tmp and /var are symlinks. Profiles must use the real paths. Network filters:
(remote tcp "*:443") ; outbound TCP to any host on port 443
(remote tcp "*:80") ; outbound TCP to any host on port 80
(local tcp "*:8080") ; local TCP port 8080
(remote unix-socket (path-literal "/var/run/something.sock"))
A significant limitation: IP-level filtering in SBPL is port-scoped. The valid values for the host component are * (any host) and localhost. You cannot write an SBPL rule that allows connections only to api.example.com or only to a specific IP address range. If you need destination-specific filtering beyond port, you need a mechanism outside SBPL.
require-all, require-any, and require-not compose filters logically:
; require-all: both conditions must hold
(allow file-read*
(require-all
(subpath "/Users/you/data")
(require-not (regex #".*\.key$")))) ; allow reads, except .key files
; require-any: either condition suffices
(allow network-outbound
(require-any
(remote tcp "*:80")
(remote tcp "*:443")))
These correspond to the AND, OR, and NOT operators in the compiled DAG. Use them to express nuanced policies without writing many separate rules.
Goal: run a tool that may read standard system paths but cannot write anywhere or access the network.
; read_only.sb
(version 1)
; Log denials during development
(debug deny)
; Whitelist reads from standard system locations
(allow file-read* (subpath "/usr/share"))
(allow file-read* (subpath "/usr/lib"))
(allow file-read* (subpath "/System/Library"))
; Allow reading DNS resolver config
(allow file-read-data (literal "/etc/resolv.conf"))
(allow file-read-metadata (literal "/etc/resolv.conf"))
; Allow the process to read its own executable and libraries
(allow file-read* (subpath "/usr/bin"))
; Deny all network, all writes
(deny network*)
(deny file-write*)
; Baseline: deny everything else
(deny default)
Run it:
sandbox-exec -f read_only.sb /usr/bin/python3 -c 'import os; print(os.listdir("/usr/share"))'
If the process fails, read the system log to see which operations were denied:
log show --predicate 'subsystem == "com.apple.sandbox"' --last 1m