replay and gate can self-sandbox at startup using macOS Seatbelt (TrustedBSD MAC). The policy is kernel-enforced: once applied it covers the process and every child process it spawns; there is no way to escape or weaken it from user space.
# enable sandbox with minimal default policy; allow writes to one directory
replay --sandbox --allow-write ~/project/build actions.json
# allow writes to two directories
replay \
--sandbox \
--allow-write ~/project/build \
--allow-write ~/project/output \
actions.json
# load a JSON profile file (implicitly enables sandbox)
replay --sandbox-profile profile.json actions.json
# combine profile + extra CLI flag
replay --sandbox-profile profile.json --allow-write ~/project/extra actions.json
# deny outbound network (allowed by default)
replay --sandbox --allow-write ~/project/build --deny-network actions.json| Flag | Argument | Effect |
|---|---|---|
--sandbox |
— | Enable hard sandbox with minimal default policy. Allows re-reading the playlist file. |
--allow-read <path> |
directory | Grants file-read* on the directory and all descendants. Implicitly enables --sandbox. |
--allow-write <path> |
directory | Grants file-read* and file-write* on the directory and all descendants. Implicitly enables --sandbox. |
--sandbox-profile <file> |
JSON file | Loads a profile file (see JSON schema below). Implicitly enables --sandbox. |
--deny-network |
— | Denies all outbound and inbound network connections. Without this flag network is allowed. |
All flags may be repeated. At least one sandbox flag must be present to activate the sandbox — without any flags the process runs unsandboxed.
Auto-sandboxing for replay: When --sandbox is used with a playlist file (not stdin), replay automatically extracts declared paths from the playlist and adds them to the sandbox policy. Read operations (read, list, tree, glob, clone source) are added as read-only — at the file or directory referenced, not its parent — so a read /etc/passwd action does not unlock all of /etc. Write operations (create, edit, delete, clone destination, execute outputs) need parent-directory access for atomic-replace and creation, and so are added as read-write on the parent directory. The filesystem root / is rejected with a warning if any auto-discovered or --allow-* path resolves to it. You can still combine --sandbox with --allow-read, --allow-write, and --sandbox-profile to add additional paths.
All fields are optional.
{
"import_baseline": true,
"read_only": ["/path/to/dir", ...],
"read_write": ["/path/to/dir", ...],
"allow_network": true,
"allow_exec": true,
"allow_fork": true,
"extra_rules": ["(allow ...)"]
}| Field | Type | Default | Meaning |
|---|---|---|---|
import_baseline |
bool | true |
Import bsd.sb (see below). Rarely needs to be false. |
read_only |
string array | [] |
Directories where file-read* is allowed (recursively). |
read_write |
string array | [] |
Directories where file-read* and file-write* are allowed (recursively). |
allow_network |
bool | true |
Allow all network operations. Set to false to deny. Equivalent of --deny-network. |
allow_exec |
bool | true |
Allow process-exec* (launch any executable). See Execution below. |
allow_fork |
bool | true |
Allow process-fork (fork without exec). |
extra_rules |
string array | [] |
Raw SBPL rules appended verbatim at the end of the profile. Use as an escape hatch for rules not expressible via the structured fields. |
{
"read_only": ["/Users/alice/project/src"],
"read_write": ["/Users/alice/project/build"]
}{
"read_write": ["/Users/alice/project/build"],
"allow_network": false
}{
"read_write": ["/Users/alice/project/build"],
"allow_exec": false,
"allow_fork": false,
"allow_network": false
}When import_baseline is true (the default), the profile imports Apple's bsd.sb baseline. This baseline pre-allows several things needed for normal process operation:
- dyld / dynamic linker: loading system dylibs from
/usr/lib,/System/Library,/private/var/db/dyld, and similar system paths - Mach IPC: bootstrap port, task and thread ports — needed for Obj-C runtime, XPC, etc.
/devnodes:/dev/null,/dev/random,/dev/urandom,/dev/tty/tmpsymlink: onlyfile-read-metadataon the/tmpliteral itself (sostatof the symlink resolves). Neither reads nor writes to files under/tmpare allowed by the baseline — those require explicitread_onlyorread_writeentries.- Network:
bsd.sbdoes not grant network access on its own. Network is allowed or denied by an explicit rule thatreplay/gatealways emits —(allow network*)whenallow_networkis true (the default),(deny network*)when--deny-networkis passed.
Setting import_baseline: false removes all of the above. The process will likely crash during startup (dyld cannot load any dylib). Only do this if you are constructing a fully bespoke SBPL profile via extra_rules.
Binaries in /bin, /usr/bin, /sbin, /usr/sbin work without adding those directories to read_only. The (allow process-exec*) rule covers the exec syscall itself, and bsd.sb covers loading their system dylibs.
# This works without any read_only on /usr/bin:
replay --sandbox --allow-write ~/project/build actions.json
# where actions.json contains: [{"action":"execute","tool":"/usr/bin/true"}]Binaries that load dylibs outside the system paths covered by bsd.sb will fail at startup. The most common case is Python, Node, Ruby, or tools installed via Homebrew:
dyld: Library not loaded: /Library/Frameworks/Python.framework/Versions/3.x/Python
Reason: file system sandbox blocked open()
Fix: add the framework root (or the Homebrew prefix) to read_only:
{
"read_only": [
"/Library/Frameworks/Python.framework",
"/usr/local/lib"
],
"read_write": ["/Users/alice/project/build"]
}Or for Homebrew tools:
{
"read_only": ["/opt/homebrew", "/usr/local"],
"read_write": ["/Users/alice/project/build"]
}allow_exec: true emits (allow process-exec*) — a blanket allow for any executable path. There is no supported way to allow only specific binaries without dropping to raw extra_rules. If you need to restrict which tools can be launched, do it in application logic (whitelist the tool field) rather than SBPL.
replay's execute action and gate's wrapped command (after --) must specify the tool by an absolute path when --sandbox is active:
gate --sandbox -i src.c -o out.o -- /usr/bin/clang -c src.c -o out.o # OK
gate --sandbox -i src.c -o out.o -- clang -c src.c -o out.o # may fail$PATH lookup happens inside posix_spawn/NSTask after the sandbox is active, so a bare name like clang cannot be turned into an allowlist entry at startup. Tools in /bin, /usr/bin, /sbin, /usr/sbin happen to keep working with bare names because bsd.sb covers those locations, but anything else (Homebrew, Python virtualenv, custom installs) needs the absolute path so the right read entries can be added.
By default the sandbox allows network. Use --deny-network or "allow_network": false to block it.
The profile always emits an explicit network rule — (allow network*) by default, (deny network*) when denied. There is no implicit fallback: the rule is always present.
Use sandbox/sandbox-discover.py to capture sandbox violations and emit a profile.
- System log — queries the unified log database with
log showafter the command exits. Covers framework-level violations (LaunchServices, dyld, mach-lookup). - Stderr — parses the command's own error output for "Operation not permitted" / "permission denied" messages and extracts the denied paths. This catches action-level file violations that the kernel's per-process violation log rate-limiter may suppress when many startup-phase violations are generated first.
No guesswork, no sudo.
Two modes:
sandbox-exec mode (default) — wraps the command with a minimal baseline-only policy. Use for arbitrary commands with no built-in sandbox support. The command will fail or print errors; that is expected.
# Run once to discover what the command needs:
sandbox/sandbox-discover.py python3 script.py
# This writes sandbox_profile.json. Verify with the generated profile:
python3 --sandbox ... script.pyNative mode (-n) — runs the command directly without wrapping, relying on the tool's own sandbox (e.g. replay or gate with --sandbox or --sandbox-profile) to generate violations.
Note: replay must be able to read its own playlist file. If the playlist lives outside the allowed paths, replay fails to read it before any actions run. Include the playlist directory in
--allow-read.
./sandbox/sandbox-discover.py -n -- path/to/replay \
--sandbox \
--allow-read ~/project/tools \
--allow-write /tmp/out \
~/project/tools/actions.jsonAdditional flags:
./sandbox/sandbox-discover.py -o my_profile.json ... # custom output path
./sandbox/sandbox-discover.py -v ... # print violation paths; save raw log to /tmpsandbox-discover.py automatically queries the system log after a command exits. If you want to watch violations in real-time, run this in a separate terminal before invoking the tool:
log stream --style compact --predicate 'subsystem == "com.apple.sandbox" || sender == "Sandbox"'- All paths are matched with
(subpath ...)— the rule covers the given directory and every file or directory below it recursively. - Paths are canonicalized via
realpath(3)before being written into the SBPL profile. Symlinks in the path are resolved. If the path does not exist yet (e.g. an output directory to be created), canonicalization falls back to the raw path. - The kernel applies
realpathindependently when checking access. The two resolutions should agree in normal use; if a path is accessed through a different symlink chain, the sandbox may deny it.
Seatbelt uses a simple precedence model:
- macOS sandbox profile (SBPL) implicitly defaults to (deny default)
(import "bsd.sb")— allows for system operations- explicit
(allow ...)rules — added on top of the baseline - explicit
(deny ...)rules — e.g.(deny network*)overrides bsd.sb's network allow
More specific rules beat less specific rules. Because the profile always emits an explicit network rule after the bsd.sb import, the chosen allow or deny is always the operative one.
When --sandbox-profile and --allow-* flags are both present, they merge:
- The JSON profile is loaded first.
--allow-readpaths are appended toread_only.--allow-writepaths are appended toread_write.--deny-networksetsallow_network = false(overrides the JSON field).
This lets a base profile define most of the policy while the caller adds a single extra output directory via a CLI flag.
If any sandbox flag or profile is supplied and sandbox initialization fails (invalid JSON, sandbox_init_with_parameters returns an error, or the SPI is unavailable), the tool exits immediately with a non-zero status before processing any actions. There is no fallback to an unsandboxed run.
Sandbox violations at runtime do not terminate the process — the offending syscall returns EPERM to the caller, which surfaces as a normal I/O error.