Sandboxing a Space App
Confine an installed app to its own directories and a named set of sites: the three switches, how domain allowlisting is enforced by a proxy, what each platform can actually enforce, measured before/after results, and the traps that break real apps.
Plugins → Space Apps → the Sandbox button on an app (in both the Web UI and the desktop app). It answers exactly three questions: does this app run in a sandbox, which directories does it get, and how far onto the network can it reach — everything, nothing, or just a few sites.
Before this existed, a Space App ran as an ordinary process of yours. Measured on a real machine (the repo's own test-manager app, Node + Express):
| Measurement | App without a sandbox |
|---|---|
Read ~/.ssh/id_ed25519 | yes |
Read ~/Documents, ~/Projects | yes |
Read ~/.senclaw (daemon DB, tokens, other apps' data) | yes |
Write into $HOME | yes |
| Call any loopback port (daemon API, the WS port, every other app) | yes |
| Reach the internet | unrestricted |
That is not a bug — a Space App is a process you chose to install. This feature gives you a way to narrow it, per app.
The three switches
1. Sandbox on/off (enabled)
Off by default for every app. Turning it on means the app can only write into its own installation directory and its own data directory (reads and network are untouched) — the cheapest first step, and almost nothing breaks because of it.
Granted automatically, read and write:
- the app's installation directory (
<workspace>/space-apps/<id>) - every spelling of the data directory that apps in the repo have used:
~/.senclaw/apps/<id>,~/.senclaw/space-apps/<id>,~/.senclaw/space-apps-data/<id>,~/.senclaw/space-app-data/<id>,~/.senclaw/<id> - a private temp directory,
~/.senclaw/sandbox/app-tmp/<id>, withTMPDIRpointed at it — macOS's defaultTMPDIRlives under/private/var/folders, which the profile forbids writing because that is where other applications keep their data
2. Directories (readMode + folders)
- **
open(default) — reads everything except** the credential stores (~/.ssh,~/.aws,~/.gnupg, Keychains…) **and except~/.senclaw**, so the app cannot read the daemon's database, its tokens, or another app's data. - **
strict** — only the app's own directories, the folders you grant, and the system roots. The rest of$HOMEdisappears from view.
Add folders with the directory picker (read-write or read-only). The blocklist is the same one the sandbox mounts use: you cannot grant /, $HOME, or a credential store.
3. Network (network)
| Mode | Meaning |
|---|---|
all (default) | Free internet access, like an unsandboxed app. Loopback is still closed apart from the ports you name |
hosts | Only the domains on the list. *.example.com covers both subdomains and the apex |
off | No egress at all. The app still serves its own port normally |
Plus, under "This machine":
- Call the SenClaw API (
daemonApi, on by default) — required for the AI bridge (SENCLAW_BASE_URL/api/space/apps/<id>/bridge), which is how nearly every app gets its intelligence. It is also SenClaw's unauthenticated local API, so switch it off for apps that do not need AI. - Other local ports (
loopback) — a database, another app. Outside this list, loopback is closed completely.
How "only these sites" is actually enforced
No OS sandbox here can filter by domain. Seatbelt accepts only * or localhost as a remote host (measured: anything else is a syntax error), and bubblewrap has no concept of a host. So the mechanism is inverted:
- The sandbox gets no direct egress at all — no
connectport, not even a DNS resolver. - It gets exactly one loopback port: SenClaw's allowlist proxy.
HTTP_PROXY/HTTPS_PROXY/NODE_USE_ENV_PROXYpoint at that proxy.
Clients that honour proxy environment variables (curl, reqwest, axios, undici with NODE_USE_ENV_PROXY) reach the sites you listed. A client that ignores the proxy reaches nothing at all, because its direct connection is blocked by the sandbox itself. It fails closed, not open.
The proxy checks the destination only; it does not open the payload. CONNECT is tunnelled after the domain check, so TLS stays end-to-end and SenClaw never sees the contents.
Three guards worth knowing:
- It is never a bridge back to this machine. After name resolution, every loopback and link-local address — including the cloud metadata endpoint
169.254.169.254— is refused, and the proxy connects to exactly the address it just checked, so DNS rebinding does not get through either. The site list itself refuseslocalhost,127.0.0.1,[::1]and metadata IPs at save time. - Web ports only (80/443/8080/8443): tunnelling to an arbitrary port on an allowed host would carry SSH or a database protocol just as happily.
- Plain HTTP cannot distinguish virtual hosts: two sites on one IP means the client can rewrite the
Hostheader. Use HTTPS (the default) — SNI and the certificate bind the name back.
Blocked domains show up in the dialog itself ("This app wants: + x.com"), one click adds them to the allowlist. Editing the site list takes effect immediately for a running app; every other change needs an app restart, because the profile is fixed at launch.
What each platform can enforce
| Directories | Network (off / only these sites) | |
|---|---|---|
| macOS (Seatbelt) | yes | yes |
| Linux (bubblewrap) | yes | no |
| Windows | no | no |
On Linux, an app that serves a port cannot have its own network namespace: --unshare-net also cuts the daemon's route into the app's port — that is not isolation, that is breakage. So the app shares the machine's namespace and can bypass the proxy. The directory rules are real. Windows drives AppContainer through a pipe, which does not wrap a long-lived server process.
The dialog says this before you switch it on, not after. The app's runtime log also records one line per launch:
sandbox: seatbelt — network via allowlist proxy on 127.0.0.1:59876, 6 folder(s) grantedWhat was measured (a real app, test-manager)
| Measurement | Off | On, open + 1 site | On, strict + 1 site | On, network off |
|---|---|---|---|---|
Read ~/.ssh/id_ed25519 | yes | no | no | — |
Read ~/Documents | yes | yes | no | — |
Read ~/.senclaw (daemon DB) | yes | no | no | — |
| Read/write its own data directory | yes | yes | yes | — |
Write $HOME | yes | no | no | — |
| Write the real home directory | yes | no | no | — |
example.com (on the list) | 200 | 200 | 200 | fails |
wikipedia.org (not on the list) | 301 | fails | fails | fails |
| The daemon API (AI bridge) | 200 | 200 | 200 | fails (when unticked) |
| The daemon's WS port 18991 | 400 | fails | fails | fails |
| The app still serves its UI | 200 | 200 | 200 | 200 |
Traps that only appear with a real app
- **
strictplus a runtime installed in$HOMEmeans the app will not start.** Measured:EPERM … /Users/u/.nvm/versions/node/v24.13.1/lib/node_modules/npm/bin/npm-cli.js. Node installed by nvm lives under$HOME, exactly whatstrictremoves. Fixed: in a jailed read mode, everyPATHentry outside the system roots is granted read-only at its installation directory — not justbin, becausenpm-cli.jssits in../lib/node_modules. Derived fromPATHrather than a hardcoded list of nvm/volta/pyenv names, which would rot. Capped at 16 entries, with a warning past that (a real machine hit 8 because an editor and a model runner came before nvm). Credential stores are excluded even when they are onPATH. - Granting a subdirectory is not enough — the parent must be traversable. Measured on a real app: SQLite died with
SQLITE_CANTOPEN: unable to open database fileeven though that exact directory had been granted read and write, because~/.senclawabove it was unreadable and opening a file resolves every path component. The confusing part is thatlsand thesqlite3CLI work under the same profile, so the app gets blamed. Fixed: any granted path whose ancestors sit inside a forbidden tree gets those ancestors back at metadata permission only — not contents, so the daemon's DB, tokens and other apps' data stay dark. Thestrictbranch was never affected because it already allows metadata reads globally. - **
npm startmakes network calls.** Underhosts, the proxy blocksregistry.npmjs.organd records it — which is what the dialog shows you. The app still runs; if yours genuinely needs the registry at startup, list it. - **The credential blocklist is computed from the daemon's
$HOME.** Running the daemon with a differentHOME(testing, nested sandboxes) means the real user's~/.sshis not onopenmode's blocklist.strictis unaffected. - The app's profile is not inside the area the app can write. Unlike the exec path — where the profile lives in the sandbox and is rewritten before every run — a long-lived app keeps its profile at
~/.senclaw/sandbox/app-profiles/<id>.sb, out of the app's reach. - Paths are not remapped. The app computes its data directory from
$HOMEat startup, so everything granted keeps its real path (macOS: a path-based rule; Linux: a bind with source equal to destination). Granted paths are always canonicalized first — a Seatbelt rule on/var/xgrants nothing at all, because the real path is/private/var/x, and that mistake is silent.
What this means if you publish apps
- Assume a user may switch this on. Write into your own data directory and
TMPDIR, never next to arbitrary files in$HOME. - If your app needs the network at startup, say which domains in your README — users on
hostsmode need to list them. - If your app needs the AI bridge, say so; it is the
daemonApitick. - Long-lived state belongs in the data directory the daemon gives you, not next to the installation, if you want restarts and updates to be uneventful.
Further reading
- Running code in a sandbox — the engine behind this, and the agent-facing tools.
- Monitoring a Space App — checking what the running process actually got.
- Sandbox internals — why the profiles look the way they do.
In the SenClaw repo
Configuration and validation live in src/sandbox/app_policy.rs, the launch command construction in src/sandbox/app_launch.rs, and the allowlist proxy in src/sandbox/proxy.rs. The wrapping happens where apps are spawned, in src/gateway/ui_server/space_mcp.rs, with REST at GET/PUT /api/space/apps/:id/sandbox (src/gateway/ui_server/space.rs). The UIs are web/src/components/settings/SpaceAppSandboxModal.tsx and desktop_app/lib/features/plugins/space_app_sandbox_dialog.dart.