class

Cordon::SandboxExec

Inherits Cordon::Runner < Reference < Object

macOS sandbox runner using sandbox-exec and SBPL profiles.

sandbox-exec wraps a process in Apple's Seatbelt framework (a MACF kernel module). It evaluates a declarative SBPL (Sandbox Profile Language) policy against every syscall the process makes; violations return EPERM.

SBPL is a Scheme-like DSL. This runner generates a deny-default profile and adds explicit allow rules derived from the policy.

Deprecation note: sandbox-exec has been marked deprecated in macOS headers since 10.8 but remains functional through current releases. No public replacement exists for the ad-hoc CLI use case (App Sandbox requires code signing and an app bundle). Used in production by Chromium and Firefox.

The BASELINE constant contains the minimum permissions any process needs to start under deny-default. Omitting any of these typically causes an immediate crash or silent hang (dyld, Mach IPC, and sysctl are all gated).

process-exec / process-exec-interpreter are deliberately NOT part of BASELINE. (allow process-exec) with no path filter permits exec'ing ANY binary on the filesystem, regardless of file-read* restrictions elsewhere in the profile — Seatbelt does not cross-reference the two independent permissions, and (deny default) does not implicitly couple "readable" with "executable". A read-only or read-write policy that only grants access to a workspace directory would still allow exec'ing e.g. /usr/bin/ruby, /opt/homebrew/bin/, or any other binary on the system — entirely bypassing the intended containment. process-exec-interpreter (governs the interpreter named on a script's #! line) is the same class of bypass reached via a script instead of a direct exec, so it gets the same treatment. Both are scoped per-profile in #generate_profile instead, to exactly the paths already granted file-read (read-only/read-write/ tmpfs) — and nothing else.

In particular, the target command's own binary is NOT granted an implicit exception. The policy defines the perimeter; a command that lives outside it must be rejected, not admitted on the grounds that it was the command asked for. Naming a binary is not authority to run it — under the agent threat model the command string is precisely what the untrusted party controls, so honouring it would let the sandboxed side choose its own escape. Running a command therefore requires that the policy already cover it (via read_only/read_write/tmpfs, Preset::System, or a toolchain preset). Even system binaries like /bin/sh are NOT exec-able by default. See DEVELOPMENT.md → "Why process-exec is not in BASELINE".

Constants

BASELINE = "; --- process lifecycle ---\n(allow process-fork)\n\n; --- mach IPC: required by dyld and most system frameworks ---\n(allow mach-lookup)\n(allow mach-register)\n\n; --- sysctl: read by libc on startup ---\n(allow sysctl-read)\n\n; --- dyld, system frameworks, and basic device nodes ---\n; /private/var/db/dyld = Intel dyld shared cache\n; /System/Volumes/Preboot/Cryptexes = Apple Silicon dyld shared cache (arm64)\n(allow file-read*\n (subpath \"/usr/lib\")\n (subpath \"/usr/share\")\n (subpath \"/System/Library\")\n (subpath \"/System/Volumes/Preboot/Cryptexes\")\n (subpath \"/private/var/db/dyld\")\n (literal \"/dev/random\")\n (literal \"/dev/urandom\"))\n\n; --- common to write to `/dev/null` ---\n(allow file-read* file-write*\n (literal \"/dev/null\"))\n\n; --- stat/readdir: needed broadly ---\n(allow file-read-metadata)\n\n; --- root filesystem: required for path resolution ---\n; ! Note that file-read-data (literal \"/\") grants read on the\n; root directory node only — not its contents. That's distinct\n; from (subpath \"/\") which would be a blanket allow on the\n; entire filesystem.\n(allow file-read-data (literal \"/\"))\n\n; --- Darwin/CoreFoundation plumbing ---\n; Hit by nearly any CF- or Foundation-linked process, not just one\n; toolchain. Harmless if denied (caller falls back), but noisy.\n(allow ipc-posix-shm-read-data (literal \"apple.shm.notification_center\"))\n(allow file-read-data (literal \"/Library/Preferences/Logging/com.apple.diagnosticd.filter.plist\"))\n(allow file-read-data (literal \"/dev/autofs_nowait\"))\n\n; --- syslog ---\n; syslog() connects to a Unix-domain socket, which Seatbelt classes\n; as network-outbound — but it's local logging, not network access.\n; Allow unconditionally rather than coupling it to allow_network.\n(allow network-outbound (literal \"/private/var/run/syslog\"))\n\n; --- DNS resolver config ---\n; resolv(3) reads these directly for hostname resolution — not via\n; the system resolver APIs. Any process that does hostname lookup\n; (Ruby resolv.rb, Python socket, Go net, etc.) needs both.\n;\n; Both paths require two literals each: the symlink the process opens\n; and the real target the kernel resolves to. A deny at symlink\n; traversal fires before the target rule is ever evaluated.\n; /etc/resolv.conf -> ../var/run/resolv.conf -> /private/var/run/resolv.conf\n; /etc/hosts -> -> /private/etc/hosts\n(allow file-read-data\n (literal \"/etc/resolv.conf\")\n (literal \"/private/var/run/resolv.conf\")\n (literal \"/etc/hosts\")\n (literal \"/private/etc/hosts\"))\n\n; --- timezone database ---\n; Any process that formats a local time reads this: Ruby's Time,\n; Python's zoneinfo, Go's time, and anything CoreFoundation-linked.\n; Denied, libc silently falls back to UTC rather than erroring.\n;\n; /usr/share/zoneinfo LOOKS covered by the (subpath \"/usr/share\")\n; grant above, but isn't — it is a symlink chain out of /usr/share,\n; and Seatbelt matches the path the kernel resolves to:\n; /usr/share/zoneinfo\n; -> /var/db/timezone/zoneinfo\n; -> /var/db/timezone/tz/<VERSION>/zoneinfo\n; -> /private/var/db/timezone/tz/<VERSION>/zoneinfo\n;\n; The grant is therefore on /private/var/db/timezone as a whole,\n; deliberately NOT on .../timezone/zoneinfo. <VERSION> is Apple's\n; tzdata release (e.g. \"2026c.1.0\") and changes with system\n; updates, so any rule naming it — or naming the zoneinfo symlink\n; whose target sits beneath it — breaks on the next update. The\n; wider subpath also covers the sibling `icutz` ICU blob, read by\n; Foundation-linked processes.\n;\n; /etc/localtime is a separate entry point (libc reads it to find\n; the LOCAL zone, where the rules above cover named zones). Its\n; target resolves into the subpath already granted; these two\n; literals cover traversing the symlink itself, in both the /etc\n; and /private/etc forms.\n(allow file-read*\n (subpath \"/private/var/db/timezone\"))\n(allow file-read-data\n (literal \"/etc/localtime\")\n (literal \"/private/etc/localtime\"))\n"
BINARY = "sandbox-exec"

Instance methods

available?

Returns true if the underlying sandbox binary is present and usable.

Source
exec(command : Array(String), policy : Policy) : NoReturn

Replaces the current process with command, inside the sandbox described by policy. Used for self-relaunch (Cordon.relaunch) — the caller does not resume; either the sandboxed command takes over the process image, or this raises.

Source
generate_profile(policy : Policy) : String

Returns the SBPL profile string for policy. Useful for inspection, logging, or writing to disk without executing.

Depends only on policy — the profile is the same whatever command is eventually run under it, since no command receives an implicit grant (see BASELINE's comment). This makes the profile fully inspectable ahead of time: what cordon inspect prints is what will be enforced.

All paths are resolved to their real, symlink-free form before being written to the profile (see #resolve_path) — SBPL matches against the path the kernel actually resolves to, not the string the caller wrote. "./" or "~/.rubies" pointing through a symlink would silently match nothing otherwise.

Source
name

Short platform-facing name for this runner (e.g. "bwrap", "sandbox-exec"), used in ConfirmReport output.

Source
run(command : Array(String), policy : Policy, shell : Bool = false) : Result

Runs command inside the sandbox described by policy.

If shell is true, command must be a single-element array holding a full shell script string, executed via /bin/sh -c. No positional-arg forwarding is supported — build the complete script string yourself before calling. /bin/sh must be exec-granted by policy (e.g. via Preset::System), same as any other target; shell: true is not an implicit exec exception.

If shell is false (the default), command is the literal argv — command[0] is exec'd directly with command[1..] as its arguments, no shell involved.

Source