Nous.Sandbox.Seatbelt (nous v0.17.1)

Copy Markdown View Source

macOS confinement via sandbox-exec and an inline SBPL profile.

confine/2 wraps the caller's argv as

/usr/bin/sandbox-exec -p <profile> -- <argv...>

The executable is the absolute /usr/bin/sandbox-exec, never a bare name, so a poisoned PATH cannot substitute a different binary.

The profile fences writes, not reads

The profile opens with (allow default). That is deliberate and it is the honest description of what this provider buys you: it denies every file-write* performed by the confined process, then re-allows the console /dev nodes and — under :workspace_write — every directory in Nous.Sandbox.writable_roots/1. Reads, network, and process spawning are not restricted. This is a write fence against a prompt-injected rm -rf, not a confidentiality boundary.

Nous.Sandbox.writable_roots/1 is the single owner of the writable set; this module never recomputes it, so the profile cannot drift from the rest of the system's idea of what is writable.

Writes delegated to a running daemon are out of scope

"Denied" above is exactly as narrow as it reads: the confined process may not perform the write. It does not mean no byte can land outside the writable set. (allow default) permits mach-lookup, so the confined process can ask an already-running, unconfined system daemon to write on its behalf, and seatbelt checks the daemon's credentials, not ours. Executed, under the real :workspace_write profile:

sandbox-exec -p <profile> -- sh -c 'defaults write com.nous.esc pwned yes'

exits 0, prints no denial signature, and leaves a 56-byte plist in $HOME — outside every entry of writable_roots/1 — because cfprefsd performed the write. Anything reachable over XPC/launchd that writes on a client's behalf is the same class; where the Docker CLI is present, docker run -v /:/host is that class with a much larger blast radius (reasoned, not executed). Closing this would take a deny mach-lookup (global-name ...) list that grows with every macOS release, so the boundary is named rather than patched.

Two things that look like this and are not: osascript -e 'do shell script' (the process it spawns inherits our sandbox) and a nested sandbox-exec (it cannot widen the profile — sandbox_apply fails with EPERM).

The /dev write nodes are not a hole

base_profile/0 re-allows /dev/null, /dev/stdout, /dev/stderr, /dev/tty and (subpath "/dev/fd") in every mode, :read_only included. Those are the caller's own already-open descriptors: the confined process can write through an fd its unconfined parent opened, and it still cannot open() a new path outside the writable set (verified under the real :workspace_write profile — exec 9> "$HOME/x" is EPERM, and /dev/fd/<non-numeric> is ENOENT, so nothing can be created there). Without them cmd > /dev/stdout, tee /dev/stderr, tee /dev/tty and > /dev/fd/1 all failed with "Operation not permitted" — ordinary idioms in model-authored shell — while all four succeed under Nous.Sandbox.Bwrap, whose --dev /dev tmpfs makes those nodes writable. The two providers now tell the caller the same story.

On sandbox-exec being deprecated

Apple has marked sandbox-exec deprecated and has shipped no replacement for sandboxing an arbitrary CLI process. It nevertheless still works on current macOS and is what OpenAI's Codex CLI, Anthropic's Claude Code, and Chrome all use for exactly this job. Depending on it is an accepted risk: if Apple removes it, Nous.Sandbox.chain/0 gains a replacement provider and nothing above the seam changes. That is what the seam is for.

Summary

Functions

Wrap argv in sandbox-exec under an SBPL profile derived from policy.

Check that sandbox-exec exists and that the kernel actually enforces base_profile/0.

Functions

confine(argv, policy)

@spec confine([String.t()], Nous.Sandbox.Policy.t()) ::
  {:ok, Nous.Sandbox.Confined.t()}

Wrap argv in sandbox-exec under an SBPL profile derived from policy.

Pure — it builds strings and returns data. The profile denies all writes except the /dev console nodes and, under :workspace_write, the subpaths reported by Nous.Sandbox.writable_roots/1.

probe(timeout_ms)

@spec probe(pos_integer()) :: {:ok, :full} | {:error, :unusable}

Check that sandbox-exec exists and that the kernel actually enforces base_profile/0.

The probe runs the real base_profile/0 — the same string :read_only gets, which grants no writable root at all — and asserts both directions in one spawn:

/bin/sh -c 'echo x > /dev/null && ! : > "$0"' <path under System.tmp_dir!/0>

Exit 0 therefore means "a permitted write succeeded and a write outside every writable root was actually refused". The previous probe ran /usr/bin/true under (version 1) (allow default), a profile with no deny clause: it proved the binary exists and the parser was happy, never that one write is stopped.

Exit 0 is {:ok, :full}; a missing binary, a nonzero exit, a timeout, or any other runner error is {:error, :unusable}. Seatbelt has no partial grade — either the kernel applies the profile or it does not — and anything ambiguous fails closed.