|
| 1 | +--- |
| 2 | +title: Sandbox |
| 3 | +description: OS-level filesystem containment and environment sanitization for worker subprocesses. |
| 4 | +--- |
| 5 | + |
| 6 | +# Sandbox |
| 7 | + |
| 8 | +OS-level containment for builtin worker subprocesses (`shell` and `exec`). Prevents workers from modifying the host filesystem, reading inherited environment secrets, and accessing the agent's internal data directory. |
| 9 | + |
| 10 | +## How It Works |
| 11 | + |
| 12 | +When a worker runs a shell or exec command, the sandbox wraps the subprocess in an OS-level containment layer before execution. The worker's command runs normally -- it can only read a minimal runtime allowlist plus workspace paths, and can only write to explicitly allowed paths. |
| 13 | + |
| 14 | +``` |
| 15 | +Worker calls shell("npm test") |
| 16 | + → Sandbox.wrap() builds a contained command |
| 17 | + → Subprocess runs with: |
| 18 | + - Read access to a minimal system allowlist + workspace |
| 19 | + - Writable access only to the workspace + configured writable paths + /tmp |
| 20 | + - Clean environment (no inherited secrets) |
| 21 | + - HOME set to workspace, TMPDIR set to /tmp |
| 22 | + - tools/bin prepended to PATH |
| 23 | + → stdout/stderr captured and returned to worker |
| 24 | +``` |
| 25 | + |
| 26 | +Two things happen regardless of whether the sandbox is enabled or disabled: |
| 27 | + |
| 28 | +1. **Environment sanitization** -- worker subprocesses never inherit the parent's environment variables. Secrets like `ANTHROPIC_API_KEY` are never visible to workers. |
| 29 | +2. **PATH injection** -- the persistent `tools/bin` directory is prepended to PATH so durably installed binaries are always available. |
| 30 | + |
| 31 | +## Backends |
| 32 | + |
| 33 | +The sandbox auto-detects the best available backend at startup: |
| 34 | + |
| 35 | +| Platform | Backend | Mechanism | |
| 36 | +|----------|---------|-----------| |
| 37 | +| Linux | [bubblewrap](https://github.com/containers/bubblewrap) | Mount namespaces, PID namespaces, environment isolation | |
| 38 | +| macOS | sandbox-exec | SBPL profile with deny-default policy | |
| 39 | +| Other / not available | Passthrough | No filesystem containment (env sanitization still applies) | |
| 40 | + |
| 41 | +If the sandbox is enabled but no backend is available, processes run unsandboxed with a warning at startup. Environment sanitization still applies in all cases. |
| 42 | + |
| 43 | +### Linux (bubblewrap) |
| 44 | + |
| 45 | +The default on all hosted instances and most self-hosted Linux deployments. Bubblewrap creates a mount namespace where: |
| 46 | + |
| 47 | +- A minimal host runtime allowlist is mounted **read-only** (`/bin`, `/sbin`, `/usr`, `/lib`, `/lib64`, `/etc`, `/opt`, `/run`, `/nix` when present) |
| 48 | +- The persistent tools directory is mounted **read-only** (if present) |
| 49 | +- The workspace directory is mounted **read-write** |
| 50 | +- `writable_paths` entries are mounted **read-write** |
| 51 | +- `/tmp` is a private tmpfs per invocation |
| 52 | +- `/dev` has standard device nodes |
| 53 | +- `/proc` is a fresh procfs (when supported by the environment) |
| 54 | +- The agent's data directory is masked with an empty tmpfs (no reads/writes) |
| 55 | +- PID namespace isolation prevents the subprocess from seeing other processes |
| 56 | +- `--die-with-parent` ensures the subprocess is killed if the parent exits |
| 57 | + |
| 58 | +Nested containers (Docker-in-Docker, Fly Machines) may not support `--proc /proc`. The sandbox probes for this at startup and falls back gracefully -- `proc_supported: false` in the startup log means `/proc` inside the sandbox shows the host's process list rather than an isolated view. |
| 59 | + |
| 60 | +### macOS (sandbox-exec) |
| 61 | + |
| 62 | +Uses Apple's sandbox-exec with a generated SBPL (Sandbox Profile Language) profile. The profile starts with `(deny default)` and explicitly allows: |
| 63 | + |
| 64 | +- Process execution and forking |
| 65 | +- Reading only a backend allowlist (system runtime roots + workspace + configured writable paths + tools/bin) |
| 66 | +- Writing only to the workspace, configured writable paths, and `/tmp` |
| 67 | +- Network access (unrestricted) |
| 68 | +- Standard device and IPC operations |
| 69 | + |
| 70 | +The agent's data directory is denied for both reads and writes even if it falls under the workspace subtree. |
| 71 | + |
| 72 | +Note: `sandbox-exec` is deprecated by Apple but remains functional. It's the only user-space sandbox option on macOS without requiring a full VM. |
| 73 | + |
| 74 | +## Filesystem Boundaries |
| 75 | + |
| 76 | +When the sandbox is enabled, the subprocess sees: |
| 77 | + |
| 78 | +| Path | Access | Notes | |
| 79 | +|------|--------|-------| |
| 80 | +| System runtime allowlist | Read-only | Backend-specific system roots required to execute common tools | |
| 81 | +| Agent workspace | Read-write | Where the worker does its job | |
| 82 | +| `writable_paths` entries | Read-write | User-configured additional paths | |
| 83 | +| `{instance_dir}/tools/bin` | Read-only | Persistent binaries on PATH | |
| 84 | +| `/tmp` | Read-write | Private per invocation (bubblewrap) | |
| 85 | +| `/dev` | Read-write | Standard device nodes | |
| 86 | +| Agent data directory | **No access** | Masked/denied to protect databases and config | |
| 87 | + |
| 88 | +The data directory protection is important: even if the data directory overlaps with workspace-related paths, it's explicitly blocked. Workers can't read or modify databases, config files, or identity files at the kernel level. |
| 89 | + |
| 90 | +## Environment Sanitization |
| 91 | + |
| 92 | +Worker subprocesses start with a **clean environment**. The parent process's environment variables are never inherited. This applies in all sandbox modes -- even when the sandbox is disabled, `env_clear()` strips the environment. |
| 93 | + |
| 94 | +A worker running `printenv` sees only: |
| 95 | + |
| 96 | +| Variable | Source | Value | |
| 97 | +|----------|--------|-------| |
| 98 | +| `PATH` | Always | `{instance_dir}/tools/bin:{system_path}` | |
| 99 | +| `HOME` | Always | Worker workspace path | |
| 100 | +| `TMPDIR` | Always | `/tmp` | |
| 101 | +| `USER` | Always | From parent (if set) | |
| 102 | +| `LANG` | Always | From parent (if set) | |
| 103 | +| `TERM` | Always | From parent (if set) | |
| 104 | +| `passthrough_env` entries | Config | User-configured forwarding | |
| 105 | + |
| 106 | +Workers never see `ANTHROPIC_API_KEY`, `DISCORD_BOT_TOKEN`, `SPACEBOT_*` internal vars, or any other environment variables from the parent process. |
| 107 | + |
| 108 | +### passthrough_env |
| 109 | + |
| 110 | +Self-hosted users who set credentials as environment variables in Docker Compose or systemd can forward specific variables to worker subprocesses: |
| 111 | + |
| 112 | +```toml |
| 113 | +[agents.sandbox] |
| 114 | +passthrough_env = ["GH_TOKEN", "GITHUB_TOKEN", "NPM_TOKEN"] |
| 115 | +``` |
| 116 | + |
| 117 | +Each listed variable is read from the parent process environment at subprocess spawn time and injected into the worker's environment. Variables not in the list are stripped. |
| 118 | + |
| 119 | +When the secret store is available, `passthrough_env` is redundant -- credentials should be stored in the secret store, which injects tool secrets automatically. The field is additive and continues to work alongside the store. |
| 120 | + |
| 121 | +## Durable Binaries |
| 122 | + |
| 123 | +On hosted instances, the root filesystem is ephemeral -- machine image rollouts replace it. Binaries installed via `apt-get install` or similar disappear on the next deploy. |
| 124 | + |
| 125 | +The `{instance_dir}/tools/bin` directory is on the persistent volume and is prepended to `PATH` for all worker subprocesses. Binaries placed here survive restarts and rollouts. |
| 126 | + |
| 127 | +Workers are instructed about this in their system prompt: |
| 128 | + |
| 129 | +``` |
| 130 | +Persistent binary directory: /data/tools/bin (on PATH, survives restarts and rollouts) |
| 131 | +Binaries installed via package managers (apt, brew, etc.) land on the root filesystem |
| 132 | +which is ephemeral on hosted instances -- they disappear on rollouts. To install a tool |
| 133 | +durably, download or copy the binary into /data/tools/bin. |
| 134 | +``` |
| 135 | + |
| 136 | +The `GET /agents/tools` API endpoint lists installed binaries for dashboard observability: |
| 137 | + |
| 138 | +```json |
| 139 | +{ |
| 140 | + "tools_bin": "/data/tools/bin", |
| 141 | + "binaries": [ |
| 142 | + { "name": "gh", "size": 1234567, "modified": "2026-02-20T14:15:00Z" }, |
| 143 | + { "name": "ripgrep", "size": 3456789, "modified": "2026-02-15T10:30:00Z" } |
| 144 | + ] |
| 145 | +} |
| 146 | +``` |
| 147 | + |
| 148 | +## Leak Detection |
| 149 | + |
| 150 | +All tool output (shell, exec, file, browser) is scanned for known secret patterns before being returned to the LLM. This runs in the `SpacebotHook` after every tool execution. |
| 151 | + |
| 152 | +Detected patterns include: |
| 153 | + |
| 154 | +- OpenAI keys (`sk-...`) |
| 155 | +- Anthropic keys (`sk-ant-...`) |
| 156 | +- GitHub tokens (`ghp_...`) |
| 157 | +- Google API keys (`AIza...`) |
| 158 | +- Discord bot tokens |
| 159 | +- Slack tokens (`xoxb-...`, `xapp-...`) |
| 160 | +- Telegram bot tokens |
| 161 | +- PEM private keys |
| 162 | +- Base64-encoded, URL-encoded, and hex-encoded variants of the above |
| 163 | + |
| 164 | +Detection also covers encoded forms -- secrets wrapped in base64, URL encoding, or hex are decoded and checked against the same patterns. |
| 165 | + |
| 166 | +If a leak is detected, the process is terminated immediately with an error. The raw leaked value is never logged or returned to the LLM -- only the detection event, encoding type, and a truncated non-reversible fingerprint are recorded for debugging. |
| 167 | + |
| 168 | +### OpenCode Workers |
| 169 | + |
| 170 | +OpenCode workers (external coding agent processes) are covered by the same protection. SSE output events are scanned through both: |
| 171 | + |
| 172 | +1. **Output scrubbing** (exact-match redaction of known secret values) -- runs first |
| 173 | +2. **Leak detection** (regex pattern matching for unknown secrets) -- runs second |
| 174 | + |
| 175 | +The ordering ensures that stored tool secrets are redacted before leak detection runs, so expected secret values in worker output don't trigger false-positive kills. |
| 176 | + |
| 177 | +## Dynamic Mode Switching |
| 178 | + |
| 179 | +Sandbox mode can be changed at runtime via the API or dashboard without restarting the agent. The `Sandbox` struct reads the current mode from a shared `ArcSwap<SandboxConfig>` on every `wrap()` call. |
| 180 | + |
| 181 | +``` |
| 182 | +PUT /agents/config |
| 183 | +{ |
| 184 | + "sandbox": { "mode": "disabled" } |
| 185 | +} |
| 186 | +``` |
| 187 | + |
| 188 | +Backend detection runs at startup regardless of the initial mode. If the sandbox starts disabled and is later enabled via the API, bubblewrap/sandbox-exec is already detected and ready to use. |
| 189 | + |
| 190 | +## Configuration |
| 191 | + |
| 192 | +```toml |
| 193 | +[agents.sandbox] |
| 194 | +mode = "enabled" # "enabled" | "disabled" |
| 195 | +writable_paths = ["/home/user/shared-data"] # additional writable directories |
| 196 | +passthrough_env = ["GH_TOKEN"] # env vars to forward to workers |
| 197 | +``` |
| 198 | + |
| 199 | +| Key | Type | Default | Description | |
| 200 | +|-----|------|---------|-------------| |
| 201 | +| `mode` | string | `"enabled"` | `"enabled"` for OS-level containment, `"disabled"` for passthrough | |
| 202 | +| `writable_paths` | string[] | `[]` | Additional directories workers can write to beyond the workspace | |
| 203 | +| `passthrough_env` | string[] | `[]` | Environment variable names to forward from the parent process | |
| 204 | + |
| 205 | +See [Configuration](/docs/config#agentssandbox) for the full config reference. |
| 206 | + |
| 207 | +## Protection Layers |
| 208 | + |
| 209 | +The sandbox is one layer in a defense-in-depth model: |
| 210 | + |
| 211 | +| Layer | What It Does | Scope | |
| 212 | +|-------|-------------|-------| |
| 213 | +| **Sandbox (filesystem)** | Read allowlist + writable workspace/writable_paths/tmp; blocks agent data dir | Shell, exec subprocesses | |
| 214 | +| **Env sanitization** | Clean environment, no inherited secrets | All subprocesses (including passthrough mode) | |
| 215 | +| **File tool workspace guard** | Path validation against workspace boundary | File tool only (in-process) | |
| 216 | +| **Exec env var blocklist** | Blocks `LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`, etc. | Exec tool | |
| 217 | +| **Leak detection** | Regex scan of all tool output for secret patterns | All tools via SpacebotHook | |
| 218 | +| **Output scrubbing** | Exact-match redaction of known secret values | Worker output, status updates, OpenCode events | |
| 219 | +| **Permissions system** | Application-level tool access control | All tools | |
| 220 | + |
| 221 | +The sandbox and permissions system are complementary. The [permissions system](/docs/permissions) controls which tools an agent can use and what paths the LLM is allowed to access at the application level. The sandbox enforces filesystem boundaries at the kernel level for subprocesses that are allowed to run. |
0 commit comments