3. Writing Your First Profile

SBPL fundamentals

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.

Core operations

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.

Filters

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.

Meta-filters

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.

A minimal default-deny profile

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