OpenAI Codex · Exec boundary

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.

Course goalAfter this lesson you can explain three things. On macOS the platform sandbox is on by default — no Windows-style master switch. The policy body handed to 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.
Try it first · Flip fragments, watch the menu grow
Policy bench: add fragments on the left; watch the body, ingredients, and allow range on the right
Fragments
Flip fragments and watch the policy body and the probes on the right.
Network
Experiment
Policy body · placeholders only
Ingredients and probes
Logic trail · each animation step maps to a source span
  1. Split the profile into read, write, networkmanager.rs L375
  2. Always use /usr/bin/sandbox-execseatbelt.rs L56
  3. Load four static sbpl filesseatbelt.rs L21
  4. Build allow and require-not from rootsseatbelt.rs L476
  5. Pin file-write-unlink on the write rootseatbelt.rs L508
  6. Exclusions write both literal and subpathseatbelt.rs L551
  7. Build the network section; managed with no port stays restrictedseatbelt.rs L309
  8. Join sections into -p textseatbelt.rs L1012
  9. Write paths as -DKEY=valueseatbelt.rs L1024
  10. User command after the double dashseatbelt.rs L1029
Hit Play to see a policy assembled from fragments. Or flip fragments yourself.
Body and paths stay apartThe menu only writes param keys. Real directory names land on -D. A quoted path cannot pollute SBPL syntax.
Miss one carve-out rule and it leaksKeep only subpath and mkdir .codex succeeds. Two require-not lines plus a final unlink pin the node, its descendants, and a move together.
Narrow the network firstIf you need a proxy but have no port, do not allow the whole net. The network baseline is still there — just no blanket outbound.
Teaching sketch: paths and probes are course fixtures, to show how fragments change the policy text and the allow range. Line numbers on the logic trail match openai/codex commit 4f39251a01.
Idea 1 · Close the gate first, then open by fragment
What problem it solves

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.

What the idea is

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.

Permission profile Read roots · write roots · network Four static sbpl files Dynamic read/write rules Dynamic network section join -p text -D params sandbox-exec Then exec the user command
Teaching diagram: one permission profile is split into fragments, joined into argv, then handed to that fixed-path wrapper on the system.
Why it lasts

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.

Idea 2 · Paths go in the ingredients list, not the menu
What problem it solves

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.

What the idea is

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

Path written into the body subpath "/tmp/app(copy)" Parentheses become syntax Wrapper parse fails Path goes in the param table Body writes param only WRITABLE_ROOT_0 -D as its own argv value may contain quotes Syntax stays clean
Teaching contrast: the left hands the directory name to the parser; the right hands it only a key.
Why it lasts

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.

Idea 3 · A carve-out must block the node, descendants, and a move
What problem it solves

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.

What the idea is

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

literal Block this node mkdir .codex When the directory itself does not exist yet subpath may not match subpath Block its descendants Files after it is created literal only Writes underneath leak unlink Block moving the boundary rm then ln -s Last among the sections So a wider allow cannot reopen it
Teaching diagram: the three rules are generated together; miss one and one bypass leaks.
Paths go in the param table. A carve-out needs all three.
Why it lasts

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

Side-by-side · whether the path enters this string

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.

Source checked on both sides · 2026-08-22

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.

Source checked on both sides · 2026-08-22
Classroom Exercise
01

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.

Takeaway: The macOS sandbox is a stitched SBPL string. Paths go on -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.