Skip to content

perf(macos): pass seatbelt profile to sandbox-exec via temp file (-f)#303

Open
shawnm-anthropic wants to merge 3 commits into
mainfrom
shawnm/macos-profile-file
Open

perf(macos): pass seatbelt profile to sandbox-exec via temp file (-f)#303
shawnm-anthropic wants to merge 3 commits into
mainfrom
shawnm/macos-profile-file

Conversation

@shawnm-anthropic

Copy link
Copy Markdown
Collaborator

What

Each sandboxed command on macOS interpolated the seatbelt profile (tens to hundreds of KB depending on config) inline into the wrapped command string (sandbox-exec -p "<profile>"). The outer shell has to re-parse the quoted profile and the kernel copies it through argv twice.

Measured on this branch with a 50KB profile (hyperfine, 60 runs, echo hi wrapped by wrapCommandWithSandboxMacOS): 54.2ms ± 2.6ms inline -p vs 34.5ms ± 5.6ms via -f — 1.57× faster, ~20ms saved per sandboxed command. The gap grows with profile size; Claude Code (which carries this change as a patch on its vendored copy, with a ~110KB profile) measured ~45-50ms saved per sandboxed Bash command end-to-end.

The profile is now written once per command to a 0600 file under a per-user temp directory ($TMPDIR/sbx-profiles-<uid>, 0700) and passed via sandbox-exec -f <file>, keeping the wrapped command string ~1KB. Stale profile files older than 5 minutes are swept on each wrap call (covers crashed processes), and the wrap falls back to inline -p if the file cannot be written.

Security: the profile file must be tamper-proof from inside the sandbox

With inline -p, the profile traveled in the argv of the unsandboxed wrapper — nothing inside the sandbox could touch it. With -f, a sandboxed command that can write to the profile directory could overwrite the profile file of the next sandboxed command (e.g. with (version 1)(allow default)) before sandbox-exec reads it — a sandbox escape, same class as the existing protections for git hooks and dangerous dotfiles. Two layers close this:

  • Every generated profile denies file-write* under the profile directory, plus the standard move-blocking rules (file-write-unlink/file-write-create on the subpath and literal denies on ancestors, so the directory can't be renamed away and recreated, or replaced with a symlink). This applies even when the config imposes no other write restrictions (e.g. network-only sandboxes), and regardless of what the caller puts in allowWrite.
  • writeMacOSProfileFile() refuses a directory that is not exclusively ours (wrong owner, not a directory, or group/other-writable) and falls back to inline -p — covers a pre-created sbx-profiles-<uid> in a shared world-writable /tmp.

The directory path is realpath-resolved (/var/folders/private/var/folders) so the deny rule matches the canonical path seatbelt evaluates.

Tests

New test/sandbox/macos-profile-file.test.ts:

  • wrapped command uses -f, profile file is 0600 inside the profile dir, command string stays small
  • generated profile contains the deny + move-blocking rules for the profile dir (with and without write restrictions)
  • per-command file uniqueness; stale-file sweep keeps fresh and foreign files
  • fallback to inline -p when the profile dir is not exclusively owned
  • live sandbox-exec (macOS): wrapped command executes; a sandboxed command cannot create or overwrite files in the profile dir even when its parent is write-allowed; the same write succeeds outside the profile dir (sanity)

Existing tests that asserted profile content against the wrapped command string now read it via a shared getProfileFromWrappedCommand() helper (handles both -f and -p).

Full suite on macOS: 342 pass / 5 fail — the 5 failures are --control-fd tests that fail identically on clean main on this machine (pre-existing, unrelated).

🤖 Generated with Claude Code

shawnm-anthropic and others added 3 commits June 5, 2026 15:41
Each sandboxed command interpolated the often ~100KB seatbelt profile
inline into the wrapped command string (sandbox-exec -p "<profile>").
The outer shell had to re-parse the quoted profile and the kernel copied
it through argv twice — measured at ~45-60ms of overhead per sandboxed
command on macOS.

The profile is now written once per command to a 0600 file under a
per-user temp directory and passed via sandbox-exec -f <file>, keeping
the wrapped command string ~1KB. Stale profile files older than 5
minutes are swept on each wrap call (covers crashed processes), and the
wrap falls back to inline -p if the file cannot be written.

Because the profile now lives in a file rather than the unsandboxed
wrapper's argv, a sandboxed command that could write to the profile
directory would control the policy applied to the NEXT sandboxed
command — a sandbox escape. Every generated profile therefore denies
file-write* under the profile directory (plus the standard
move-blocking rules), even when the config imposes no other write
restrictions, and writeMacOSProfileFile() refuses directories that are
not exclusively owned by the current user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…apply)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match the repo's existing srt- temp naming (srt-ca-, SANDBOX_RUNTIME=srt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant