---
title: "Sandbox on macOS"
description: "Configure Seatbelt and check macOS isolation, resource and process-cleanup limits."
canonical_url: "https://docs.oz0.ai/fleet/sandbox-on-macos"
---
# Sandbox on macOS

> Configure Seatbelt and check macOS isolation, resource and process-cleanup limits.

For: cluster operators.

Seatbelt restricts file and network access, with different resource limits from Linux. Check the validation and isolation limits below before relying on a Mac for sandboxed work.

A macOS node uses Seatbelt (`/usr/bin/sandbox-exec`) for [sandbox tier 1](https://docs.oz0.ai/concepts/security/sandbox-tiers#sandboxes). When the node can start that profile, harness sessions and jobs started by outside events use it. An installed plugin's servers use it in the default `--plugin-sandbox strict` mode; `auto` leaves them outside a sandbox.

<warning>

The current Seatbelt profile and installed-plugin sandboxes have not completed native validation on macOS. Check your node before relying on it for sandboxed work.

macOS sandboxes have no memory, process or CPU limits and no separate user per plugin. Intel Macs remain unvalidated. Processes that leave their original process group can [survive a session](#processes-after-a-session).

</warning>

## What a sandboxed process may do

`oz0-node sandbox check` says whether the Mac can run it: it runs `/usr/bin/true` under the same profile a session gets.

```bash
oz0-node sandbox check
# Sandbox: tier 1 (seatbelt)
```

The profile denies everything and then allows:

<table>
<thead>
  <tr>
    <th>
      
    </th>
    
    <th>
      Allowed
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Files it writes
    </td>
    
    <td>
      its job's directories (the working directory, a harness's state), and <code>
        /dev/null
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Files it reads
    </td>
    
    <td>
      <a href="#files-the-sandbox-reads">
        Details
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Network
    </td>
    
    <td>
      the node's egress proxy, on a loopback port of its own, and the node's ports a session needs (the LLM proxy). Nothing else, so a process that ignores <code>
        HTTPS_PROXY
      </code>
      
       cannot connect. A profile can name loopback only as <code>
        localhost
      </code>
      
      , which Seatbelt matches at both <code>
        127.0.0.1
      </code>
      
       and <code>
        ::1
      </code>
      
      , so the node holds the proxy's port at both: no other program can listen there for the sandbox to reach
    </td>
  </tr>
  
  <tr>
    <td>
      Unix sockets
    </td>
    
    <td>
      none: not Docker's, not ssh-agent's, not the node's, not one in its own working directory
    </td>
  </tr>
  
  <tr>
    <td>
      Hard links and clones
    </td>
    
    <td>
      none: Seatbelt checks <code>
        link
      </code>
      
       and <code>
        clonefile
      </code>
      
       as operations of their own, which no rule for reading or writing covers, so the profile denies both by name; a process cannot give a file it may not read (the node's key, say) a second name or a copy in a folder it may. git, uv, pnpm and cargo copy where they cannot link or clone
    </td>
  </tr>
  
  <tr>
    <td>
      macOS services
    </td>
    
    <td>
      a short list by name (user lookups, logging, certificate checks, the system's configuration); never LaunchServices (<code>
        open
      </code>
      
      , which would start apps outside the sandbox) or the pasteboard
    </td>
  </tr>
  
  <tr>
    <td>
      Terminals
    </td>
    
    <td>
      <a href="#terminal-restrictions">
        Details
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Descriptors
    </td>
    
    <td>
      its standard streams only: a descriptor the node got without close-on-exec, from whatever started it, is marked close-on-exec before the command starts, since Seatbelt checks a file when it is opened and not when a descriptor someone else opened is read
    </td>
  </tr>
</tbody>
</table>

The egress proxy allows the hosts on the [tenant's, the plugin's and the agent's lists](https://docs.oz0.ai/concepts/security/sandbox-tiers#how-tier-1-works), as on Linux.

## How denials are reported

A refused operation fails with "Operation not permitted" and Seatbelt itself says nothing to the process. The node reads Seatbelt's denials back from the macOS unified log: each run's profile tags its denials with a random marker of its own, and the node follows the log (`log stream`) for that marker while the process runs. Then:

- A connection around the proxy (a program that ignores `HTTPS_PROXY` and connects to an address itself) is reported as an egress denial of that host and port, with the reason "a direct connection, not through the egress proxy", in the job's live stream and on the job, like a host the proxy refused. Where macOS logs only the port of a refused connection (`network-outbound remote:*:443`), the denial names the host `*`: the log does not say which address it was.
- Other denials (a write outside the job's directories, a read of a file it may not read, a Unix socket, a service): at the end of the command the node appends a short list to its error output, so that the person or agent reading it sees why it failed:```text
oz0 sandbox: Seatbelt denied 2 operation(s) of this command:
  file-write-create /Users/me/notes.txt (touch)
  network-outbound 93.184.216.34:443 (curl)
    (connections go through the egress proxy in HTTPS_PROXY, to the hosts on the egress allowlist)
```

Each operation is reported once per run, at most 100 of them; past that the list says the rest are not reported. Lookups of DNS through `mDNSResponder` are left out of the list: the egress proxy resolves names for the process.

So is what macOS's own programs try on every run and do without, which stays refused: a shell's write to the terminal (a sandboxed command's output goes where the node sends it), and CoreFoundation's reads of the account's text encoding (`~/.CFUserTextEncoding`) and global preferences (`~/Library/Preferences/.GlobalPreferences*.plist`), which are the account's files. The violations file (`sandbox run --violations`) keeps every denial, these too.

Reading the unified log needs an administrator: `log stream` refuses other users. A node that runs as a user who is not one still sandboxes, but without these reports, and the command's error output starts with a line that says so (`oz0 sandbox: Seatbelt's denials are not reported for this run: ...`). The install script's launch daemon runs as the user who ran `sudo`, who is one.

## How it differs from Linux

- No network namespace. On Linux a sandbox has an empty network of its own; on macOS the profile decides each connection. The result is the same (only the proxy and the node's own ports), but it rests on the profile.
- The node's own ports at `::1`. The node holds the egress proxy's port at both `127.0.0.1` and `::1`, but not the ports of its own services a session reaches (the LLM proxy), which listen at `127.0.0.1` only. The profile's `localhost` lets the sandbox connect to those ports at `::1` too, so another program on the Mac that listened at `::1` on such a port would be reachable from the sandbox while the session runs.
- IPv4 inside IPv6 on loopback. Seatbelt matches `127.0.0.1` and `::1` but not `::ffff:127.0.0.1`. A runtime that opens dual-stack sockets (Java by default) cannot reach the proxy until it uses IPv4: set `-Djava.net.preferIPv4Stack=true`.
- Certificate checks go through `trustd`. Programs that verify TLS with the system (Go programs, Swift, Security.framework) need it, so the profile allows it. `trustd` may fetch a certificate's issuer and revocation URLs from outside the sandbox, which a hostile program could use to send a request to a host that is not on its list. Linux has no such service. This is a known gap.<br />

The profile also refuses `com.apple.TrustEvaluationAgent`, which curl requests. Whether real-certificate validation needs that service remains unvalidated. Check certificate validation with the programs your jobs use before relying on HTTPS from a Mac sandbox.
- No limits. Memory, process and CPU limits are Linux cgroups; a Mac's sandbox has none.
- No process namespace. A sandboxed process can signal and inspect only its own processes, but it sees that others exist.
- The unified log may be readable. The profile lets a process use the system's logging services, so on a node that runs as an administrator a process might read the Mac's log, other jobs' denials included. Direct access through those services remains unvalidated; do not assume all log reads are blocked. A sandboxed process cannot add denials to a job this way because only the kernel's denials are read.

## Validation limits

Native validation is incomplete for the current node-data profile, installed-plugin servers and Intel Macs. File and service denials appear in the command's error output and its violations file; direct-network denials also appear on the job. Reporting through the unified log needs an administrator account.

Use `oz0-node sandbox check` on each Mac. Its successful probe shows that the profile can start a command; it does not establish every security property. The limits below apply even when the node reports tier 1.

### Files the sandbox reads

The sandbox can read the system (`/usr`, `/System`, `/etc`, Homebrew in `/opt/homebrew`, Xcode, and of `/Library` only `Apple`, `Developer/CommandLineTools`, `Frameworks`, `Java`, `Fonts`, `TeX`, `Preferences` and `Managed Preferences`), its plugin, the node's managed Python and tools, and its own directories.

Nothing else of the node: its data directory (a system service's in `/Library/Application Support/orchestrator-zero-node`, or a user's) is denied after every allow but for the folders in it a process is given (the Python, the tools, its plugin, and its own directories where they lie there), which the denies leave out, and never `identity`, which holds the node's key; not other plugins or other jobs, and nothing in your home folder.

A `sandbox run` asked to read the data directory whole, or its identity, is refused.

### Terminal restrictions

The sandbox can use only the standard streams it was given: no ioctl on `/dev/tty` or on a terminal device, and no opening either to read. A command run from a person's terminal cannot push keystrokes into it for that person's shell to run once the sandbox ends, or read what is typed there.

### Processes after a session

macOS has no process namespace. `sandbox run` ends when its command ends. At session shutdown the node stops the original process group, including children that stayed in it.

A process that moves to another session or process group can survive. Seatbelt still restricts it, but it can write the job's working directory and connect to the loopback ports its profile allowed. It cannot use a later session's egress proxy, which requires that session's token.

This remains unresolved. Use Linux for work that requires all child processes to end with the sandbox. After an interrupted macOS session, check for remaining processes before reusing or removing its working directory.

<legacy-sections>
<span id="what-is-tested-where">

Previous validation section

</span>

: [Read the validation limits](#validation-limits).

<span id="escape-matrix-checks">

Escape matrix checks

</span>

: [Read the validation limits](#validation-limits) and [process cleanup limitation](#processes-after-a-session).

</legacy-sections>


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
