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):

MeasurementApp without a sandbox
Read ~/.ssh/id_ed25519yes
Read ~/Documents, ~/Projectsyes
Read ~/.senclaw (daemon DB, tokens, other apps' data)yes
Write into $HOMEyes
Call any loopback port (daemon API, the WS port, every other app)yes
Reach the internetunrestricted

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>, with TMPDIR pointed at it — macOS's default TMPDIR lives 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 $HOME disappears 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)

ModeMeaning
all (default)Free internet access, like an unsandboxed app. Loopback is still closed apart from the ports you name
hostsOnly the domains on the list. *.example.com covers both subdomains and the apex
offNo 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:

  1. The sandbox gets no direct egress at all — no connect port, not even a DNS resolver.
  2. It gets exactly one loopback port: SenClaw's allowlist proxy.
  3. HTTP_PROXY / HTTPS_PROXY / NODE_USE_ENV_PROXY point 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 refuses localhost, 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 Host header. 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

DirectoriesNetwork (off / only these sites)
macOS (Seatbelt)yesyes
Linux (bubblewrap)yesno
Windowsnono

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) granted

What was measured (a real app, test-manager)

MeasurementOffOn, open + 1 siteOn, strict + 1 siteOn, network off
Read ~/.ssh/id_ed25519yesnono
Read ~/Documentsyesyesno
Read ~/.senclaw (daemon DB)yesnono
Read/write its own data directoryyesyesyes
Write $HOMEyesnono
Write the real home directoryyesnono
example.com (on the list)200200200fails
wikipedia.org (not on the list)301failsfailsfails
The daemon API (AI bridge)200200200fails (when unticked)
The daemon's WS port 18991400failsfailsfails
The app still serves its UI200200200200

Traps that only appear with a real app

  1. **strict plus a runtime installed in $HOME means 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 what strict removes. Fixed: in a jailed read mode, every PATH entry outside the system roots is granted read-only at its installation directory — not just bin, because npm-cli.js sits in ../lib/node_modules. Derived from PATH rather 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 on PATH.
  2. 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 file even though that exact directory had been granted read and write, because ~/.senclaw above it was unreadable and opening a file resolves every path component. The confusing part is that ls and the sqlite3 CLI 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. The strict branch was never affected because it already allows metadata reads globally.
  3. **npm start makes network calls.** Under hosts, the proxy blocks registry.npmjs.org and records it — which is what the dialog shows you. The app still runs; if yours genuinely needs the registry at startup, list it.
  4. **The credential blocklist is computed from the daemon's $HOME.** Running the daemon with a different HOME (testing, nested sandboxes) means the real user's ~/.ssh is not on open mode's blocklist. strict is unaffected.
  5. 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.
  6. Paths are not remapped. The app computes its data directory from $HOME at 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/x grants 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 hosts mode need to list them.
  • If your app needs the AI bridge, say so; it is the daemonApi tick.
  • 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

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.