macOS: stitch a security policy into one string
Seatbelt eats a string. That string is a static baseline plus freshly built read, write, and network sections. Paths stay out of that string and go in a param table beside it.
sandbox-exec keeps placeholders only; real paths go on -D. When you carve out a path, you must block the node itself, its descendants, and moving that node away.
- Split the profile into read, write, networkmanager.rs L375
- Always use /usr/bin/sandbox-execseatbelt.rs L56
- Load four static sbpl filesseatbelt.rs L21
- Build allow and require-not from rootsseatbelt.rs L476
- Pin file-write-unlink on the write rootseatbelt.rs L508
- Exclusions write both literal and subpathseatbelt.rs L551
- Build the network section; managed with no port stays restrictedseatbelt.rs L309
- Join sections into -p textseatbelt.rs L1012
- Write paths as -DKEY=valueseatbelt.rs L1024
- User command after the double dashseatbelt.rs L1029
param keys. Real directory names land on -D. A quoted path cannot pollute SBPL syntax.subpath and mkdir .codex succeeds. Two require-not lines plus a final unlink pin the node, its descendants, and a move together.A teammate sets the repo as a writable root so the model can edit code there. The model runs mkdir .codex, hoping to drop its own config in the repo. The command fails at once; stderr says Operation not permitted. “Writable root” sounds like the whole tree. It isn’t.
Try a sneakier path. The model writes an ordinary file in the repo, then mvs the whole workspace, hoping to carry the .codex inside it out of the sandbox. That fails too. Same error: Operation not permitted.
On macOS, get_platform_sandbox returns MacosSeatbelt outright. That boolean switch only matters on Windows. The wrapper is not Codex’s own exe; it is the fixed-path /usr/bin/sandbox-exec on the system. A same-named binary on PATH does nothing.
Source: codex-rs/sandboxing/src/manager.rs lines 62–76 · codex-rs/sandboxing/src/seatbelt.rs lines 52–56
Policy text has two layers. Four .sbpl files are baked in with include_str! as the static baseline. Dynamic sections are built now from this call’s readable and writable roots. Join order is fixed: baseline, read, write, network first. Full-disk read adds preferences. Restricted read adds platform default paths. Ancestor file-write-unlink goes last, so an earlier, wider allow cannot reopen the unlink that rename needs.
Source: codex-rs/sandboxing/src/seatbelt.rs lines 21–27 · codex-rs/sandboxing/src/seatbelt.rs lines 985–1012
The baseline’s first business rule is (deny default). Anything not written down is denied. Child processes inherit this draft; bash started by sandbox-exec, and the grandchildren it forks, stay in the same rules.
Seatbelt’s kernel interface eats one SBPL string. What you control is how that string is generated. Deny by default, then open upward from this call’s roots — rewrite it in another language and you still want this. Fragment order is a contract: a narrower deny must be able to sit on a wider allow that came first.
Someone parks the project in a directory like ~/work/app (copy) — parentheses and spaces — or the path has quotes. In SBPL, parentheses, quotes, and semicolons are syntax. Once a path is interpolated into the policy body, a user directory name becomes grammar. sandbox-exec fails to parse; the model sees a wrapper error, not a sandbox deny.
Dynamic read/write rules only write keys like (param "WRITABLE_ROOT_0") into the body. Real paths become -DKEY=value, a separate argv. Excluded subpaths use the param table too. Policy body and -D must appear together; tests lock that as a contract.
Source: codex-rs/sandboxing/src/seatbelt.rs lines 1022–1028
If a security policy ends as a string, user-controlled text must not enter that string. The template holds placeholders; real paths, hosts, and ports go in another param table. This is not about Seatbelt syntax. In Python it is still template plus argv.
Write only subpath and the first create of that directory leaks — mkdir .codex succeeds. Metadata protection becomes a hollow shell. Skip file-write-unlink and rename can walk a read-only subtree out of the carve-out. Next time you still authorize the old path, but the write is on the other side of a link.
When excluding a subpath, write two require-not lines. literal covers the directory itself; subpath covers what is under it. Both go into require-all — an AND. Order does not matter; omitting literal does.
Source: codex-rs/sandboxing/src/seatbelt.rs lines 548–555
A writable root protects three top-level names by default: .git, .agents, .codex. The writable-root directory itself gets one more deny file-write-unlink. If a user-controlled symlink sits in the middle of a writable root, assembly errors out and does not follow the link.
Source: codex-rs/protocol/src/permissions.rs lines 24–33 · codex-rs/sandboxing/src/seatbelt.rs lines 504–509 · codex-rs/sandboxing/src/seatbelt.rs lines 441–450
The network is another dynamic string. Need a proxy but have no usable port: return the restricted network section plus the network baseline, not a whole-net (allow network-outbound). The baseline is still there — just no blanket outbound.
Source: codex-rs/sandboxing/src/seatbelt.rs lines 309–335
Carve out a path, and block the node itself and its descendants. Then block rename of that node. Generate all three together; lock the shape with tests. Denial copy differs across platforms: macOS often says Operation not permitted, which the existing keyword table catches. Do not assume the other two platforms say the same sentence.
Source: codex-rs/sandboxing/src/denial.rs lines 50–58
Claude Code: start by allowing reads; paths enter the text after stringify
Claude starts from (allow file-read*), adds deny, then re-allows inside deny. Later rules win. Paths are wrapped with JSON.stringify and embedded in the policy text. The wrap command finds sandbox-exec on PATH; it does not pin /usr/bin. Both sides must stop rename; Claude also emits deny file-write-unlink.
Codex starts from (deny default) and adds allow upward; paths stay on -D. You pay generator complexity; user paths never enter SBPL syntax. Claude pays “later-write order must be right,” and gets a config surface closer to allowAllExcept.
DSH and Grok: paths enter the text — escape, or fail
DSH defaults to (allow default), then (deny file-write*), taking write back. Paths are escaped for backslashes and double quotes by sbplString and embedded in -p. There is no -D table. sandbox-exec comes from PATH. On a local macOS it cannot do the “carve .codex out of a writable root” grain.
Grok does not wrap a sandbox-exec. It hands SBPL fragments to the current process. Paths still enter the text; control characters fail outright, so the sandbox cannot report active while that path was never blocked. If the kernel apply fails, it keeps running. Codex returns an error when Seatbelt prep fails; the command does not run naked.
Miss one require-not — where does it leak
In the demo, turn on write root and carve .codex; the mkdir .codex probe is red. Then turn on “subpath only”; the probe goes green. In your own words: subpath covers what is under the directory — why can’t it stop the first create of that node? The comments at seatbelt.rs lines 548–555 are enough. Do not run destructive commands in a real repo.
-D; the body keeps placeholders. A carve-out must block the node, its descendants, and a move. Seatbelt can stop operations the policy denies. It cannot stop an allow written too wide, and it cannot stop the model from editing product code inside a writable root.