The sandbox, and going away
Every bash and exec call runs in a sandbox by default — a direct call, one from a script,
and one from a child session — in every mode, --approve-all included, unless you approve one of
the two ways out (below). In auto the judge (modes.md) still judges every
command; the sandbox contains what it misses.
The backend is bubblewrap on Linux (Windows through WSL2) and Seatbelt on macOS 15.4 or later
(“Requirements”, below). A machine that is itself the sandbox — a dedicated VM, a container, a CI
runner — can say so with mode = "external" (“External mode”, below).
What it does
- Reads everywhere except the hidden list (below). A hidden path reads as empty on Linux. On
macOS it fails with
Operation not permittedand cannot be run. - Writes only to the project, a private temp folder, and
[sandbox] writablepaths. On Linux the temp folder is/tmp. On macOS it is$TMPDIR,/tmpis not writable, and the user’s temp folder (/var/folders/…/T, used by a baremktemp) is writable except Apple’scom.apple.*entries. - Untracked protected files are read-only, even in the project (below).
- Network. On Linux, only localhost within the same command: no other session’s or the
machine’s services, no internet. On macOS, none, not even
127.0.0.1: any port needsnetwork: true(“The two ways out”, below). - No local sockets: no ssh-agent, gpg-agent, D-Bus, Docker or tmux. Python’s default
multiprocessing needs a full escape on Linux; on macOS only a
Managerfails. - No credentials. Every provider’s key variable, and anything named
*TOKEN*,*SECRET*,*_KEY,*CREDENTIAL*,*PASSWORD*,*PASSWD*,*_PASS,*_AUTH,*_AUTH_*,*COOKIE*,AWS_*,SSH_AUTH_SOCKorGPG_AGENT_INFO, or whose value is a URL with a password, is stripped from the environment.[sandbox] env_hideadds names. - A private process view. Other processes are invisible, and background processes end with
their command. On Linux there is no terminal to read from. On macOS
pscannot run, and only a terminal the command opens itself is usable.
The hidden list is ~/.ssh, ~/.gnupg, ~/.aws, ~/.azure, ~/.config/gcloud,
~/.config/gh, ~/.config/hub, ~/.docker, ~/.kube, ~/.netrc,
~/.git-credentials, ~/.npmrc, ~/.pypirc, ~/.gem/credentials,
~/.terraform.d/credentials.tfrc.json, ~/.vault-token, ~/.password-store,
~/.local/share/keyrings, ~/.secrets, ~/.cargo/credentials*, ~/.bash_history,
~/.zsh_history, ~/.claude/, ~/.claude.json, ~/.codex/, the harness’s own
config root, and its sessions/ and checkpoints/ folders under the data root
($DATA/sessions/, $DATA/checkpoints/). [sandbox] hide adds paths. A symlink is hidden
with its target (~/.ssh -> ~/dotfiles/ssh). Linux covers paths that exist at session start;
macOS also covers ones created later. A ~/.cargo/credentials* file is hidden only if it exists
at session start.
macOS adds ~/Library/Keychains, ~/Library/Cookies, ~/Library/HTTPStorages,
~/Library/Accounts, ~/Library/Group Containers, ~/Library/Containers, ~/Library/Mail,
~/Library/Messages, ~/Library/Safari, the profile folders of Chrome, Chromium, Firefox, Edge,
Brave, Arc, Vivaldi and Opera under ~/Library/Application Support, and ~/.zsh_sessions.
With HARNESS_HOME set, the list (minus the macOS additions) also applies inside it. If it is
the project, commands cannot see the project’s own .npmrc, .claude/ or .docker/. An entry that
holds the project or its git folder stays visible, and the start-up notice names it. The exception
is a Windows Users folder under WSL2: a project inside one makes the sandbox unavailable instead
(“Windows (WSL2)”, below).
mode = "external" and "off" hide nothing.
Session logs are always hidden, so harness export does not work in a sandboxed command; if the
project holds sessions/ or checkpoints/, sandboxed commands are refused. The harness’s other
data, state and cache are read-only to commands.
The protected list (modes.md, “Protected files”) applies here too, except **/
patterns such as **/build.rs. An untracked protected file is read-only to commands, however the
write is spelled. A protected directory with nothing tracked is read-only whole. One with tracked
files is judged file by file, and anything a command adds is moved aside (below); past 1,000
entries it is read-only whole. A file counts as tracked only if it was tracked at session start (or
last resume) and still is, so staging it does not unlock it. With no repository, nothing is
tracked. .claude/worktrees/ stays writable.
A tracked protected file — CLAUDE.md, a workflow, .cargo/config.toml — is writable, so a
sandboxed git checkout completes. Commands can write .git, so to review such changes, note the
commit hash and the ignored files before the work, outside the project:
S=$(git rev-parse HEAD); B=$(mktemp)
git ls-files --others --ignored --exclude-standard > "$B"
After it, review from a fresh index built from that commit:
git --no-replace-objects for-each-ref refs/replace # a line you didn't make: stop here
T=$(mktemp); r() { GIT_INDEX_FILE=$T git --no-replace-objects "$@"; }
r read-tree "$S"
r diff --stat # each tracked file changed; drop --stat to read the changes
r ls-files --others --exclude-standard # files added
r ls-files --others --ignored --exclude-standard | grep -vxFf "$B" # added, but ignored
This defeats tricks that make git status look clean, and the last list catches files hidden by a
new ignore rule. It is not a proof: a change to an existing ignored file (in .venv/, say) shows
nowhere, and submodules have their own index. Checkpoints (checkpoints.md) do
not rely on the index.
A protected path a command creates — a new .envrc or .vscode/, a new file in a .claude/
with tracked files, a re-pointed protected link, or a tracked file replaced by another kind of
file — is moved aside afterwards, never deleted, even if git now tracks it. It goes to
<name>.harness-quarantined-<16 hex digits>, beside it, or at the project root if it was inside a
protected directory. Links are moved, never followed. A note says what moved where; to keep it,
write it with the file tools, which ask. If the move fails, later sandboxed commands are refused,
naming the path, until it moves or you move it on the host.
A new .claude/ that holds only worktrees/ is kept, so git worktree add .claude/worktrees/<name>
works. If a .claude/ with nothing tracked has no worktrees/ yet, that command fails: create the
directory with the file tools, or run the command with approval.
A protected link to the project or a folder above it (.claude -> ., .vscode -> ..) refuses
every sandboxed command, naming the link, until you replace it on the host. So does a protected link
whose target needs more mounts than bubblewrap allows: replace it or shorten the target’s path.
A directory a command makes unreadable (chmod 000) becomes read-only; the result says to check it
before restoring its permissions. An unsearchable project root (chmod 600) refuses every
sandboxed command until you restore it on the host.
Git’s auto-run files are read-only, even when committed: .git/hooks/, .git/config,
.git/config.worktree, worktree and submodule copies, your global and system git config, and
whatever those name (core.hooksPath, core.fsmonitor, include/includeIf files, a hook’s
linked script). If a command re-points or removes a symlink on the way to one, you’re told to
restore it before your next git command.
A git checkout of a branch whose hooks differ therefore stops short. The model is told which
files git could not update. After a checkout or switch it gets git checkout -- <those files> to finish
outside the sandbox, which asks you. After a merge, pull, reset --hard or stash pop, it is told
to check git status. With escape = "never", it tells you which files are left.
A .git/commondir a command creates is moved aside. A hooks folder, include or git config file
that did not exist at session start is not locked: creating one gets a note, so husky install
keeps working.
Past 512 entries in .git/modules/ or .git/worktrees/, that folder is read-only whole, so a
submodule update or git worktree add fails inside the sandbox. A command that would still need
more binds than the system allows is refused, naming the folder: remove what you don’t need on the
host, then resume.
On macOS these are path rules with the same effect, under a separate limit (“The budget”, below). Also:
-
A command cannot create the first missing folder above a hidden or read-only path, so making a missing
.git/hooksfails (mkdir: .git/hooks: Operation not permitted). -
Folders above protected paths cannot be renamed (“What you carry”, below).
-
A protected file with a second hard link refuses the command:
denied: `.envrc` has another hard link, so a command could change it through that name. Fix on the host: remove the other link, or copy the file over itself (`cp -p .envrc x && mv x .envrc`). -
An unsearchable folder on the way to a hidden or read-only path refuses the command, naming it and, if needed, the fix (
chmod u+rwxon it).
What it does not stop
- Damage inside the project (
git reset --hard,rm -rf src): that is the judge’s job.rm -rf(or--recursive --force) on a path with a.gitcomponent is refused in every mode. - Reading an in-project secret like
.envwith the file tools: it asks first, except under--approve-alland inrun --mock. - Anything an approved full escape does.
- What a network-only command reaches: project files and localhost services.
- The removal ask (
modes.md, “Local asks”) sees only named files in known forms. Globs,find -delete,xargs rm,sed -i, one-liners and scripts go to the judge. - Overwriting is not asked (
cp other f,cat > f << EOF): that is an edit. - A touched-but-unchanged file reads as changed (
touch,chmod, renamed back): an extra ask. - With no repository, every named delete or empty asks.
- The decode-and-run ask is a text match. What it misses goes to the judge, still sandboxed.
- Tracked protected files and build files are writable. Changes to
CLAUDE.md, a workflow,build.rs,package.jsonscripts,conftest.pyor aMakefileshow only in the review above, and run the next time you build or test on the host. Review first. - A hard link made outside the sandbox to an untracked protected file lets a command change it through the other name. On macOS such a file refuses the command instead.
- A protected directory on another filesystem keeps what a command creates in it, moved aside in place.
- On Linux, a protected link into the private
/tmpis not read-only. - A path the harness couldn’t move aside blocks commands only until the session ends. Move it on the host before resuming.
- Concurrent children: a protected path one child creates can be moved aside by a sibling’s check.
Which tools it covers
Only the two that run programs.
| Tool | In the sandbox? | What limits it instead |
|---|---|---|
bash, exec | yes | the sandbox |
read_file, write_file, edit_file, glob, search | no | the project root; no links out; credential reads and protected writes ask; the file tools open regular files only, and read none over 16 MiB (below) |
script | no | it acts only through the tools above |
agent | no, but the child’s own bash/exec are | the same rules, one level down |
skill | no | it only reads skill text; some loads ask |
The file tools refuse a symlink out of the project, naming its target; a project CLAUDE.md or
.agents/ file that links outside does not load. Reading a credential file asks first in every
mode, with Asked because it reads a credential file, except under --approve-all and in
run --mock. Credential files are .env/.env.* (not .env.example, .env.sample or
.env.template), *.pem, *.key, *.p12, *.pfx, and anything in a .secrets folder in the
project. The “this turn” choice reads except this kind: the next one asks again. search skips
credential files and says how many (credential_files_skipped).
The file tools read only regular files, up to 16 MiB. After the checks above, read_file, edit_file
and write_file refuse:
- a FIFO, a socket, a device or a folder (a read of a FIFO or a device can wait forever):
refused: <path> is not a regular file; the file tools work only on regular files; - on Linux, a file in
/procor/sys, which matters when the project is at/, whereread_file proc/self/environwould return the harness’s own environment:refused: <path> is in /proc or /sys; the file tools do not read there.
read_file and edit_file also refuse a file over 16 MiB, before reading any of it:
refused: <path> is larger than 16 MiB; the file tools read files up to 16 MiB. write_file replaces a
file of any size, since it keeps none of it. search skips a file over 16 MiB and says how many it skipped
(large_files_skipped, shown only when above zero). bash and exec keep the first 1 MiB of each stream
as before, and no longer hold the rest in memory while a command runs. A file that is a link out of the
project, or a credential file, keeps its own refusal. A session recorded before 0.26.0 replays as it was
recorded.
The two ways out
bash and exec take two more arguments, each with a reason shown to you and never sent to the
judge.
network: truekeeps the rest of the sandbox and adds the machine’s network; on macOS that includes any port on127.0.0.1. It hides the project’s credential files and every.secretsfolder, found fresh each time. On Linux a hidden.secretsreads empty and writes into it are discarded; macOS refuses them. A.pemup to 2 MiB holding only public certificates stays visible.node_modules,site-packagesand__pycache__are skipped. Past 1,000 files to hide or 100,000 entries to search, or at a directory or credential-named link it can’t read, the command is refused, naming the path: fix its permissions, dropnetwork, or useoutside_sandbox. It does not stopgit show HEAD:.env, credentials inside skipped folders (.git,target,node_modules,site-packages,__pycache__), or a renamed copy. It is judged inautoand asked in manual, and a grant for plain commands never covers it.outside_sandbox: trueruns the command as you, unconfined. It always asks, in every mode, with no “for this turn”/“for this session” option. Unconfined (this way, or with the sandbox off or unavailable), if a process still holds the output two seconds after the command exits, the call returns. Processes in its group are ended; ones that left it (setsid, job control) keep running. The model sees[output cut: the command exited, but a process it started still holds its output].
[sandbox] escape = "never" removes both: nothing leaves the sandbox or reaches the internet.
Under --approve-all, network: true is approved automatically and outside_sandbox: true
is refused; the model is told to carry on and name the step in its final message. Child sessions
inherit this. A conversation (--input-format stream-json) refuses outside_sandbox: true the
same way unless --allow-escapes says a person answers the program’s requests
(docs/headless.md, “Approvals”).
Away
/away (panel or plain REPL) says you’re not watching: until the current task ends if typed
mid-task, or from the next task if typed while idle. The plain REPL reads it only between tasks.
Type /away again to come back. harness run --away --prompt "…" does the same for a scripted
run; it needs --prompt and starts in auto unless --mode says otherwise. Both are refused
under --approve-all and in a mode where every command asks.
While you’re away, anything that would ask you is refused (an unreachable judge and a child’s ask
included), and the model is told to keep going. A network-only command is judged at the away
cutoff ([judge] away_cutoff, default 0.2 like the ordinary cutoff; lower asks more while you’re
away, config.md). Grants from
before you left (AllowForTurn/AllowForSession) do not apply.
The timeout
[away] timeout (default 15m) is how long a question waits before it is refused as if you’d
typed /away; the rest of the task then runs away. It applies only where /away can start: a mode
where commands don’t ask, with the sandbox on or external. Otherwise questions wait with no limit.
A child’s question follows your session’s mode. Values: off, or Ns/Nm/Nh up to 24h. On
the panel any key restarts the clock.
After the breaker has tripped in the current task (below), a timeout ends the turn instead, and the session waits for your next message.
The breaker
While you’re away, 3 refusals in a row, or 20 across the away stretch (unreachable-judge refusals included), trip the breaker. Away ends, and the next thing that needs you waits as an ordinary ask. Until the task ends, a timeout ends the turn instead of restarting away, and the away cutoff stays in force even if you return. A child spawned after a trip starts tripped.
A tripped root task keeps doing what needs no approval. A tripped child stops, and its parent gets
that as the result. Under run --away, the next question is denied at once if nothing can read it,
or after the timeout if something can. Either way an unfinished turn exits 1.
/iterations for long tasks
An away task can still hit the per-task cap on model calls (32 by default); each refusal costs one.
/iterations <n> raises it for the rest of the session, the running task included, before its next
model call; alone, it prints the cap. --away warns at launch if the cap is the default. A resume
keeps a cap you chose (--max-iterations or /iterations); one from the default or config.toml
follows config.toml as it reads at the resume. A crash resume keeps the cap the log last recorded.
Requirements
On Linux the backend is bubblewrap (bwrap) with a
seccomp filter. It needs unprivileged user namespaces, which Ubuntu 24.04 usually blocks through
AppArmor; the harness names the fix. Under Windows it runs in WSL2 (“Windows (WSL2)”, below).
On macOS the backend is Seatbelt, through /usr/bin/sandbox-exec. It needs:
- macOS 15.4 or later, on Apple Silicon or Intel (
README.md, “macOS”); 27.0 or later is better, on Apple Silicon (“Update macOS”, below). /usr/bin/sandbox-exec, which ships with macOS.- The harness outside any other sandbox, since Seatbelt refuses a second one.
Otherwise each start says why the sandbox is unavailable:
the sandbox is unavailable: the harness is itself running inside a sandbox. Auto is off, and --approve-all and --away will not start. Fix: start the harness outside it, or set [sandbox] mode = "external" when that sandbox is the boundary, in <your config file>.
the sandbox is unavailable: `/usr/bin/sandbox-exec` is missing. Auto is off, and --approve-all and --away will not start. Fix: set [sandbox] mode = "external" when this machine is its own boundary, or mode = "off" to run commands unconfined, in <your config file>.
Each session start also runs a probe (about 0.2 s) and names any check that fails:
the sandbox is unavailable: the sandbox did not hold (listing_order). Auto is off, and --approve-all and --away will not start. Fix: no setting fixes a failed check; report the check's name. To run without the sandbox on purpose, set [sandbox] mode = "off" in <your config file>.
A failed check usually follows a macOS update, and no setting fixes it. Until the harness is
updated, mode = "off" restores auto and --approve-all, and mode = "external" also
restores --away when the machine is its own boundary. Report the check’s name.
Default Docker and Docker Desktop cannot nest the namespace bubblewrap needs. If the container is your boundary:
[sandbox]
enabled = false
That runs commands unconfined but still refuses run --away; for unattended runs use
mode = "external" (“External mode”, below). On Linux, when markers such as /.dockerenv
suggest a container, the unavailable message says so and suggests mode = "external" first:
the sandbox is unavailable: user namespaces are blocked; the harness seems to be inside a Docker container (/.dockerenv). Auto is off, and --approve-all and --away will not start. Fix: set [sandbox] mode = "external" when that container is the boundary, in <your config file>; otherwise sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0.
When the sandbox is unavailable, the harness fails closed. auto is not offered: choosing it,
resuming into it, or a judging default mode in config.toml prints the cause and fix and falls
back to a plain manual that judges nothing. --approve-all, run --mode auto, run --away and
harness eval exit 2 at launch, as does resuming a session that crashed with its sandbox on. In
manual, approved commands run unconfined.
How mode changes that:
| sandbox | auto | --approve-all, run --mode auto, eval | run --away, /away |
|---|---|---|---|
| unavailable | not offered | exit 2 | exit 2 |
mode = "off" | yes, unconfined | yes, unconfined | refused |
mode = "external" | yes | yes | yes |
On macOS the model is told it has BSD tools (sed -i '', stat -f).
A killed command’s leftovers
harness resume brings back a session whose harness was killed mid-command (terminal closed, SSH
dropped, a reboot), without re-running the command (checkpoints.md,
“Resuming a killed session”). It also handles what the command left behind.
The record. Before each command the harness writes leftovers/<session>/<effect number>
under its state folder (~/.local/state/harness by default, config.md), and
removes it once the command is done.
What resume does, before anything else runs, for the session and its children:
-
Linux: the sandbox died with the harness. One still running holds sandboxed commands (below).
-
macOS: sandboxed leftovers are stopped:
Leftover processes: 2 stopped (they ran in the sandbox). -
A command outside the sandbox that was still running is named, never stopped:
Still running outside the sandbox: 275710 (sleep). They were left running.If the command’s first process is gone:
May still be running outside the sandbox: …. The command's first process is gone, so these may belong to another program; check with `ps -p <numbers>` before stopping one.After a reboot, nothing is looked up.
-
A record with no process (killed before the command started) quotes its first two words:
A command may still be running; it could not be identified. Look for it with `pgrep -fl 'cargo test'`; stop any it lists that you did not start, with `kill` and its number. -
Then protected files the command created are moved aside:
The command created .envrc, which runs code or steers the agent later, so the harness moved it to .envrc.harness-quarantined-1231b2265de8612a. To keep it, write it with the file tools; the user is asked.
An interrupted resume reports again.
Holds. If a leftover can’t be confirmed gone, sandboxed commands are held (unsandboxed ones are not). Each one re-checks, so the hold lifts once the cause is gone:
-
An old sandbox still running, or one that
could not be checked:Sandboxed commands are held: a sandbox from before the harness was killed is still running (pid 4321). It ends by itself, or stop it with `kill -9 4321`; then run the command again.On macOS, for a command that never entered its sandbox:
a command from before the harness was killed has not entered its sandbox (pid 4321). Stop it with `kill -9 4321`; then run the command again. -
Files that could not be moved aside:
Sandboxed commands are held until .envrc is moved aside: move it away or remove it on the host. -
A record that is empty, damaged, from another version, or incomplete:
Sandboxed commands are held: ~/.local/state/harness/leftovers/<session>/3 could not be read (it is empty or damaged), so the files that command may have planted were not checked. Protected files in the project now: .envrc, CLAUDE.md. Remove any you did not make. Then run: rm ~/.local/state/harness/leftovers/<session>/3
Another folder. Resume from the folder the command ran in:
session failed: a command this session ran in /path/to/project was killed; files it may have planted there can only be checked from that folder. Resume it there: cd /path/to/project && harness resume <session>
A records folder that can’t be listed stops the resume the same way.
When the record cannot be written (permissions, a full disk), sandboxed commands are refused:
Sandboxed commands are refused: the harness cannot write in ~/.local/state/harness/leftovers/<session>/ (Permission denied). Make that folder writable by you, or free disk space, then run the command again.
Commands outside the sandbox, or with it off, run with the note
leftover record not written: <why>; if the harness is killed while this command runs, its processes are not reported at resume.
Known gap: a session held at resume and then closed while idle is neither held nor swept at its next resume.
On macOS
Seatbelt enforces the same policy. Where macOS differs (below), the model is told too. The session
record names the backend (seatbelt macOS 27.0 (26A428)).
Side by side
| Policy | Linux (bubblewrap) | macOS (Seatbelt) |
|---|---|---|
| Reads | all but the hidden list; hidden reads empty | the same; hidden fails Operation not permitted |
| Hidden list | covered at session start | plus the macOS additions; later paths covered too |
| Writes | the project, private /tmp, a linked worktree’s git directory, [sandbox] writable | the same, private folder at $TMPDIR, plus the user’s temp folder minus com.apple.* |
The machine’s /tmp | out of reach | out of reach; a project inside it works |
| Git’s auto-run files, protected files, the harness’s own folders | read-only; created ones moved aside | the same; folders above them cannot be renamed |
| Network, plain command | loopback within the command | none |
| Network-only command | the machine’s network | the same |
| Unix-domain sockets | refused | refused; socketpair works |
| DNS | fails at once | fails at once |
| Processes | all end with the command | invisible; swept when the command ends |
| Shared objects | a private IPC namespace | others’ shared memory refused; named semaphores shared |
| Devices | a minimal /dev | a short list; none of your disks, console or terminals |
| Terminal | a fresh devpts | one the command opens |
| Credentials in the environment | removed | removed |
| Ways out | — | refused: open, Apple Events, launchd, the keychain, the pasteboard, most system services, terminal input injection, setuid programs, a nested sandbox-exec |
A refused command shows the system’s own error, then the blocked-command note:
touch: /tmp/x: Operation not permitted
What you carry
- Loopback tests need
network: true. A test against a fake server on127.0.0.1(acargo test, say) fails in a plain command — Rust:Operation not permitted (os error 1), Python:PermissionError: [Errno 1] Operation not permitted. Withnetwork: trueit has the whole network, is judged, cannot run underescape = "never", and cannot read the project’s credential files, so a TLS test with a*.keyor.pemfixture needs a full escape. - The user’s temp folder is shared with your other programs. A bare
mktempand Foundation use it rather than$TMPDIR, so files left there outlast the session. Usemktemp -d "$TMPDIR/x.XXXX". The user’s cache folder (/var/folders/…/C) is read-only. - A network-only command listens on every address, reachable from the local network.
- Keychain credentials do not work inside:
securityfinds nothing, andgit’sosxkeychainhelper and theghlogin should fail too (unverified). Run the step that needs the credential outside the sandbox. - Named shared memory is refused except
libomp’s, so Python’smultiprocessing.shared_memoryand PyTorch worker tensors fail withPermissionError. Named semaphores are shared with your programs. System V objects outlive the command. - A command cannot rename or remove a folder holding a hidden or read-only path (or, for a
network-only command, a credential file), nor the project, the private temp folder or another
writable root:
mv .vscode .vscode-oldfails withOperation not permitted. A whole-folder clone is refused;cp -c -Rcopies file by file.
These fail with their own error and get no blocked-command note: open
(No application knows how to open URL …), osascript to another app
(Connection Invalid error for service com.apple.hiservices-xpcservice), security
(The specified item could not be found in the keychain.), and pbcopy and launchctl
(exit 1, no output).
Tools that sandbox themselves
Seatbelt refuses a second sandbox, so a program that sandboxes itself fails inside; an App Sandbox
program is killed at once. SwiftPM is the common case: swift build fails with
sandbox-exec: sandbox_apply: Operation not permitted
swift build --disable-sandbox fixes it. Similar: xcodebuild (run it with outside_sandbox),
Bazel (--spawn_strategy=local, unverified), Chrome and Electron (--no-sandbox, unverified).
When such a failure is a command’s whole result (exit 65 or 71), it reads as the sandbox failing to
start, with no note:
io: sandbox-exec failed to start the sandbox: sandbox_apply: Operation not permitted
A held session
When a command ends, even on a timeout or Ctrl-C, the harness ends every process it started. If it cannot confirm they all ended, the result is withheld and the session is held: every later command and file tool, children’s included, is refused until the process is gone. The command’s error (the process ID is an example):
the harness could not confirm that every process this command started has ended (process 4321 is still running), so its result is withheld and nothing else runs in this session until it can: end it on this machine (`kill -9 4321`), then run any command to check again. Files a leftover process creates are checked once it is confirmed.
Each later call:
an earlier command left a process the harness could not confirm has ended (processes 4321 and 4322 are still running), so this was not run: end them on this machine (`kill -9 4321 4322`), then run any command to check again.
At most eight IDs are listed, then and N more. If only unsettled entries remain, the text says how
many (3 entries of the process list could not be settled) and the fix is wait a moment. Run the
kill -9 in your own terminal, then any command. A killed harness’s leftovers stay sandboxed until
resume sweeps them (“A killed command’s leftovers”).
The budget
A sandboxed command starts about 30 ms later, and a session start about 0.2 s later. Every path a command’s profile names adds start time, so a profile is capped at 1,000 path filters; a typical project uses about 100. Past the cap the command is refused, naming the largest list and the fix:
denied: the sandbox for this command needs 1155 path filters, more than Seatbelt compiles in a usable time (1,000): most are hidden paths. Fix on the host: shorten the [sandbox] hide list, then resume the session.
It can also name the protected paths (drop unneeded protected-list entries and git files) or the credential files (move unneeded ones out of the project).
The whole command line, environment included, must fit the system’s 1 MiB argument limit. Past it the refusal names the largest part:
denied: the sandbox for this command needs 1111404 bytes of arguments and environment, more than the system's argument limit (1048576 bytes): the largest part is the command's own arguments (1100066 bytes). Fix: shorten them, or pass the data to the command through a file in the project or $TMPDIR.
An environment near 1 MiB makes the sandbox unavailable at start.
A project path over about 1,000 bytes cannot run sandboxed commands. The message blames permissions, but the cause is the length:
denied: the project root cannot be read (File name too long (os error 63)), so the command was not run: restore its permissions on the host.
Move the project to a shorter path.
Update macOS
macOS 27.0 fixed a Seatbelt flaw Apple describes only as “an app may be able to bypass network
restrictions” (CVE-2026-84551, 27.0’s notes). Apple’s
record (NVD) marks every macOS below 27 as
affected, so below 27, doubt the network rules. An Apple Silicon Mac fixes it by updating to 27;
update when you can. An Intel Mac cannot (27 does not run on Intel), so on Intel the network rules
carry that doubt for good. The backend in a session’s record shows which macOS enforced it.
Windows (WSL2)
Windows is supported through WSL2, as a Linux install inside a distribution. It has been checked on one Windows machine (WSL2 with Ubuntu 26.04); the rest of what follows is from Microsoft’s documentation and the code.
- Check the version. In PowerShell,
wsl -l -vlists each distribution with a VERSION column, which must say 2.wsl --set-version <name> 2converts one;wsl --installsets up WSL2 with Ubuntu. WSL1 is not supported, and neither is Windows on ARM. - Set up inside the distribution. Install bubblewrap with its package manager
(
sudo apt install bubblewrapon Ubuntu), then run the installer from Getting started there. The Linux requirements above apply unchanged. - Keep projects in the Linux file system (
~/code), not under/mnt/c. Names on a Windows drive compare without case, sowrite_file claude.mdcould replaceCLAUDE.mdwith no prompt. The harness folds case there, as it does on a Mac’s default disk, so that write is refused; but the Linux file system is the safe place. - What the sandbox hides. WSL2 mounts each Windows drive at
/mnt/c,/mnt/d, and so on, and the sandbox reads the whole file system, so a command would otherwise readC:\Users: every account’s.ssh, AppData (browser profiles, app tokens) and Documents. Each drive’sUsersfolder is hidden under every spelling that names an existing folder (Users,users,USERS, …), because the drive folds case. The rest of the drive stays readable, and read-only. The mount table decides what a drive is, so a moved automount root (/etc/wsl.conf) is followed. - A project (or its git folder) inside a
Usersfolder (/mnt/c/Users/you/code) makes the sandbox unavailable, since hiding the folder would hide the project. Auto,--approve-alland--awayfail closed, as for any unavailable sandbox (/mnt/c/codekeeps the sandbox):
the sandbox is unavailable: the project is inside a Windows Users folder (/mnt/c/Users), which the sandbox hides. Auto is off, and --approve-all and --away will not start. Fix: move the project into the Linux file system (such as ~/code) or elsewhere on the drive, or set [sandbox] mode = "off" to run commands unconfined, in <your config file>.
- Windows programs do not start from a sandboxed command.
cmd.exeand/mnt/c/…/*.exereach Windows over a Unix socket, which the sandbox refuses. Run them outside the harness. - Not covered: a
Usersfolder moved to another drive or folder, a drive mounted after the session starts, andUserson a Windows partition mounted outside WSL (dual boot).
[sandbox] and [away]
Both tables are read only from your own config.toml: a project cannot widen or disable the
sandbox or lengthen the timeout. A [sandbox] table in a config reached through HARNESS_HOME or
XDG_CONFIG_HOME is refused if it lies inside the project (config.md).
[sandbox]
enabled = true # false runs every command unconfined (mode = "off")
mode = "on" # "on", "off" or "external"; must agree with enabled
writable = ["~/.cache/pip"] # extra ~/… paths commands may write
hide = ["~/.ssh"] # extra ~, ~/… or absolute paths to hide
env_hide = ["MY_APP_TOKEN"] # extra variable names (or * patterns) to strip
env_pass = ["GITHUB_TOKEN"] # exact names kept in the environment; external only
escape = "ask" # "ask" (default) or "never" (no ways out)
[away]
timeout = "15m" # "off", or Ns / Nm / Nh, up to 24h
writable takes ~/… paths below your home directory: not ~ itself, and not absolute paths.
hide takes ~, ~/… or absolute paths; relative entries are refused at load.
[judge] away_cutoff (default 0.2) is in config.md.
External mode: the machine is the sandbox
[sandbox] mode = "external", or --sandbox external for one session, says this machine is
itself the boundary: a dedicated VM, a container, a CI runner. The harness builds no sandbox of
its own. It works on Linux and macOS, and only your config or the flag can set it
(config.md, “[sandbox] — mode, env_pass and --sandbox”). At start it
says:
this machine is the sandbox ([sandbox] mode = "external", set by <your config file>): commands run without a sandbox from the harness.
(set by --sandbox when the flag chose it.)
What it allows. auto, --approve-all, run --away, /away and harness eval.
--approve-all refuses only the hard-deny list (modes.md, “The hard-deny list”);
run --away with --approve-all is still refused.
What goes. Hidden folders, read-only protected files, the private /tmp, the network rule,
the probe, and the two ways out. What stays. The judge, the local asks, the file tools’ limits
and asks, and the hard-deny list.
The environment. For every command, children’s included:
- A provider’s key variable is always removed.
- Otherwise a name in
[sandbox] env_passstays (exact, case-sensitive, no patterns). - Otherwise the credential names under “What it does”, URL values with a password, and
env_hidenames are removed.
This is not a credential boundary: commands run as the harness’s user and can read that user’s other processes’ environments. Keep credentials off the machine.
The session record lists the names removed and those kept by env_pass (never values), and the
model is told what is missing:
Commands run without a sandbox from the harness: this machine is the boundary. These variables are not available to commands: MY_APP_TOKEN, OPENAI_API_KEY.
The second sentence is dropped when nothing was removed.
Resuming. To switch a session to or from external, /quit and harness resume <session>
with the new setting. A resume after a crash keeps the recorded sandbox: external continues only
if it is still set and the recorded environment names, platform and case still match. Otherwise it
exits 2 with the start-up notice, the first difference
(the variables removed from commands changed since the crash: recorded …; now ….), and:
a session resumed after a crash keeps the sandbox it recorded; to change it, /quit and resume, or start a new session
A session recorded on resumes in the sandbox or exits 2. One recorded off, unavailable, or
before the sandbox existed counts as unavailable unless the setting is now off.
A 0.5.x binary cannot read a log that records external.
What the environment must do instead. A command can damage anything outside the project that the machine’s user can touch. So:
- keep key folders, other checkouts and long-lived tokens off the machine; pass a scoped,
short-lived token through
env_pass; - limit its network: the harness enforces none (the record says
"network":"host"); - snapshot or discard it after each run.
Protected files are writable to commands under external, and under off. The file tools
still ask, but a command can write .envrc, .claude/, .vscode/ or a workflow, and the judge
treats that as an in-project edit. Planted code runs when the project is next opened, so review
the work (“What it does”). The judge is untested on macOS commands
(brew, launchctl, osascript, security, defaults).
What stays fine inside
On both systems:
- DNS fails fast:
Network is unreachableorCould not resolve host, not a hang. On macOScurlprintscurl: (6) Could not resolve host: example.com. - The private temp folder (
<cache>/sandbox-tmp/<session>) is shared with child sessions.
On Linux:
cargo testagainst a loopback fake server.- A fresh
devpts: a command that opens a pseudo-terminal gets a private one. - The private
/tmp, in place of the machine’s (your tmux sockets, other sessions’ files).
On macOS:
- Ordinary builds and tools:
git,cargo buildwith a C build script,swiftcwith its module cache, a zsh here-document, a Foundation atomic save, and Python’smultiprocessingPool,ProcessandQueue. A package manager that fetches needsnetwork: true. $TMPDIR, the private folder;mktemp -d "$TMPDIR/x.XXXX"works.- A terminal the command opens,
socketpair, pipes and/dev/null.
A blocked command says so
When a sandboxed command fails, or exits 0 while its output shows a sandbox refusal on a protected
git path, the result carries a note: what the sandbox blocks, and how to ask for more —
network: true for the internet, outside_sandbox: true for anything else. On macOS it also
says network: true is the way to a local port. A failing
open, osascript, launchctl, pbcopy or security gets no note (“What you carry”, above).
An overnight task
Set the model, thinking effort and mode, raise the per-task cap, then go away — the rest is one ordinary message:
harness run
/model claude-opus-5
/model thinking high
/mode auto
/iterations 500
/away
<the actual task>