Sandbox internals

How the isolation is actually built: the two backends, the generated Seatbelt profile, the three design decisions, port enforcement, tracing, and the measured findings — including a loopback escape that was found against a live daemon and fixed.

This page is the why behind Running code in a sandbox. It is written for people evaluating what the isolation is worth, or working on it.

Two backends

directdocker
MechanismmacOS Seatbelt (sandbox-exec) / Linux bwrap / Windows AppContainera container
Requiresnothinga running Docker daemon
Startupinstantseconds (plus the first image pull)
Blocks writes outside the sandboxyesyes
Blocks reading ~/.ssh, ~/.aws, the Keychain, ~/.senclawyesyes
Blocks reading the rest of the diskyes, at strict/allowlist (the default)yes
CPU/RAM/pid limitsno on Unix · yes on Windows (Job Object)yes
WindowsAppContainer + Job Object — not verified on real hardwareyes

When a machine has neither sandbox-exec nor bwrap, direct falls back to Degraded: the child process runs normally, with no OS barrier. That is a named, reported state — not silence pretending to be isolation.

How each backend builds the jail

The three read modes (strict / allowlist / open) mean the same thing to a user, but the backends implement them in fundamentally different ways:

  • Seatbelt: the whole disk is still present, only rules stand in the way — (deny file-read*) and then grant back individual subpaths.
  • bubblewrap: a path that is not bound simply does not exist in the mount namespace. There is no rule to get wrong. It uses --ro-bind-try because the system-root list is deliberately broad across distributions (glibc's /lib64, NixOS's /nix/store) and a missing entry must be skipped rather than break the sandbox.
  • docker: the setting does not apply — a container starts from an image; there is no host disk to jail.

An unrecognised value in the database falls back to strict, never to open: a typo must not silently open the disk.

The generated Seatbelt profile

Rebuilt on every run, and the order matters because the last matching rule wins:

(allow default)              ; deny-by-default would also block dozens of mach services
(deny file-write*)           ; …then carve out
(allow file-write* (subpath "<workdir>") (subpath "/dev"))
(deny file-read* … ~/.ssh ~/.aws ~/.gnupg ~/Library/Keychains ~/.senclaw …)

;; only at strict/allowlist — jail the read direction too
(deny file-read*)
(allow file-read-metadata)                        ; stat still has to work
(allow file-read* (subpath "/usr") (subpath "/System") …)   ; system libraries
(allow file-read* (subpath "<mount source>") …)             ; mounted directories

(allow file-read* (subpath "<workdir>"))   ; the workdir is under ~/.senclaw, so re-open it

;; the network part
(deny network*)                                        ; when the sandbox has no network
(allow network-outbound (remote ip "*:443"))           ; each connect port
(allow network-bind     (local ip "*:8000"))           ; each listen port
(allow network-inbound  (local ip "*:8000"))           ; …binding alone lets nobody in
(allow network-outbound (literal "/private/var/run/mDNSResponder"))  ; DNS — see below
(deny  network-outbound (remote ip "localhost:*"))     ; ← LAST, even with the network on
(allow network-outbound (remote ip "localhost:8899"))  ; …then give back each loopback port

Linux

bwrap --die-with-parent --new-session --unshare-pid --unshare-ipc --unshare-uts
      [--unshare-net] --ro-bind / / --dev /dev --proc /proc --tmpfs /tmp
      --tmpfs $HOME              # whiting out home hides every secret dotfile
      --bind <workdir> <workdir> # …then bind the workdir back, AFTER the tmpfs
      --chdir <workdir> -- /bin/sh -s

Docker

docker run -d a sleep infinity container; each run is a docker exec, so a pip install in one call is still there in the next. The sandbox directory on the host is bind-mounted at /work, which lets the file browser read straight from the host rather than through Docker.

Flags worth noting: --network none by default, --memory-swap equal to --memory (without it the container swaps past the cap and the limit means almost nothing), --cap-drop ALL, --security-opt no-new-privileges, and --entrypoint sh because the python and node images' own entrypoints would swallow the sleep.

Three core decisions

1. Scripts arrive on stdin, never interpolated into a command line. Every backend runs sh -s and writes the program to stdin. There is no sh -c "…" anywhere. A single quote inside someone's Python is a syntax error at best and a different command at worst. Likewise, code is written to a file and the interpreter pointed at the file rather than python -c, so tracebacks carry real filenames and line numbers. There is a dedicated test: a snippet containing ', ", $(whoami), backticks and ;|& must come back out unchanged.

