Skip to content

Commit 4fb6558

Browse files
feat(sandbox): mount allowExternalDirectories read-only and ship build-essential
1 parent 452ee7d commit 4fb6558

11 files changed

Lines changed: 180 additions & 22 deletions

File tree

‎container/Dockerfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ FROM docker.io/docker/sandbox-templates:shell-docker
1212
USER root
1313

1414
RUN apt-get update && apt-get install -y --no-install-recommends \
15+
build-essential \
1516
ca-certificates \
1617
curl \
1718
git \

‎docs/api/_media/architecture.md‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,24 @@ Sandbox is optional. When the `sbx` daemon is available and `sandbox.mode = 'sbx
109109

110110
1. When a sandbox loop starts, an `sbx` sandbox is created
111111
2. The worktree directory is mounted at its identical host path inside the sandbox
112-
3. Tool hooks redirect `bash`, `glob`, and `grep` calls into the sandbox via `sbx exec`
112+
3. `bash` runs inside the sandbox; `glob` and `grep` results are produced inside the sandbox
113113
4. File operations (`read`, `write`, `edit`) operate on the host directly
114114
5. On loop completion, the sandbox is stopped and removed
115115

116116
### Tool Redirection
117117

118-
The sandbox uses OpenCode's tool hook system to intercept and redirect tool calls:
119-
- `tool.execute.before` hook prepends commands with `sbx exec`
120-
- `tool.execute.after` hook captures output and returns it to the host
118+
`bash` and the search tools reach the sandbox through two different mechanisms:
119+
120+
- **`bash`** is redirected out of band, not through a tool hook. The `config` hook points
121+
`cfg.shell` at the `forge-shell` shim (`sandbox/shell-shim.ts`) and the `shell.env` hook
122+
injects `FORGE_SANDBOX_CONTAINER`. The shim `exec`s `sbx exec -w "$PWD" <container> bash "$@"`.
123+
Tool arguments are never rewritten.
124+
- **`glob` and `grep`** use output replacement. `tool.execute.before` runs the equivalent
125+
`rg` command inside the container and stores the result by `callID`; `tool.execute.after`
126+
overwrites `output.output` with it. Because `tool.execute.before` cannot cancel a tool call,
127+
the native host search still executes and its result is discarded. The before-hook rejects
128+
absolute paths outside the sandbox mounts, so that host execution stays confined to the
129+
mounted worktree.
121130

122131
## Hook System
123132

‎docs/api/_media/configuration.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ Default log path: `~/.local/share/opencode/forge/logs/forge.log` or `$XDG_DATA_H
5252
| `loop.cleanupWorktree` | `false` | Auto-remove worktree on cancel. |
5353
| `loop.stallTimeoutMs` | `60000` | Stall watchdog timeout in milliseconds. |
5454
| `loop.maxConsecutiveStalls` | `5` | Consecutive stalls before terminating with `stall_timeout`. `0` disables stall termination. |
55-
| `loop.allowExternalDirectories` | unset | Absolute host directories that loop, audit, and post-action sessions may read despite worktree isolation. |
55+
| `loop.busyStallTimeoutMs` | `900000` | How long a session may stay busy with no sign of progress before the watchdog aborts the wedged message and sends a continue prompt. Both tool activity and streamed content (including reasoning/thinking deltas) count as progress, in the loop session or any of its subagent sessions, so a long thinking stretch is not mistaken for a wedged stream. `0` disables nudging. |
56+
| `loop.allowExternalDirectories` | unset | Absolute host directories that loop, audit, and post-action sessions may read despite worktree isolation. In sandboxed loops each entry is also bind-mounted read-only, so in-container `bash`/`glob`/`grep` see the same tree as host `read`. |
5657
| `loop.permissions` | unset | Per-tool `deny` overrides for loop, audit, and post-action sessions. See [Loop Permissions](#loop-permissions). |
5758
| `loop.worktreeOpencodeConfig` | unset | Inline [opencode config](https://opencode.ai/config.json) written as `opencode.jsonc` into each freshly created loop worktree. Enables per-loop customization (MCP servers, model overrides, etc.). Skip-if-exists — never overwrites a committed `opencode.json`/`opencode.jsonc`. The written file is git-excluded to keep it out of loop commits. |
5859

@@ -74,7 +75,7 @@ Configured rules are layered into the ruleset in this order:
7475

7576
1. Blanket allow-all (worktree/audit isolation).
7677
2. Blanket `external_directory` deny.
77-
3. `external_directory` allows (opencode's tool-output directory, then `loop.allowExternalDirectories`).
78+
3. `external_directory` allows (opencode's tool-output and temp directories, then `loop.allowExternalDirectories`).
7879
4. Configured `deny` rules.
7980
5. Forge structural denies.
8081

@@ -246,6 +247,7 @@ See [Sandbox](sandbox.md) for detailed behavior and security notes.
246247
| `sandbox.enabled` | `true` | Enable sandboxed execution when the `sbx` daemon is available. |
247248
| `sandbox.mode` | `"sbx"` | Sandbox mode. `sbx` is currently the only supported mode. |
248249
| `sandbox.image` | `"oc-forge-sandbox:latest"` | sbx template tag used for sandboxed execution. |
250+
| `sandbox.imageFeatures.browserControl` | `false` | Include Chromium, the Browser Control CLI/MCP server, and its extension when building the bundled sandbox image. Rebuild the template after changing it. |
249251
| `sandbox.resources.memory` | `"8g"` | Sandbox memory limit (`sbx create --memory`). |
250252
| `sandbox.resources.cpus` | `"4"` | CPU count (`sbx create --cpus`; integer-only). |
251253
| `sandbox.mountProjectReadonly` | `true` | Mount the source project read-only at its identical host path. |

‎docs/api/_media/sandbox.md‎

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ See also: [Configuration](configuration.md), [Tools](tools.md), [Loop System](lo
1111
- A platform the `sbx` daemon supports: macOS 14+ on Apple silicon, Windows 11 with Hypervisor Platform, or Ubuntu 24.04+ with KVM.
1212
- Docker, used only to build the sandbox template (see below).
1313

14+
The daemon serializes its work behind in-flight sandbox commands, so `sbx daemon status` and `sbx ls` can take seconds while several loops are running. Forge bounds those queries at 30s and treats a query that does not answer as *indeterminate* rather than "daemon down": it logs and continues, letting the actual sandbox operation report the authoritative error. Only a daemon that answers definitively (or a missing CLI) fails a loop launch with remediation advice.
15+
1416
Build and load the bundled template:
1517

1618
```bash
@@ -19,16 +21,59 @@ docker save oc-forge-sandbox:latest -o forge-sandbox.tar
1921
sbx template load forge-sandbox.tar
2022
```
2123

22-
The image includes Node.js 24, pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and a native Docker daemon inside each sandbox.
24+
The default image includes Node.js 24, pnpm, Bun, Python 3 + uv, ripgrep, git, jq, and a native Docker daemon inside each sandbox.
25+
26+
The sandbox image grants the `agent` user passwordless sudo, so loops can install whatever software they need at runtime. `sbx` commands stay unprivileged as `agent` (keeping host-mapped worktree files owned by the host user), so system-wide installs use an explicit `sudo` prefix, for example `sudo apt-get install ruby`.
27+
28+
### Browser Control (opt-in)
29+
30+
Chromium and Browser Control add a substantial browser payload, so they are excluded by default. Enable them for future image builds:
31+
32+
```jsonc
33+
{
34+
"sandbox": {
35+
"imageFeatures": {
36+
"browserControl": true
37+
}
38+
}
39+
}
40+
```
41+
42+
Then run `Build sandbox template` from the command palette. Changing the option does not modify an already-loaded template; rebuild it explicitly. The equivalent manual Docker build is:
43+
44+
```bash
45+
docker build \
46+
--build-arg INSTALL_BROWSER_CONTROL=true \
47+
-t oc-forge-sandbox:latest \
48+
container/
49+
docker save oc-forge-sandbox:latest -o forge-sandbox.tar
50+
sbx template load forge-sandbox.tar
51+
```
52+
53+
The resulting image provides `browser-control`, `browser-control-mcp`, Chromium as `chromium`, and the unpacked extension at `/opt/browser-control-extension`.
54+
55+
Browser Control connects to a browser in the same sandbox. Launch Chromium with the packaged extension path resolved to its installation directory:
56+
57+
```bash
58+
extension="$(readlink -f /opt/browser-control-extension)"
59+
xvfb-run -a chromium \
60+
--no-sandbox \
61+
--disable-dev-shm-usage \
62+
--disable-extensions-except="$extension" \
63+
--load-extension="$extension" \
64+
--user-data-dir=/opt/forge/.browser-control-profile
65+
```
66+
67+
It cannot control a browser running on the host because sandbox networking cannot reach the host loopback interface.
2368

2469
## How It Works
2570

2671
1. A sandbox loop uses its isolated git worktree. A host-session sandbox instead uses the project root selected from the TUI.
2772
2. Forge creates one sandbox per loop, or one project-scoped host-session sandbox shared by plugin instances in the process.
28-
3. The active directory and the read-only source project (when `sandbox.mountProjectReadonly` is enabled) are mounted at their identical host paths, so absolute paths resolve the same on both sides. There is no `/workspace` or `/project` container path.
73+
3. The active directory, the read-only source project (when `sandbox.mountProjectReadonly` is enabled), and the worktree's git metadata directory are mounted at their identical host paths, so absolute paths resolve the same on both sides. There is no `/workspace` or `/project` container path.
2974
4. Shell commands and search tools execute inside the sandbox; file tools stay on the host, so LSP and editor integration continue to work.
3075

31-
The read-only project mount is dropped whenever the worktree's git directories live inside the source project (the default forge layout), so `sandbox.mountProjectReadonly` is effectively inert there.
76+
The read-only project mount is dropped whenever it would nest over the writable worktree — which is the default forge layout, where the worktree lives inside the source project — so `sandbox.mountProjectReadonly` is effectively inert there. The worktree stays writable and the git metadata directory is mounted read-write alongside it, so in-sandbox git works and multiple loops in the same project each mount their worktree plus the shared git metadata independently.
3277

3378
## Shell Routing
3479

@@ -93,7 +138,16 @@ By default, Forge mounts the source project directory read-only at its identical
93138
|---|---:|---|
94139
| `sandbox.mountProjectReadonly` | `true` | Enable the read-only source project mount. |
95140

96-
The loop worktree remains writable.
141+
The loop worktree remains writable. When the read-only project mount would nest over the writable worktree (the default forge layout), it is dropped instead: inside the sandbox the outermost mount's read-only flag applies to the whole subtree, so a read-only ancestor would silently make the worktree read-only. The worktree and the shared git metadata directory are mounted read-write in that case.
142+
143+
## Git Metadata and Hooks
144+
145+
The worktree's git metadata directory is mounted read-write so git works inside the sandbox (`status`, `log`, `diff`, and commits all resolve against the real repository). Two guards keep that from becoming a path out of the sandbox:
146+
147+
- `<git-common-dir>/hooks` is mounted **read-only**, so a sandboxed agent cannot plant a hook that the user's own git would later execute on the host.
148+
- Every git command Forge itself runs is invoked with `core.hooksPath` disabled, so no repository hook runs on the host — including one reached through a `core.hooksPath` entry in the repo-local config, which cannot be mounted read-only because `sbx` workspaces are directories, not files.
149+
150+
Consequences: tools that install hooks into `.git/hooks` (for example `pre-commit install`, or Husky v4) fail inside the sandbox — hook managers that keep hooks in the working tree and point `core.hooksPath` at them still work. Forge's own scratch-branch commits never run repository hooks.
97151

98152
## Custom Bind Mounts
99153

@@ -123,6 +177,10 @@ Security note: read-write custom mounts give the sandbox write access to host pa
123177

124178
Each sbx sandbox has its own Docker daemon natively, so loops can build and run containers (for example end-to-end tests) without touching the host Docker daemon. Every sandbox gets isolated image and container storage.
125179

180+
## Sandbox Lifecycle
181+
182+
`sbx` auto-stops a sandbox roughly 35 seconds after the last exec session ends, and `sbx exec` auto-starts a stopped sandbox, so a stop is never a correctness problem — only a restart. A stop is a full VM reboot that destroys in-memory state, while on-disk state (Docker images, containers, and files) persists across it. Cold starts are roughly 0.9s for the first command after a stop, vs ~0.16s warm. Forge relies on auto-resume instead of holding a separate keep-alive exec open.
183+
126184
## Large Command Output
127185

128186
Shell output truncation is handled by opencode's native bash tool: when output exceeds the tool limit, the full output is spilled to opencode's tool-output directory on the host (readable from loop sessions, see below). The worktree `.forge/` scratch directory is added to git exclude so forge-written files are not committed.
@@ -132,7 +190,18 @@ Shell output truncation is handled by opencode's native bash tool: when output e
132190
opencode spills large tool outputs to its truncation directory (`<opencode-data>/tool-output`, e.g. `~/.local/share/opencode/tool-output`) and references the saved file by absolute host path. Forge makes those overflow files readable from loop and audit sessions in two complementary ways:
133191

134192
- **Sandbox tools** (`bash`, `glob`, `grep`): the directory is bind-mounted **read-only at the identical sandbox path**, so the same absolute path opencode reports resolves inside the sandbox. The mount is added automatically when the directory exists; it is skipped when missing or already covered by the workspace mount.
135-
- **Host file tools** (`read`): the directory is granted an `external_directory` allow rule in the loop/audit permission ruleset (layered after the blanket external-directory deny), so reads succeed without prompting in the unattended loop. All other external directories remain denied unless added via `loop.allowExternalDirectories`.
193+
- **Host file tools** (`read`): the directory is granted an `external_directory` allow rule in the loop/audit permission ruleset (layered after the blanket external-directory deny), so reads succeed without prompting in the unattended loop — the ruleset's blanket allow covers the `read` permission itself, but a `loop.permissions` rule that denies or asks for `read` is layered after these grants and still applies. All other external directories remain denied unless added via `loop.allowExternalDirectories`.
194+
195+
opencode's temp directory (`<os-tmp>/opencode` — the path opencode's bash tool advertises to agents as pre-approved scratch space) is handled the same way, but for writes: it is granted an `external_directory` allow rule for host file tools **and** bind-mounted read-write at the identical sandbox path, so scratch files an agent writes at that path resolve identically on the host and inside the sandbox. It is opencode's own directory — Forge provides no separate scratch directory, and agents can use the advertised OS temp path without issue.
196+
197+
## External Directory Access
198+
199+
`loop.allowExternalDirectories` entries are granted the same two ways, so host and container agree on what exists:
200+
201+
- **Host file tools** (`read`, `write`, `edit`): an `external_directory` allow rule layered after the blanket deny.
202+
- **Sandbox tools** (`bash`, `glob`, `grep`): a **read-only** bind mount at the identical sandbox path, added automatically. Entries that do not exist on the host are skipped with a log line.
203+
204+
The mount is read-only because the setting exists to grant read access. To make an external directory writable from inside the sandbox, add it to `sandbox.mounts` with `"readonly": false`; explicit `sandbox.mounts` entries are resolved first, so they win for any path listed in both.
136205

137206
## Resource Defaults
138207

‎docs/architecture.md‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,24 @@ Sandbox is optional. When the `sbx` daemon is available and `sandbox.mode = 'sbx
109109

110110
1. When a sandbox loop starts, an `sbx` sandbox is created
111111
2. The worktree directory is mounted at its identical host path inside the sandbox
112-
3. Tool hooks redirect `bash`, `glob`, and `grep` calls into the sandbox via `sbx exec`
112+
3. `bash` runs inside the sandbox; `glob` and `grep` results are produced inside the sandbox
113113
4. File operations (`read`, `write`, `edit`) operate on the host directly
114114
5. On loop completion, the sandbox is stopped and removed
115115

116116
### Tool Redirection
117117

118-
The sandbox uses OpenCode's tool hook system to intercept and redirect tool calls:
119-
- `tool.execute.before` hook prepends commands with `sbx exec`
120-
- `tool.execute.after` hook captures output and returns it to the host
118+
`bash` and the search tools reach the sandbox through two different mechanisms:
119+
120+
- **`bash`** is redirected out of band, not through a tool hook. The `config` hook points
121+
`cfg.shell` at the `forge-shell` shim (`sandbox/shell-shim.ts`) and the `shell.env` hook
122+
injects `FORGE_SANDBOX_CONTAINER`. The shim `exec`s `sbx exec -w "$PWD" <container> bash "$@"`.
123+
Tool arguments are never rewritten.
124+
- **`glob` and `grep`** use output replacement. `tool.execute.before` runs the equivalent
125+
`rg` command inside the container and stores the result by `callID`; `tool.execute.after`
126+
overwrites `output.output` with it. Because `tool.execute.before` cannot cancel a tool call,
127+
the native host search still executes and its result is discarded. The before-hook rejects
128+
absolute paths outside the sandbox mounts, so that host execution stays confined to the
129+
mounted worktree.
121130

122131
## Hook System
123132

‎docs/configuration.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Default log path: `~/.local/share/opencode/forge/logs/forge.log` or `$XDG_DATA_H
5353
| `loop.stallTimeoutMs` | `60000` | Stall watchdog timeout in milliseconds. |
5454
| `loop.maxConsecutiveStalls` | `5` | Consecutive stalls before terminating with `stall_timeout`. `0` disables stall termination. |
5555
| `loop.busyStallTimeoutMs` | `900000` | How long a session may stay busy with no sign of progress before the watchdog aborts the wedged message and sends a continue prompt. Both tool activity and streamed content (including reasoning/thinking deltas) count as progress, in the loop session or any of its subagent sessions, so a long thinking stretch is not mistaken for a wedged stream. `0` disables nudging. |
56-
| `loop.allowExternalDirectories` | unset | Absolute host directories that loop, audit, and post-action sessions may read despite worktree isolation. |
56+
| `loop.allowExternalDirectories` | unset | Absolute host directories that loop, audit, and post-action sessions may read despite worktree isolation. In sandboxed loops each entry is also bind-mounted read-only, so in-container `bash`/`glob`/`grep` see the same tree as host `read`. |
5757
| `loop.permissions` | unset | Per-tool `deny` overrides for loop, audit, and post-action sessions. See [Loop Permissions](#loop-permissions). |
5858
| `loop.worktreeOpencodeConfig` | unset | Inline [opencode config](https://opencode.ai/config.json) written as `opencode.jsonc` into each freshly created loop worktree. Enables per-loop customization (MCP servers, model overrides, etc.). Skip-if-exists — never overwrites a committed `opencode.json`/`opencode.jsonc`. The written file is git-excluded to keep it out of loop commits. |
5959

‎docs/sandbox.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,15 @@ opencode spills large tool outputs to its truncation directory (`<opencode-data>
194194

195195
opencode's temp directory (`<os-tmp>/opencode` — the path opencode's bash tool advertises to agents as pre-approved scratch space) is handled the same way, but for writes: it is granted an `external_directory` allow rule for host file tools **and** bind-mounted read-write at the identical sandbox path, so scratch files an agent writes at that path resolve identically on the host and inside the sandbox. It is opencode's own directory — Forge provides no separate scratch directory, and agents can use the advertised OS temp path without issue.
196196

197+
## External Directory Access
198+
199+
`loop.allowExternalDirectories` entries are granted the same two ways, so host and container agree on what exists:
200+
201+
- **Host file tools** (`read`, `write`, `edit`): an `external_directory` allow rule layered after the blanket deny.
202+
- **Sandbox tools** (`bash`, `glob`, `grep`): a **read-only** bind mount at the identical sandbox path, added automatically. Entries that do not exist on the host are skipped with a log line.
203+
204+
The mount is read-only because the setting exists to grant read access. To make an external directory writable from inside the sandbox, add it to `sandbox.mounts` with `"readonly": false`; explicit `sandbox.mounts` entries are resolved first, so they win for any path listed in both.
205+
197206
## Resource Defaults
198207

199208
| Option | Default | sbx flag |

0 commit comments

Comments
 (0)