---
title: How the macOS sandbox works
description: Seatbelt, the exact rules nd7 applies, why nothing inside can loosen them, how nd7-exec routes Bash commands, and the known limits.
order: 40
section: Sandbox
---

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](https://github.com/nd7-dev/nd7-core/blob/main/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](https://github.com/nd7-dev/nd7-core/blob/main/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](https://github.com/nd7-dev/nd7-core/blob/main/src/sbprofiles/claude.sb), but it is a permissive placeholder (`(allow default)`) used by a test, not the profile a session runs under.

<svg viewBox="0 0 760 428" role="img" aria-labelledby="sandbox-diagram-title" style="max-width:760px;width:100%;height:auto;display:block;margin:1.5rem auto">
  <title id="sandbox-diagram-title">The process tree of one nd7 run session. nd7 run applies the Seatbelt floor to claude and everything it starts; the Write and Edit tools stay inside it. A PreToolUse hook rewrites each Bash command, and the highlighted arrow leaving the floor is its only exit: exec of nd7-exec with the no-sandbox modifier. nd7-exec then applies the per-command policy, the floor plus any nd7 allow grants, to itself and runs the shell.</title>
  <defs>
    <marker id="sb-arrowhead" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto">
      <path d="M0 0 L8 4 L0 8 Z" fill="currentColor"/>
    </marker>
    <marker id="sb-arrowhead-exit" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="8" markerHeight="8" orient="auto">
      <path d="M0 0 L8 4 L0 8 Z" fill="var(--accent, #e9c46a)"/>
    </marker>
  </defs>
  <g fill="var(--paper-2, #171f29)" stroke="currentColor" stroke-width="1">
    <rect x="24" y="8" width="110" height="32"/>
    <rect x="8" y="56" width="744" height="124" stroke-dasharray="4 3"/>
    <rect x="32" y="118" width="110" height="32"/>
    <rect x="178" y="118" width="300" height="32"/>
    <rect x="8" y="250" width="744" height="160" stroke-dasharray="4 3"/>
    <rect x="273" y="310" width="110" height="32"/>
    <rect x="419" y="310" width="120" height="32"/>
  </g>
  <g fill="none" stroke="currentColor" stroke-width="1" marker-end="url(#sb-arrowhead)">
    <line x1="79" y1="40" x2="79" y2="52"/>
    <line x1="142" y1="134" x2="174" y2="134"/>
    <line x1="383" y1="326" x2="415" y2="326"/>
  </g>
  <line x1="328" y1="150" x2="328" y2="306" fill="none" stroke="var(--accent, #e9c46a)" stroke-width="1" marker-end="url(#sb-arrowhead-exit)"/>
  <g fill="var(--paper, #121820)">
    <rect x="342" y="187" width="184" height="18"/>
    <rect x="342" y="205" width="252" height="18"/>
  </g>
  <g fill="currentColor" font-family="ui-monospace, monospace" font-size="13" text-anchor="middle">
    <text x="79" y="29">nd7 run</text>
    <text x="87" y="139">claude</text>
    <text x="328" y="139">PreToolUse hook rewrites the command</text>
    <text x="328" y="331">nd7-exec</text>
    <text x="479" y="331">/bin/zsh -c</text>
  </g>
  <g fill="currentColor" font-family="ui-monospace, monospace" font-size="13">
    <text x="24" y="78">floor: applied to claude and every process it starts</text>
    <text x="24" y="100">Write and Edit run inside claude: the floor only, no grants</text>
    <text x="346" y="200">the floor's only exit:</text>
    <text x="346" y="218">exec nd7-exec (with no-sandbox)</text>
    <text x="24" y="272">per-command policy:</text>
    <text x="24" y="292">the floor plus nd7 allow grants</text>
    <text x="24" y="366">nd7-exec applies it to itself; there is no further exit</text>
    <text x="24" y="386">the command and everything it starts inherits it</text>
  </g>
</svg>

*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](https://github.com/nd7-dev/nd7-core/blob/main/docs/DECISIONS.md#adr-0007-sandbox-claude-code-with-one-seatbelt-floor-and-a-trampoline-and-refuse-every-other-profile) 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](https://github.com/nd7-dev/nd7-core/blob/main/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](https://github.com/nd7-dev/nd7-core/blob/main/src/bin/nd7-exec.rs) then does, in order:

1. Accept exactly `-c <command>` and nothing else. Any other arguments are a usage error, exit 2.
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.
3. 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.
4. Read that session's `policy.sb` and apply it to itself. Apply it, not merge it: from here the process is confined again, and there is no further exit.
5. Replace itself with `/bin/zsh -c <command>` via `exec`. 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](https://github.com/nd7-dev/nd7-core/blob/main/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 `nd7` and `nd7-exec` binaries, and the directory holding them. `nd7 run` checks at start-up that `nd7-exec` is 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 the `claude` process, which sees only the profile the session started with. Widening them means restarting, for example `nd7 run claude --resume <id>`.
- **`ps` and `pgrep` are 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 `/sandbox` settings 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; inside `require-not` a 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 honouring `updatedInput` on `PreToolUse` hooks.

## 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](https://developer.apple.com/documentation/security/app-sandbox) — Apple's developer reference for App Sandbox, the supported way a macOS application confines itself.
- [Apple Platform Security: security of runtime process](https://support.apple.com/guide/security/security-of-runtime-process-sec15bfe098e/web) — 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)`](https://keith.github.io/xcode-man-pages/sandbox_init.3.html) — a **mirror** of the man page for the C function nd7 calls (through `sandbox_init_with_parameters`). Apple no longer publishes these man pages on the web. The mirrored text marks `sandbox_init()` DEPRECATED and points developers to App Sandbox instead.
- [`sandbox-exec(1)`](https://keith.github.io/xcode-man-pages/sandbox-exec.1.html) — a **mirror** of the man page for the command-line form, which "enters a sandbox using a profile specified by the `-f`, `-n`, or `-p` option 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](https://docs.kernel.org/userspace-api/landlock.html) — 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](/docs/how-sandboxing-works) — the same mechanism explained from the beginning, with the other layers of isolation for comparison.
