The macOS sandbox backend provides macOS sandbox isolation by wrapping Apple's Seatbelt sandbox — the same kernel-enforced sandbox that backs the App Sandbox used by every Mac App Store application.
On macOS, MXC executes scripts inside the macOS sandbox via
sandbox_init() — the same kernel-enforced Seatbelt framework that backs
the App Sandbox used by every Mac App Store application. A TinyScheme
profile is generated on-the-fly from the MXC policy and applied to the
child process via pre_exec, which means the child inherits the parent's
Mach bootstrap namespace. This enables both CLI commands and GUI
applications (when guiAccess is enabled) to run under the sandbox. This
provides:
- Filesystem isolation via
(allow file-read*)/(allow file-write*)rules oversubpathliterals, with deny rules layered on top sodeniedPathsoverrides any broader allow. - Network isolation via allow/block-all outbound rules. Seatbelt cannot
enforce DNS host lists:
allowedHostsdegrades to allow-all outbound andblockedHostsis rejected. - Cooperative HTTP proxy via
network.proxy:HTTP_PROXY/HTTPS_PROXY/ALL_PROXYare injected into the sandbox so well-behaved clients route through the configured proxy (raw-socket clients can bypass it). - UI isolation by denying mach-lookup of
com.apple.windowserver, pasteboard, and HID iokit user clients whenui.disable/ui.clipboard=none/ui.injection=false. - Process-tree management by allowing signals only between processes that inherited the same sandbox.
The macOS sandbox is process-scoped, not container-scoped: there is no named container, no lifecycle, and nothing to clean up. The sandbox lives only as long as the spawned process tree. This is intentionally simpler than LXC.
The sandbox applies the generated Seatbelt (TinyScheme) profile to the
child process via sandbox_init() inside Command::pre_exec (after
fork(), before exec()). The profile string is passed directly to
sandbox_init — no temporary files are needed. The child then execs
/bin/sh -c <script> with the sandbox already active.
- macOS 11 or later (Big Sur).
sandbox_init()ships with every macOS release; Apple has marked it deprecated in headers since 10.8 but continues to ship and use it internally. - Xcode Command Line Tools for building from source (
xcode-select --install). Not needed fornpm installof pre-built binaries.
No additional packages are required at runtime — the macOS sandbox is part of the base OS.
Follow these steps to prepare a macOS machine for building and running
mxc-exec-mac from source.
xcode-select --installProvides clang, ld, system headers, and the macOS SDK needed by the
Rust toolchain to compile native binaries.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"After installation, follow the shell setup instructions printed by the
installer (adds /opt/homebrew/bin to PATH on Apple Silicon).
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"Add the targets needed for building:
# Native Apple Silicon (required on M-series Macs)
rustup target add aarch64-apple-darwin
# Intel (optional — needed for --all / cross-compilation)
rustup target add x86_64-apple-darwinbrew install pythonThis makes python3 available at /opt/homebrew/bin/python3. The example
configs that invoke Python (21_mac_python_info.json) require this.
Note: On Apple Silicon, Homebrew installs to
/opt/homebrew. Example configs that run Python include"readonlyPaths": ["/opt/homebrew"]so the sandbox can access the interpreter and its libraries.
brew install nodeRequired only if you plan to build and test the TypeScript SDK
layer (npm run build / npm test).
After setup, verify the build works end-to-end:
# Build the binary
./build-mac.sh --rust-only
# Run a quick smoke test
./src/target/aarch64-apple-darwin/release/mxc-exec-mac --debug tests/examples/15_mac_hello_world.jsonYou should see sandbox profile generation output followed by
hi from seatbelt.
The macOS sandbox backend uses the same JSON configuration schema as the
other backends, with containment set to "seatbelt". Backend-specific
settings live under a top-level seatbelt key:
{
"$schema": "../../schemas/stable/mxc-config.schema.0.7.0-alpha.json",
"containment": "seatbelt",
"process": {
"commandLine": "echo hi from seatbelt",
"timeout": 30000
},
"filesystem": {
"readwritePaths": ["/tmp/output"],
"readonlyPaths": ["/Users/me/project"],
"deniedPaths": ["/Users/me/.ssh"]
},
"network": {
"defaultPolicy": "block",
"allowedHosts": ["api.github.com"]
},
"seatbelt": {
"nestedPty": true
}
}| Field | Type | Default | Description |
|---|---|---|---|
seatbelt.profileOverride |
string | unset | Optional override of the generated TinyScheme sandbox profile. When set, the SDK-generated profile is replaced with this raw TinyScheme string verbatim — all filesystem/network/ui policy fields are ignored for profile generation (they are still type-checked). Use this only when the auto-generated profile is insufficient. |
seatbelt.guiAccess |
boolean | false |
When true, adds wildcard Mach service and IOKit rules so GUI applications can create windows and render via WindowServer. Requires ui.disable: false. Native AppKit apps (e.g. Terminal.app) work well; Electron-based apps may escape the sandbox via re-launch patterns. |
seatbelt.launchMethod |
"exec" | "open" |
"exec" |
How to launch the sandboxed process. "exec" (default) uses the sandbox_init() API in pre_exec then execs the command directly — works for third-party GUI apps (Alacritty, etc.) and all CLI commands. "open" launches Terminal.app via LaunchServices (open -n -W -a Terminal) then applies the sandbox to the inner shell via the sandbox-exec CLI tool. This is required because Terminal.app enforces Apple Launch Constraints that kill it when exec'd by unauthorized parents. Currently only Terminal.app is supported with the "open" method — other Apple system apps (Calculator, TextEdit) cannot be sandboxed due to Launch Constraints and lack of an inner shell to constrain. |
seatbelt.nestedPty |
boolean | true |
When true, the inner process can allocate its own pseudo-terminals via posix_openpt. Required by anything that spawns a shell (test runners, git, gh, REPLs, agent tools that wrap commands in a pty). Adds (allow pseudo-tty) and read/write/ioctl on /dev/ptmx to the generated profile. Set to false for a tighter sandbox when the inner command does not need to allocate new ttys. |
seatbelt.keychainAccess |
boolean | false |
When true, opens the sandbox enough for keytar / Security.framework to reach the macOS Keychain end-to-end. Adds Mach lookup for com.apple.SecurityServer, com.apple.securityd, com.apple.trustd, com.apple.ocspd, com.apple.cfprefsd.daemon, com.apple.xpcd, and the com.apple.lsd.* family (regex); read access to /private/var/db/mds (Spotlight/MDS metadata) and /private/var/protected/trustd (trustd protected store); and read+write access to ~/Library/Keychains (user keychain DB) and /private/var/folders (XPC cache and per-user containers). The system keychain stores under /Library/Keychains and /System/Library/Keychains are already covered by the baseline /Library and /System read-only allows. Off by default — opt in only when the inner workload genuinely needs Keychain access. |
| Policy field | Generated rule | Effect |
|---|---|---|
readonlyPaths |
(allow file-read* (subpath …)) |
Script can read these subtrees |
readwritePaths |
(allow file-read* file-write* network-bind network-outbound (subpath …)) |
Script can read and write, and bind/connect AF_UNIX sockets under these subtrees |
readonlyPaths (nested under a read-write path) |
(deny file-write* network-bind network-outbound (subpath …)) |
Removes write and socket authority a shallower read-write rule would otherwise grant |
deniedPaths |
(deny file-read* file-write* network-bind network-outbound (subpath …)) emitted last |
Overrides any broader allow above |
Apple's Seatbelt evaluates rules with last-match-wins semantics between rules
that carry a filter, so denies emitted after allows correctly override them.
This matches MXC's denied_paths contract on every other backend. An
unfiltered rule does not participate: a later (allow network-outbound) with
no filter will not override an earlier path-scoped deny.
readonlyPaths and readwritePaths are emitted shallow-to-deep, one rule
per path, using the same ordering the Linux backends apply
(wxc_common::filesystem_resolve). Last-match-wins then makes the deepest
intent win at every path, so a read-only entry nested inside a broader
read-write subtree stays read-only. That last part needs an explicit
(deny file-write* network-bind network-outbound …) per read-only path,
because the read-only allow only names file-read* — it says nothing about
write or socket operations, so on its own it cannot displace a broader grant.
deniedPaths is not part of that plan. It is emitted last so it outranks the
filtered allows above regardless of depth. Its position relative to the network
rules does not matter: as noted above, an unfiltered allow cannot override it.
Policy paths are rewritten before they are emitted into the profile:
- A leading
~/~/…is expanded against$HOME. - Redundant lexical segments are collapsed: repeated separators (
//tmp),.segments (/./tmp), and a trailing/. A..segment is rejected as a config error rather than resolved — macOS resolves..physically, after following symlinks (/tmp/..is/private, not/), so resolving it lexically could silently point the rule somewhere else. - A leading symlinked root path segment is rewritten to its real target:
/etc,/tmp,/var→/private/…, and/home→/System/Volumes/Data/home.
A .. segment is rejected on this backend only. The shared config parser
accepts it, so a cross-backend policy using .. will fail on macOS and run
elsewhere. That is intentional: the alternative is the failure mode this whole
section exists to remove — the rule would be emitted, match nothing, and for
deniedPaths fail open. Resolving .. correctly needs std::fs::canonicalize
against the live filesystem, which the profile builder deliberately avoids so it
stays pure string generation, unit-testable on any host.
After resolution, the most-restrictive-wins precedence (deny > readonly >
readwrite) is re-applied to the resolved paths. The shared config parser
already applies it, but only to the raw strings, so two spellings of the same
path (readonlyPaths: ["/private/tmp/x"] and readwritePaths: ["/tmp/x"])
survive it and collide only here. Seatbelt is last-match-wins and the read-write
rule is emitted second, so without this the weaker grant would win and silently
make a read-only path writable.
Steps 2 and 3 are mandatory, not cosmetic. Those root entries are symlinks on macOS,
and the kernel fully resolves a path before matching it against a profile
filter — so a rule written against the unresolved path never matches and is
silently dead. This applies to the automatic $TMPDIR grant too, which
resolves to /var/folders/…. /Users needs no rewriting: it is a firmlink,
not a symlink, so it is already the canonical path.
Seatbelt matches AF_UNIX sockets by path — bind() under network-bind
and connect() under network-outbound — while the (local ip) / (remote ip) filters used by the network policy cover IP sockets only. MXC therefore
governs AF_UNIX sockets with the filesystem policy, not the network policy:
both halves are permitted anywhere the sandbox may already create files.
This is deliberate. A socket is a filesystem object reachable only by processes
that can traverse to its path, and Node toolchains (tsx, vite, esbuild, jest
workers) need both halves for their IPC pipes. Gating them behind
allowLocalNetwork would force real network ingress on just to run a build.
Because the rules are path-scoped they never widen IP binding or IP egress,
which stay governed by defaultPolicy and allowLocalNetwork.
There is a real tradeoff to be aware of, though: connect() is a capability
that file-write* alone did not grant. A sandbox with a broad readwritePaths
root can now talk to any pre-existing listener underneath it, and a control
socket (Docker, ssh-agent, gpg-agent) is a meaningful target. Prefer a
narrow read-write root, and put any sensitive socket in deniedPaths, which
overrides this grant.
Two asymmetries are intentional:
readonlyPathsgrants neither operation. A socket is a bidirectional channel, not a read.deniedPathsdeniesnetwork-outbound, overriding both kinds of allow that can reach it: a broaderreadwritePathssubtree containing the denied path, and the unfiltered(allow network-outbound)emitted bydefaultPolicy: "allow"and the remote-proxy fallback. Without the deny the sandbox couldconnect()to a pre-existing socket inside a denied subtree — a Docker,ssh-agentorgpg-agentsocket is a control plane, so that would be an escape.
A baseline of read-only system paths (/usr/lib, /usr/libexec,
/usr/share, /System, /Library, /private/var/db/timezone,
/private/var/db/dyld, /private/etc, /dev/null, /dev/zero,
/dev/random, /dev/urandom) is always emitted so the dynamic linker
and standard libraries continue to work. SIP-protected system paths
remain readable but unwritable; this is enforced by the kernel
independently of the profile.
| Policy | Generated rule |
|---|---|
defaultPolicy: "block" |
No (allow network-outbound) is emitted; the baseline (deny default) then blocks all IP sockets. |
defaultPolicy: "allow" (no host list) |
(allow network-outbound) plus (allow network-bind (local ip)) and (allow system-socket). |
allowLocalNetwork: true |
(allow network-inbound (local ip)) — on its own this is what lets a process listen() on a local address; it covers the bind() too. (network-bind (local ip) alone is not enough: bind() succeeds and listen() is denied.) Independent of defaultPolicy, and unrelated to AF_UNIX sockets (see above). |
allowedHosts |
Accepted for SDK compatibility, but Seatbelt cannot filter DNS names; the profile degrades to allow-all outbound as best-effort. |
blockedHosts |
Rejected during validation because Seatbelt cannot enforce hostname blocks. |
proxy (loopback: localhost / builtinTestServer) |
Under defaultPolicy: "block", allows only the resolved localhost:<proxy-port>. Other loopback services and the wider network remain blocked. Under allow, the existing allow-all covers it. |
proxy (remote url) |
Under defaultPolicy: "block", rejected during validation — Seatbelt cannot filter a remote proxy by DNS name, so reachability would degrade to allow-all and silently weaken the deny for raw-socket clients. Under allow, allows all outbound as best-effort (the proxy enforces host policy). Use a loopback proxy or builtinTestServer for MXC-scoped reachability under deny. |
Proxy configuration (network.proxy) is supported via the cooperative
env-var model (the same as the Bubblewrap backend): the runner launches or
points at an HTTP proxy and injects HTTP_PROXY / HTTPS_PROXY / ALL_PROXY
(and their lowercase forms) into the sandbox environment, stripping any
caller-supplied proxy vars so sandboxed code can't override them. Well-behaved HTTP clients
(curl, requests, fetch, …) honor it; clients that open raw sockets and ignore
the env vars bypass it. builtinTestServer launches a bundled, testing-only
proxy (unix-test-proxy) and requires --allow-testing-features. macOS has no
per-process WinHTTP-style OS proxy policy, so unlike Windows the proxy is
cooperative rather than kernel-enforced.
| Policy | Generated rule |
|---|---|
ui.disable: true (default) |
(deny mach-lookup …) for com.apple.windowserver.active, com.apple.windowserver.session, and com.apple.coreservices.launchservicesd |
ui.clipboard: "none" (default) |
(deny mach-lookup (global-name "com.apple.pasteboard.1")) |
ui.injection: false (default) |
(deny iokit-open (iokit-user-client-class "IOHIDLibUserClient")) |
The host environment is never inherited — the sandboxed child always starts
from a cleared environment, so host secrets (cloud credentials, API tokens) can
never leak into untrusted code. PATH defaults to /usr/bin:/bin:/usr/sbin:/sbin,
and each process.env entry adds to / overrides that baseline. (This is
unconditional; it applies whether or not process.env is provided.)
If process.cwd is omitted it resolves to readwritePaths[0], else
readonlyPaths[0], else /; a ~/~/… default is tilde-expanded the same way
the sandbox profile expands policy paths. PWD is exported to the resolved
directory so the child's getcwd() takes its fast $PWD path.
# Run with config file
./mxc-exec-mac config.json
# Run with base64-encoded config
./mxc-exec-mac --config-base64 <base64-string>
# Validate the config and exit without executing
./mxc-exec-mac --dry-run config.json
# Diagnostic output to console + file
./mxc-exec-mac --debug --log-file mxc.log config.jsonimport { spawnSandbox, SandboxPolicy } from '@microsoft/mxc-sdk';
const policy: SandboxPolicy = {
filesystem: {
readwritePaths: ['/tmp/output'],
readonlyPaths: ['/opt/tools'],
},
network: {
allowOutbound: false,
},
};
// On macOS, spawnSandbox automatically resolves to mxc-exec-mac and
// builds a seatbelt config.
const pty = spawnSandbox('echo hello', policy);
pty.onData((data) => console.log(data));
pty.onExit((e) => console.log('Exit:', e.exitCode));# Native arch only
./build-mac.sh
# Both Apple silicon and Intel slices for distribution
./build-mac.sh --all
# Debug build
./build-mac.sh --debug
# Rust binary only, skip TS SDK
./build-mac.sh --rust-onlyThe script writes to sdk/node/bin/<arch>/mxc-exec-mac so the SDK's
findDarwinExecutable() picks up the dev build automatically.
The binary produced by build-mac.sh is unsigned. Shipping to end
users via npm or Developer-ID download requires:
codesign --options runtime --sign "Developer ID Application: …" mxc-exec-macxcrun notarytool submit … --waitxcrun stapler staple mxc-exec-mac
These steps are added to the release CI pipeline (see ci-macos and
codesign-notarize todos in the session plan), not to the local build
script — they require Apple credentials and run in a controlled
environment.
-
Proxy support is cooperative, not enforced.
network.proxyinjectsHTTP_PROXY/HTTPS_PROXY/ALL_PROXYinto the sandbox (see Network policy). Clients that honor those env vars route through the proxy; clients that open raw sockets and ignore them bypass it. The macOS sandbox cannot interpose at the TLS/socket layer per process, so this matches the Bubblewrap backend rather than Windows' kernel-enforced WinHTTP policy. -
Per-host network filtering (
blockedHosts) is not supported. Apple's Seatbelt profile language has no mechanism for selectively blocking individual hostnames while allowing all other traffic. TheblockedHostsconfig field is rejected at validation time rather than silently ignored.Alternative approaches considered:
Approach Status Notes pf(Packet Filter) rulesNot viable Requires root privileges, operates system-wide (not per-process), and hostname → IP resolution is unstable for CDN-backed hosts. /etc/hostsmanipulationNot viable Requires root, affects all processes on the system, and is bypassable via direct IP connections or DNS-over-HTTPS. Network Extension framework Potential future path Apple's NEFilterDataProviderAPI can filter per-process at the hostname level. Requires a signed System Extension with thecom.apple.developer.networking.networkextensionentitlement and user approval via System Preferences. Would run as a separate daemon alongside MXC.To deny all network access, use
defaultPolicy: "block"instead. -
sandbox_initis technically deprecated in headers since macOS 10.8 but remains shipping and is used by Apple's own apps and Chromium. It is the same Seatbelt framework that backs the App Sandbox. -
GUI support is limited to native apps. Third-party AppKit-based apps (e.g. Alacritty) work with
guiAccess: trueand the defaultlaunchMethod: "exec"(usessandbox_init()API). Terminal.app requireslaunchMethod: "open"which usessandbox-execon the inner shell — Apple Launch Constraints kill Terminal when exec'd by unauthorized parents. Other Apple system apps (Calculator, TextEdit) cannot currently be sandboxed — they are killed by Launch Constraints and lack an inner shell for the"open"path. Electron-based apps (VS Code, Spotify) may escape the sandbox by re-launching themselves via helper processes. -
No container abstraction. Unlike LXC, there is no persistent container to attach to or destroy — every invocation is a fresh process tree.
-
SIP overrides the profile for protected system paths. You cannot grant write access to
/Systemor/usreven with explicitreadwritePaths.
| Feature | AppContainer (Windows) | LXC (Linux) | seatbelt (macOS) |
|---|---|---|---|
| Isolation level | Process | Container | Process |
| Startup time | Fast (~10 ms) | Medium (~1 s) | Fast (~10 ms) |
| Filesystem | BFS policy | Bind mounts | Profile subpath rules |
| Network | Windows Firewall | iptables/nftables | Profile network-* rules |
| Privileges | Optional admin | Root (or unprivileged LXC) | None — sandbox_init is unprivileged |
| Container lifecycle | Yes (named) | Yes (named) | No (per-process) |
| Proxy support | Yes (WinHTTP, kernel-enforced) | No | Cooperative (env-var) |