2. The environment is built, not inherited. The daemon process holds a full environment — SenClaw variables, API keys, tokens. Its children get exactly one explicitly constructed set and nothing else. HOME points at the sandbox directory itself, so pip's cache and npm's config write to the one place they are allowed to write.

3. Machine capability is measured, not guessed. The machine this was built on had the Docker CLI on PATH, a happy docker --version, and a dead daemon ("Docker Desktop is unable to start"). A probe that stops at "does the binary exist" reports Docker as usable and then every sandbox dies at run time with a confusing error. So the probe asks the daemon, and wraps every child process in a hard 4-second timeout with a kill — docker info against a broken Docker Desktop hangs for minutes, and a hanging capability probe hangs the UI with it. Results are cached for 20 seconds with a "re-check" button, and the direct and docker caches are separate (see the pitfalls table).

One runner, two front doors

REST (/api/sandbox/*, 28 routes nested in the daemon's UI server) and the MCP sbx_* tools go through the same runner.rs. If a limit were enforced in the HTTP handler but not the MCP tool it would not be a limit at all, because agents come in through MCP. When the engine is broken the whole branch returns 503 rather than dropping the routes — a 404 would be misread as "an older version".

ExecPolicy — forcing existing paths through the sandbox

This is what the built-in engine has that the standalone Space App copy does not: it does not merely offer a sandbox, it makes execution paths that already existed go through one. Stored as a single database row, read and written at GET|PUT /api/sandbox/exec-policy, toggled under Plugins → Sandbox.

JSON keyDefaultMeaning
execShellfalseThe agent's Bash tool runs in the OS sandbox, write-jailed to the chat's working directory
execNetworktrueNetwork for the enforced shell
execFsMode**open**Read mode for the enforced shell
execLoopback[]Local ports the shell may call. Empty means none
runPythontrueAllow real Python (REPL, /api/code/run, sandbox)
runNodetrueAllow real Node.js
codeNetworkfalseNetwork for the python/node REPL
schedulerScriptfalsescript and script-agent tasks run in a single-use sandbox
schedulerNetworktrueNetwork for scheduler scripts

"Off" means different things per field, deliberately: execShell and schedulerScript off means run as before, unsandboxed (isolation: "none") — those paths predate the sandbox, so off restores the original behaviour. runPython and runNode off means flat refusal with a pointer ("switched off (Plugins → Sandbox)"), because those runtimes never ran outside a sandbox and there is no original behaviour to fall back to.

If a switch is on and the engine is broken, the command fails — it does not quietly drop back to a raw shell. A security switch that turns itself off under difficulty is not a security switch.

The gate exists in four places, but the one that matters is the deepest: runner::run_code. Every entrance — REST, MCP, REPL, scheduler — passes through it, so there is no way around it by calling a different API.

Port isolation

The network switch is blunt. ports.rs adds the state in between — no general network, but these ports are open — which is exactly what running an app inside a sandbox needs: serve on 8000, be viewable in a browser, reach nothing else.

Loopback is blocked even when the network is on

*:443 includes 127.0.0.1:443, and network: true includes every service on the machine — among them SenClaw's own REST API, which has no authentication because its trust boundary is loopback. That combination removes the entire sandbox: code that cannot read a credential file at the filesystem layer can instead have the daemon act on its behalf, up to and including provisioning itself a second sandbox with no restrictions at all. Both consequences were demonstrated against a live daemon before the deny rule existed. So the profile always emits (deny network-outbound (remote ip "localhost:*")) last — last match wins — and then gives back exactly the ports named in loopback. The test guarding this runs a real listener on the machine and checks the sandbox cannot reach it, network on and all.

"Only one website" cannot be an OS rule

Seatbelt cannot filter by host; the parser rejects it outright: sandbox-exec: host must be * or localhost in network address. So connect: [443] means every site on 443, not one site.

What does work (proven end-to-end, 4 of 4 measurements): an allowlist proxy running outside the sandbox, then connect: [] plus loopback: [<the proxy port>] plus an HTTPS_PROXY environment variable. Anything ignoring the proxy hits the wall — it fails closed — and with no connect there is no resolver either, which closes the DNS-tunnel route.

Tracing at the language layer, not the syscall layer

The obvious approach is a syscall tracer. It is not available:

  • macOSdtrace, dtruss and ktrace all refuse to run while SIP is enabled, and SIP enabled is the default state. The Endpoint Security framework needs an Apple-granted entitlement plus root. There is no path for a userspace feature, and telling users to disable SIP to see a file list is far too high a price.
  • Linuxstrace works and is genuinely better, but it is Linux-only and not installed everywhere.

So the mechanism is in-process hooks, injected before the workload runs:

RuntimeHow
Pythonsys.addaudithook (PEP 578) via a sitecustomize.py on PYTHONPATH
Nodea --require preload patching fs, child_process, net, dns
anything elsecomparing the sandbox directory before and after (writes only)

Both are inherited by child processes, so a script calling another script stays traced.

Details that matter: the shim logs with os.write on a file descriptor opened once, because open() itself emits an audit event and logging with open() would make the hook trigger itself forever (there is a test). The log is append-only for the whole sandbox, and each run records the offset before it starts, otherwise every run re-reports the previous runs' events. Noise filtering drops reads of system libraries and the app's own bookkeeping files — but never drops a write, because writing into a system directory is precisely what someone turns tracing on to catch.

Resource monitoring and killing

The docker backend asks docker top. The direct backend has no container to ask, so the engine remembers: every spawn registers its process group in a registry and every exit unregisters it, through an RAII guard — exec has five exit paths and a hand-written version would forget one. Groups rather than pids, because a run is sh plus everything it spawns, and setsid collects them into one group.

Sampling runs ps -axo pid=,ppid=,pgid=,pcpu=,pmem=,rss=,etime=,comm= and filters by pgid in Rustps's own group-selection flags differ between macOS and Linux, so one full listing plus local filtering behaves identically on both.

**sbx_kill can only stop processes in a group the engine itself started for that sandbox.** Getting this wrong turns the endpoint into "kill any process on the machine", pid 1 included. Verified: pid: 1 is refused.

Mounts

A mount is a source (a real path) appearing at a target (a relative path inside the sandbox). Both backends put it in the same place — the sandbox root plus the target — so code written against one backend runs on the other.

BackendHow
dockera real bind mount: -v source:/work/target[:ro]
bubblewrapa real bind mount: --bind / --ro-bind
SeatbeltmacOS cannot remap paths for a process, so: a symlink at the target plus a rule granting the source in the profile

The forbidden-roots list blocks /, /etc, /usr, /System, /var and the home directory itself, and blocks descendants for ~/.ssh, ~/.aws, ~/.gnupg, gcloud, kube, docker, Keychains — and the engine's own data directory, because mounting that would let one sandbox edit another's files, including the Seatbelt profile the next run is about to use. Sources are canonicalized before comparison, so ~/./ does not slip past.

The file browser has to know about mounts too: on macOS a mount is a symlink pointing outside the sandbox — exactly the shape the escape check exists to block — so its scope carries the list of permitted sources. Walking into a mount is allowed; one step beyond it is not (there is a test).

Pitfalls, and what was done about them

PitfallResolution
Loopback disabled the whole sandbox. network: true includes 127.0.0.1, where the daemon's unauthenticated REST API lives — sandboxed code could have the daemon read forbidden files for it, and create a second sandbox with the whole disk mounted. Measured against a live daemonEmit (deny network-outbound (remote ip "localhost:*")) last, even with the network on; give back only the ports named in ports.loopback. Guarded by a test that stands up a real listener
DNS on macOS does not use port 53connect: [53, 443] still could not resolve, because getaddrinfo asks mDNSResponder over a Unix socketGrant that socket, and only to a sandbox that already has outbound permission
**A background server started with cmd & gets killed.** exec waits out the deadline and then kills the whole process groupStart it as ( cmd < /dev/null > log 2>&1 & ); exec returns immediately and the server survives. Written into the skill
Mounting an app's directory read-only and expecting it to run. Anything writing next to its own code (SQLite, locks, caches) diesCopy the app into the writable workspace and mount only data read-only
**/private/var/folders used to be on the write allow-list.** That is macOS's per-user temp and cache area, holding other applications' containers and saved state — writing there is a real escape. An end-to-end test caught itRemoved; TMPDIR points inside the sandbox so it is not needed
Seatbelt matches on resolved paths, and /tmp and home are symlinks on macOSCanonicalize the workdir before putting it in the profile
The workdir lives under ~/.senclaw, which is denied for readingRe-open the workdir after the deny rule — last match wins
A timeout killed sh but grandchildren kept runningsetsid() in pre_exec, then kill the whole process group
Timeout under docker: killing the docker exec client does not kill the process inside the containerdocker restart — the workdir is a bind mount and installed packages are in the writable layer, so nothing is lost
Slicing a string by byte index panicked mid-character; Vietnamese output is where it showed upClamp back to a character boundary
bwrap applies mounts in order: binding the workdir before the home tmpfs hides the workdirtmpfs first, bind second
A database row saying "running" while the container was stopped from outsideRe-check in the single-sandbox GET, not in the list — that would be one docker command per row
A merged capability probe made every run wait for Docker. On a machine with broken Docker, a 38 ms Python snippet took 4.06 seconds. Only measurement revealed itSplit the direct cache (which spawns nothing) from the docker cache. Back to 0.03 s, with a test guarding the 3-second mark
A target like /data was silently trimmed to data, so users thought they had mounted at /data when it was at /work/dataReject absolute targets outright, with a suggested rewrite
The privileged-port rule was applied to the outbound direction too. Refusing to bind below 1024 is correct; applying it to connect rejected connect: [443] — the single most useful rule there is. Five tests went red for this one bugApply it to listen only
**PUT /exec-policy resets any field the body omits**, because serde(default) sits at the struct levelThe UI always sends the whole object; scripted callers must GET and merge. (PUT /settings next door has no such default and rejects a partial body with 422 — two neighbouring endpoints, two behaviours)
New columns never reached existing databases. The schema runs with IF NOT EXISTS, so a column added to CREATE TABLE only appears in fresh databases while old ones keep the old shape and fail at run timeA migration reads PRAGMA table_info and issues ALTER TABLE ADD COLUMN for what is missing — additive only
A filter that dropped anything not an absolute path (meant to skip open(4) on a descriptor) threw away every relative path — precisely the interesting reads, since sandboxed code opens its own files relatively. An end-to-end test caught itOnly skip targets that are entirely digits

What is verified

158 test functions in the engine (the standalone Space App copy has its own 152). The group that genuinely executes code under Seatbelt or bwrap skips itself, printing a SKIP line, on machines without isolation. Among the things they check:

  • Python runs and returns the right output, and a snippet full of quotes survives intact
  • writing inside the sandbox works; writing outside is blocked
  • an infinite loop is killed on deadline
  • the network is blocked when the sandbox has no network
  • the parent process's ANTHROPIC_API_KEY is not visible from inside
  • a read-write mount reads and writes through to the real file; a read-only mount refuses writes and the real file is unchanged
  • the file browser can walk into a mount but not out of it
  • a direct run does not wait on a Docker probe (asserted under 3 seconds)
  • strict blocks reading files outside the sandbox and Python can still import the standard library; allowlist opens exactly the declared directory and not its neighbour; open can read, or the comparison would be meaningless
  • tracing catches all four kinds from one real run — file write, file read, process spawn with argv, a connection to 1.1.1.1:53, a name lookup — and a second run does not replay the first run's events
  • with tracing off, no trace directory is left behind
  • a real HTTP server runs inside the sandbox on port 18771 and answers from the host, while an undeclared port cannot be bound
  • connect opens only the declared remote port
  • services on this machine are unreachable even with the network on, and naming one in loopback gives back exactly that one service
  • the resolver is granted only to sandboxes with outbound permission
  • toggling the network on an existing sandbox takes effect on the next run — the fence is rebuilt per run, not built once and cached
  • the agent's shell reaches no local service until one is named

Run by hand on macOS (2026-08-01, with Docker Desktop broken), through REST, through the MCP sbx_run tool, and through the WebSocket terminal:

$ echo pwned > /tmp/x            → Operation not permitted
$ ls ~/.ssh                      → Operation not permitted
$ ls ~/.senclaw                  → Operation not permitted
$ echo pwned > ~/sbx_escape.txt  → Operation not permitted

The measurement round that found the holes

**2026-08-06, macOS 25.5 (arm64), direct/Seatbelt backend**, against a daemon built in a temporary HOME on port 18990 so the machine's real daemon was never touched. The question: put real apps in a sandbox and verify three constraints — only these directories, only local access, only one specific website — while the app still works.

#ConclusionStatus
1Directory and port limits hold with real apps (Python http.server; Express 5 with the native sqlite3 module)19 of 19 measurements as expected
2network: true left the daemon's own unauthenticated loopback API in reach of sandboxed code, which is enough to escalate straight out of the sandboxa real escape — fixed
3connect: [443] alone cannot resolve names on macOS, so apps using hostnames failusability bug — fixed
4Seatbelt cannot filter by host, so "only one site" cannot be an OS rulean OS limitation — the proxy workaround was proven
5A background server started with cmd & is killed when exec expiresusage — written into the skill

On finding 2: blocking reads of a credential file at the filesystem layer is worth nothing while the daemon will act on that file's behalf over HTTP on request — that API has no authentication because its trust boundary is the loopback interface itself. Two escalation paths were confirmed against a running daemon, one reading state that the sandbox was denied and one provisioning a fresh sandbox without restrictions. The exact requests are deliberately not reproduced here; they are in the repo's own report. After the fix, all four escalation attempts return Operation not permitted, by numeric IP and by the name localhost alike.

Findings 2 and 3 are fixed in the shipping engine, and everything this page describes is what a current daemon does. On an older build the port rules do not give you what they appear to — update before relying on them.

The reusable recipe that came out of it:

{
  "fsMode": "strict",
  "network": false,
  "ports": {
    "listen":   [8080],
    "connect":  [],
    "loopback": [8899]
  }
}

Mount only the data directory, read-only; add HTTPS_PROXY=http://127.0.0.1:8899 to the sandbox environment; run the allowlist proxy outside.

What is still open

  • Docker and bubblewrap: opening a port loses network isolation, and loopback cannot be enforced there at all (Docker also has host.docker.internal). The note field in the response says so — do not promise users more than that.
  • The daemon's local API is unauthenticated by design, because it binds loopback only and treats that interface as its trust boundary. Sandboxes can no longer reach it, but any other process running as you on the same machine can. That is a separate problem with its own page: Remote access and API tokens covers what changes the moment you move that boundary off loopback.
  • Windows is implemented but unverified on real hardware.

The second layer: Space Apps

The engine above serves code an agent runs — single-use sessions, a private workspace, remapped paths. A Space App is a different animal: a long-lived process started by the daemon, serving a port, computing its own data directory from $HOME at startup. So it has its own layer, sharing the profile builder but differing in three fundamentals:

The engine (sbx_*)A Space App
Lifetimeone runa long-lived, supervised process
Pathsremapped into a workspace (/work/…)kept as the real paths
Configurationper-run parametersstored per app, fixed at launch
"Only a few sites"a recipe you builda bundled allowlist proxy

Paths are not remapped because the app already computed its data directory from $HOME; move it to /work/data and the app finds nothing. See Sandboxing a Space App for the user-facing side and Monitoring a Space App for checking what a running process actually got.

Process lifecycle rules

Three rules, each the answer to a state measured on a real machine — 47 orphaned apps, one process 299 hours old, and not one app actually confined despite its configuration saying so:

  1. The daemon must catch SIGTERM. It used to await ctrl_c() only, while the desktop app stops it with kill -TERM and then SIGKILL 800 ms later — so the shutdown block had never run the way people actually quit.
  2. Shutdown must be parallel. Killing serially with a 2-second grace each needs a minute for a few dozen apps, which does not fit in 800 ms. Now: SIGTERM to all, wait once for 300 ms, SIGKILL the rest (measured at about 300 ms total).
  3. An app's port must be reclaimed, not adopted. Seeing a fixed port still answering and using it means the app is running old code from an old directory, outside every sandbox, indefinitely. The daemon now kills that process and relaunches — only when its working directory (lsof -d cwd) is inside the app's installation directory. If that cannot be verified it is left alone and logged: a user's own dev server on a colliding port must not become something killed at every startup.

The supervisor learned the same lesson: "the port answers" does not mean healthy. A port answering with no child record is a stranger, and gets one reclaim attempt per daemon run.

In the SenClaw repo

The engine is src/sandbox/: backend/ (direct.rs for Seatbelt, the bwrap path, direct_windows.rs for AppContainer plus Job Object, and the Docker backend), fsmode.rs, ports.rs, policy.rs, runner.rs, trace.rs, monitor.rs, mounts.rs, caps.rs, settings.rs and schema.sql. The per-app layer is app_policy.rs, app_launch.rs and proxy.rs. Tests run with cargo test -- sandbox; the experiment scripts are under scripts/sandbox-experiment/ and can be replayed.