Skip to content

Commit 7bbb63b

Browse files
authored
Merge branch 'main' into add-frontend-ci
2 parents 207a2f2 + ba4d2a4 commit 7bbb63b

File tree

29 files changed

+1353
-205
lines changed

29 files changed

+1353
-205
lines changed

docs/content/docs/(configuration)/config.mdx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -536,12 +536,15 @@ Agent-specific routing is set via `[agents.routing]` with the same keys as `[def
536536

537537
### `[agents.sandbox]`
538538

539-
OS-level filesystem containment for shell and exec tool subprocesses. Uses bubblewrap (Linux) or sandbox-exec (macOS) to enforce read-only access to everything outside the workspace.
539+
OS-level filesystem containment and environment sanitization for shell and exec tool subprocesses. Uses bubblewrap (Linux) or sandbox-exec (macOS) to enforce read-only access to everything outside the workspace. Environment sanitization runs in all modes -- workers never inherit the parent's environment variables.
540+
541+
See [Sandbox](/docs/sandbox) for a full explanation of how containment, environment sanitization, leak detection, and durable binaries work.
540542

541543
| Key | Type | Default | Description |
542544
|-----|------|---------|-------------|
543-
| `mode` | string | `"enabled"` | `"enabled"` for kernel-enforced containment, `"disabled"` for full host access |
545+
| `mode` | string | `"enabled"` | `"enabled"` for kernel-enforced containment, `"disabled"` for passthrough (env sanitization still applies) |
544546
| `writable_paths` | string[] | `[]` | Additional directories the agent can write to beyond its workspace |
547+
| `passthrough_env` | string[] | `[]` | Environment variable names to forward from the parent process to worker subprocesses |
545548

546549
When `mode = "enabled"`, shell and exec commands run inside a mount namespace where the entire filesystem is read-only except:
547550

@@ -552,12 +555,15 @@ When `mode = "enabled"`, shell and exec commands run inside a mount namespace wh
552555

553556
The agent's data directory (databases, config) is explicitly re-mounted read-only even if it would otherwise be writable due to path overlap.
554557

558+
Regardless of mode, all worker subprocesses start with a clean environment. Only `PATH` (with `tools/bin` prepended), safe variables (`HOME`, `USER`, `LANG`, `TERM`, `TMPDIR`), and any `passthrough_env` entries are injected. Use `passthrough_env` with least privilege: only forward variables required by worker tools (for example specific credentials set in Docker Compose or systemd), and avoid forwarding broad or highly sensitive credentials.
559+
555560
If the sandbox backend isn't available (e.g. bubblewrap not installed), processes run unsandboxed with a warning at startup.
556561

557562
```toml
558563
[agents.sandbox]
559564
mode = "enabled"
560565
writable_paths = ["/home/user/projects/myapp", "/var/data/shared"]
566+
passthrough_env = ["GH_TOKEN", "GITHUB_TOKEN"]
561567
```
562568

563569
### `[[agents.cron]]`
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"title": "Configuration",
3-
"pages": ["config", "permissions"]
3+
"pages": ["config", "sandbox", "permissions"]
44
}

docs/content/docs/(configuration)/permissions.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Per-agent permission system that controls what tools can do, enforces inter-agen
99

1010
## Design Principles
1111

12-
**The container is the OS-level sandbox.** On spacebot.sh, each user runs in an isolated container. Self-hosters who need OS-level isolation can run Spacebot in Docker themselves. The permissions system does not attempt to replicate container-level security — it handles inter-agent boundaries and tool-level restrictions within a single Spacebot process.
12+
**OS-level containment is handled by the [Sandbox](/docs/sandbox) when sandboxing is enabled.** The sandbox enforces filesystem boundaries and environment sanitization at the kernel level, with exact guarantees depending on mode and backend/platform. On spacebot.sh, each user also runs in an isolated container. The permissions system handles a different layer -- inter-agent boundaries and tool-level restrictions within a single Spacebot process.
1313

1414
**Deny is an error, not invisible.** When a tool call is denied, the tool still appears in the LLM's tool list, but returns a structured error explaining the restriction. The LLM can reason about the denial and adapt. This is better than hiding tools (which causes the LLM to attempt workarounds) and better than silent failures (which cause confusion).
1515

@@ -285,7 +285,7 @@ Default when no `[permissions]` block exists: all tools return permission denied
285285

286286
## What This Does NOT Do
287287

288-
**OS-level sandboxing.** No Docker containers, no seccomp profiles, no capability dropping. Provisioned instances (spacebot.sh) or the user's own Docker setup handles this. Spacebot's permissions system is application-level.
288+
**OS-level sandboxing.** The permissions system is application-level -- it controls which tools the LLM can use and what paths it's allowed to access. OS-level containment is handled by the [Sandbox](/docs/sandbox), which operates independently when sandboxing is enabled; exact primitives vary by mode and backend/platform. Provisioned instances (spacebot.sh) also run in isolated containers. The permissions system and sandbox are complementary layers.
289289

290290
**Shell command parsing.** We don't parse shell pipelines or analyze command strings for dangerous patterns. For `shell = "workspace"`, the `working_dir` is confined but the command itself runs unrestricted within that directory. Full command analysis is fragile and has diminishing returns — if you need that level of restriction, use `shell = "deny"`.
291291

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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.

docs/content/docs/(features)/tools.mdx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,19 +175,17 @@ async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
175175

176176
### Sandbox containment
177177

178-
Shell and exec commands run inside an OS-level sandbox (bubblewrap on Linux, sandbox-exec on macOS). The entire filesystem is mounted read-only except the workspace, `/tmp`, and any configured `writable_paths`. The agent's data directory (databases, config files) is explicitly protected. See [Configuration](/docs/config#agentssandbox) for sandbox config options.
178+
Shell and exec commands run inside an OS-level sandbox (bubblewrap on Linux, sandbox-exec on macOS). The entire filesystem is mounted read-only except the workspace, `/tmp`, and any configured `writable_paths`. The agent's data directory (databases, config files) is explicitly protected.
179+
180+
Worker subprocesses also start with a clean environment -- they never inherit the parent's environment variables. System secrets (LLM API keys, messaging tokens) are never visible to workers regardless of sandbox mode. See [Sandbox](/docs/sandbox) for full details.
179181

180182
The `file` tool independently validates paths against the workspace boundary and rejects writes to identity files (`SOUL.md`, `IDENTITY.md`, `USER.md`). The `exec` tool blocks dangerous environment variables (`LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`, etc.) that enable library injection regardless of sandbox state.
181183

182-
Leak detection (via `SpacebotHook`) scans all tool output for secret patterns (API keys, tokens, PEM keys) and terminates the process if a leak is found.
184+
Leak detection (via `SpacebotHook`) scans all tool output for secret patterns (API keys, tokens, PEM keys) and terminates the process if a leak is found. This includes base64-encoded, URL-encoded, and hex-encoded variants.
183185

184186
### Status reporting
185187

186-
Workers report progress via `set_status`. The channel sees these in its status block. Status updates use `try_send` (non-blocking) so a slow event bus never blocks tool execution.
187-
188-
### Fire-and-forget sends
189-
190-
`set_status` uses `try_send` instead of `.await` on the event channel. If the channel is full, the update is dropped rather than blocking the worker.
188+
Workers report progress via `set_status`, and the channel sees those updates in its status block. `set_status` uses `try_send` (non-blocking), so if the event channel is full the update is dropped instead of blocking the worker.
191189

192190
## What Each Tool Does
193191

0 commit comments

Comments
 (0)