How the macOS sandbox works
This page explains the mechanism behind nd7 run claude: what Seatbelt is, every rule in the profile nd7 applies, how a Bash command gets its own up-to-date rules through a small helper program, and what this design does and does not protect against. After reading it you should be able to predict which operations a session will refuse and why, and to say exactly what has to be trusted for the boundary to hold.
What Seatbelt is#
Seatbelt is the sandboxing facility built into macOS. A process hands the kernel a profile, a text document in a Lisp-like language called SBPL (Sandbox Profile Language) listing which operations are allowed and which denied, and from that moment the kernel checks every file, network and process operation the process makes against it. The check happens in the kernel, so it applies to the process and to every child it spawns, and no library call inside the process can undo it.
nd7 applies profiles through the C function sandbox_init_with_parameters, wrapped in src/sandbox.rs. Two entry points: spawn_with_profile applies the profile between fork and exec, so the program and its whole tree start inside it, and apply_to_self applies it to the calling process, irreversibly.
Two profiles are rendered from one set of rules, in src/policy.rs. The floor is what nd7 run applies to claude and everything it spawns. The per-command policy is what nd7-exec applies to itself before running one Bash command. They share a body and differ in two lines.
A note on the repository layout: the profile text lives in policy.rs, rendered from a Policy struct. There is also a file src/sbprofiles/claude.sb, but it is a permissive placeholder ((allow default)) used by a test, not the profile a session runs under.
One floor for the whole claude tree, and one exit from it: nd7-exec, which re-confines itself under the per-command policy before the command runs.
The rules, exactly#
Every session starts from (deny default) and Apple's own system.sb, then adds the following. This is the shared body of both profiles.
- run programs: rule
(allow process-fork process-exec); why: the agent runs commands, and those commands run commands. - send signals: rule
(allow signal); why: Claude Code kills commands that time out. - read kernel settings: rule
(allow sysctl-read). - terminal control: rule
(allow file-ioctl); why: window size, raw mode. - read files: rule
(allow file-read*); why: reading is open by default. - except credentials: rule
(deny file-read* file-read-data file-read-metadata file-read-xattr (subpath ~/.ssh) (subpath ~/.aws) (subpath ~/.nd7)); why: "your keys" concretely: SSH keys, AWS credentials, and nd7's own session records. - write files: rule
(allow file-write* (subpath <project>) (subpath <tmp>) (regex #"^/private/tmp/claude-") (require-all (subpath <home>) (regex #"/\.claude(/|$)"))); why: the project, the temp directory, Claude Code's scratch directories, and~/.claude. - name lookup and keychain: rule
(allow mach-lookup (global-name "com.apple.dnssd.service") (global-name "com.apple.SystemConfiguration.configd") (global-name "com.apple.SecurityServer")); why: what an HTTPS client needs. - DNS: rule
(allow network-outbound (literal "/private/var/run/mDNSResponder")). - HTTPS out: rule
(allow network-outbound (remote tcp "*:443")); why: any host, port 443 only.
"Your keys" means exactly those three directories: ~/.ssh, ~/.aws and ~/.nd7. They are denied for reading data, metadata and extended attributes alike, so a command cannot even learn whether a file is there. Credentials stored elsewhere — in a .env file inside the project, say — are readable, because reading is otherwise open.
The last rule of both profiles is the same, and it is last on purpose:
(deny file-write* file-write-acl file-write-create file-write-data file-write-flags
file-write-mode file-write-owner file-write-setugid file-write-unlink
file-write-xattr file-link (subpath ~/.nd7))
It names every concrete write operation instead of the file-write* class because Seatbelt consults per-operation rules before class rules whatever their order: an earlier (allow file-write-create …) would beat a later (deny file-write* …). Naming the operations puts the deny on the same footing, and then the last match wins. The effect is that no grant and no later rule can make nd7's own session records writable — there is a kernel-level test in policy.rs that grants write access to the whole home directory and then checks that a write under ~/.nd7 still fails.
The floor adds exactly one line the per-command policy does not have:
(allow process-exec (with no-sandbox) (literal "/path/to/nd7-exec"))
(with no-sandbox) is an SBPL modifier that lets the exec of one literal path leave the sandbox. The child is then unconfined and may apply a fresh profile of its own. That one path is the floor's only exit.
The per-command policy adds one line per grant, and nothing else:
(allow file-write* (subpath "/Users/you/data"))
Grants appear only in the per-command policy, never in the floor; the exit line appears only in the floor.
Why a process inside cannot loosen the profile#
Because the kernel will not let it. ADR-0007 records the measurement: a confined process that tries to apply a different profile fails with EPERM, whether the new profile is looser, tighter, a superset, a subset, or the same rules with different parameters. Only a semantically identical profile is accepted, as a no-op.
That single fact is what the design rests on. Neither the model, nor a compromised dependency, nor Claude Code's own per-command sandbox can widen the boundary from inside. It also means the boundary of a session cannot change while it runs — which is why widening has to happen through a program that starts outside the sandbox.
How nd7-exec routes Bash commands#
The PreToolUse hook nd7 run installs rewrites every Bash call. The logic is in src/hook_prefix.rs: it takes the tool input, replaces command with <nd7-exec> -c '<the original command>', single-quoted so the shell sees the same script, and returns the whole tool input (not just the command — updatedInput replaces the input rather than merging into it). It answers with permissionDecision: allow and the reason nd7: the command runs under this session's sandbox policy. The rewrite is safe to apply twice: a command that already carries the prefix is left alone.
src/bin/nd7-exec.rs then does, in order:
- Accept exactly
-c <command>and nothing else. Any other arguments are a usage error, exit 2. - Ask the kernel whether this process is confined (
sandbox_check). If it is — the exec did not come through the floor's exit — just run the command under whatever profile already applies. That case can only be stricter, never wider. - Otherwise, find the session by walking the parent-process chain from its own parent upwards, looking for
~/.nd7/sessions/<pid>. Ancestry is something the caller cannot forge, and the walk starts at the parent, not at itself, so a caller cannot make itself a session. - Read that session's
policy.sband apply it to itself. Apply it, not merge it: from here the process is confined again, and there is no further exit. - Replace itself with
/bin/zsh -c <command>viaexec. There is no intermediary process to orphan the command when Claude Code kills it, and the shell's exit status becomes the command's.
It refuses with exit 126 and a message beginning nd7-exec: if there is no session in its ancestry, no policy.sb, or the profile cannot be applied.
The important property: nd7-exec takes nothing from the caller's arguments, working directory or environment except the command text itself. The home directory comes from the password database, not from $HOME, precisely because the environment belongs to the process it is defending against. The one environment override that exists, ND7_SESSIONS_DIR, is compiled in only under a test-seams feature flag and is never present in a release build.
How the session's rules are found and applied#
One nd7 run owns ~/.nd7/sessions/<its pid>/, described in src/session.rs. It contains two files: record.json, the rules as data, which nd7 allow reads and amends; and policy.sb, the rendered profile, which nd7-exec reads. Both are written through a temporary file in the same directory and a rename, so nd7-exec never reads half a profile.
The directory is removed when nd7 run exits. A run that is killed outright leaves one behind, so every nd7 run first sweeps the session directories whose process is gone, checking with signal 0 and treating only ESRCH as "gone".
The trust boundary#
Trusted:
- The macOS kernel, which does the enforcing.
- The
nd7andnd7-execbinaries, and the directory holding them.nd7 runchecks at start-up thatnd7-execis a regular file and that neither it nor its directory is writable by group or others, and refuses to start otherwise. - The session directory under
~/.nd7, which the profile makes unreadable and unwritable from inside the sandbox. - The process-ancestry walk, which the kernel answers and the caller cannot forge.
Not trusted:
- The model, and anything it writes.
- The command text, which is treated as data and quoted, never interpreted.
- The caller's arguments beyond
-c <command>, its working directory, and its environment. - Claude Code's own process behaviour. The design deliberately does not depend on how Claude Code spawns processes; an alternative that did was measured, worked, and was shelved for that reason.
The failure mode is closed. If the hook ever stops applying, the command runs unprefixed under the floor, which is never wider than the per-command policy. Two escapes of an earlier, naive version of the exit binary are recorded in the spike notes and are now regression tests in tests/nd7_exec.rs.
Known limits#
From the README and ADR-0007, as written there:
- Write and Edit are not covered by
nd7 allow. Those tools run inside theclaudeprocess, which sees only the profile the session started with. Widening them means restarting, for examplend7 run claude --resume <id>. psandpgrepare denied.- Network filtering is by port, not hostname. Seatbelt matches ports; any host is reachable on 443, and nothing is reachable on any other port. Claude Code's own hostname filtering is lost under nd7 until nd7 has a proxy of its own.
- Claude Code's
/sandboxsettings have no effect under nd7. - Linux is not built. It is Tier 2, planned to use Landlock.
- Three Seatbelt behaviours are load-bearing and documented in
policy.rs: per-operation rules beat class rules regardless of order; insiderequire-nota wildcard port never matches; and(trace)is dead on current macOS. - The design must be revisited if Apple removes
no-sandbox, or if Claude Code stops honouringupdatedInputonPreToolUsehooks.
Tested versions#
The README records the sandbox as new and tested on macOS 26 with Claude Code 2.1.x. The hook reply was measured against Claude Code 2.1.278. The README also says the policy will tighten as recorded sessions show what is actually needed — in particular, process-exec is unrestricted today and an exec allowlist derived from real sessions is the stated next step.
Further reading#
Apple's own documentation for the facility this page describes, plus the kernel documentation for the planned Linux port.
- App Sandbox — Apple's developer reference for App Sandbox, the supported way a macOS application confines itself.
- Apple Platform Security: security of runtime process — Apple's security guide on what sandboxing is for: "All third-party apps are sandboxed. Sandboxing is designed to prevent apps from gathering or modifying information stored by other apps."
sandbox_init(3)— a mirror of the man page for the C function nd7 calls (throughsandbox_init_with_parameters). Apple no longer publishes these man pages on the web. The mirrored text markssandbox_init()DEPRECATED and points developers to App Sandbox instead.sandbox-exec(1)— a mirror of the man page for the command-line form, which "enters a sandbox using a profile specified by the-f,-n, or-poption and executes command with arguments". It is the tool Claude Code's own sandbox uses, and the mirrored text marks it DEPRECATED as well.- Landlock — the Linux kernel's own sandboxing facility, which the planned Linux port will use. Its rules behave like Seatbelt's in the way that matters here: "Once a thread is landlocked, there is no way to remove its security policy; only adding more restrictions is allowed."
- How sandboxing works — the same mechanism explained from the beginning, with the other layers of isolation for comparison.