Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@

## Sandbox

- `src/sandbox/sbx.ts` is the only module invoking the `sbx` CLI; route through its `SandboxRuntime` facade. `src/sandbox/process.ts` is the only child-process spawner; all shell execution goes through `runCommand`.
- `src/sandbox/sbx.ts` is the only module invoking the `sbx` CLI and `src/sandbox/smolvm.ts` the only one invoking `smolvm`; both are constructed exclusively through `createSandboxRuntime` in `src/sandbox/runtime-factory.ts`, which is the single mode-dispatch point. `src/sandbox/process.ts` is the only child-process spawner; all shell execution goes through `runCommand`.
- `buildShimScript` in `src/sandbox/shell-shim.ts` is the second place backend CLI shapes are encoded (as generated shell text, not a spawn) and must stay mode-aware and fail-closed.
- `buildSmolvmRootWrapper` in `src/sandbox/smolvm.ts` is the only guest-user elevation, shared by `buildSmolvmExecArgs` and the shim's smolvm branch: smolvm virtiofs has no uid mapping, so only root can write the mounts. Never add a second elevation path, and never elevate in `sbx` mode — it id-maps mounts to `agent`.
- `getSandboxState` is the only liveness primitive; four states: `running`, `stopped` (reusable, never create/evict), `unknown` (query failed), `missing` (may create or evict). `registerActiveSandbox` is the only place a usable sandbox is recorded.
- `container/Dockerfile` must derive from `docker.io/docker/sandbox-templates:shell-docker`; no `ENTRYPOINT`, `CMD`, or `WORKDIR`.

Expand Down
14 changes: 7 additions & 7 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Configured rules are layered into the ruleset in this order:

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

Expand Down Expand Up @@ -244,15 +244,15 @@ See [Sandbox](sandbox.md) for detailed behavior and security notes.

| Option | Default | Description |
|---|---:|---|
| `sandbox.enabled` | `true` | Enable sandboxed execution when the `sbx` daemon is available. |
| `sandbox.mode` | `"sbx"` | Sandbox mode. `sbx` is currently the only supported mode. |
| `sandbox.image` | `"oc-forge-sandbox:latest"` | sbx template tag used for sandboxed execution. |
| `sandbox.enabled` | `true` | Enable sandboxed execution when a sandbox backend is available. |
| `sandbox.mode` | `"sbx"` | Sandbox backend: `"sbx"` (default; CLI + daemon) or `"smolvm"` (the smolvm CLI, no daemon). Unknown or legacy values fall back to `"sbx"`. |
| `sandbox.image` | `"oc-forge-sandbox:latest"` | Template tag used for sandboxed execution: loaded via `sbx template load`, or stored as `<dataDir>/smolvm-images/<sanitized-ref>.tar` and passed to `smolvm machine create --image`. Under smolvm a registry-qualified ref containing `/` is passed through for smolvm to pull. |
| `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. |
| `sandbox.resources.memory` | `"8g"` | Sandbox memory limit (`sbx create --memory`). |
| `sandbox.resources.cpus` | `"4"` | CPU count (`sbx create --cpus`; integer-only). |
| `sandbox.resources.memory` | `"8g"` | Sandbox memory limit (`sbx create --memory`; smolvm `--mem`, converted to integer MiB). |
| `sandbox.resources.cpus` | `"4"` | CPU count (`sbx create --cpus` / smolvm `--cpus`; integer-only). |
| `sandbox.mountProjectReadonly` | `true` | Mount the source project read-only at its identical host path. |
| `sandbox.mounts` | `[]` | Additional host directories to mount at their identical host path. |
| `sandbox.network.allow` | `[]` | Hosts the sandbox may reach (deny-by-default proxy). |
| `sandbox.network.allow` | `[]` | Hosts the sandbox may reach. sbx: deny-by-default proxy, applied via `sbx policy allow network` at sandbox start. smolvm: per-machine `--allow-host` flags applied at create time (changing the list requires recreating the sandbox); an empty list means unrestricted egress. |
| `sandbox.network.env` | `[]` | Host environment variables to pass into each sandbox command via the env file. |

## Bundled Assets & Installer
Expand Down
18 changes: 11 additions & 7 deletions docs/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,36 +268,40 @@ Source: [src/services/execution.ts](../src/services/execution.ts)

---

## `sandbox/` — sbx Sandboxing
## `sandbox/` — Sandbox Runtimes

Drives the `sbx` CLI to provision isolated sandboxes for loop execution.
Drives the `sbx` and `smolvm` CLIs to provision isolated sandboxes for loop execution.

### Files

| File | Purpose |
|------|---------|
| `sbx.ts` | `SandboxRuntime` facade over the `sbx` CLI (create/exec/remove/list, availability probe) |
| `smolvm.ts` | Pure helpers plus the `SandboxRuntime` facade over the `smolvm` CLI (argv builders, image-store paths, stopped-machine recovery) |
| `runtime-factory.ts` | `SandboxMode` resolution and the single `createSandboxRuntime` construction point |
| `process.ts` | Child-process runner (`runCommand`) shared by the sandbox helpers |
| `template.ts` | Template build/save/load helper (`docker build`/`docker save`/`sbx template load`) |
| `template.ts` | Template build/save/load helper (`docker build`/`docker save`/backend `loadTemplate`) |
| `config-warnings.ts` | Warnings for legacy Docker-era sandbox config keys |
| `manager.ts` | `SandboxManager` lifecycle management (start/stop/getActive/isLive) |
| `reconcile.ts` | Sandbox reconciliation with loop states |
| `context.ts` | `SandboxContext`, `isSandboxEnabled()` |
| `path.ts` | Sandbox path utilities |
| `exec-fs.ts` | Filesystem operations through `sbx exec` |
| `exec-fs.ts` | Filesystem operations executed inside the sandbox (backend-agnostic) |

### SandboxRuntime Interface

```typescript
interface SandboxRuntime {
checkAvailable(): Promise<SbxAvailability>
describeUnavailable(result: Extract<SbxAvailability, { available: false }>): string
templateExists(ref: string): Promise<boolean>
loadTemplate(tarPath: string): Promise<void>
templateLoadHint(ref: string): string
loadTemplate(tarPath: string, ref: string): Promise<void>
createSandbox(name: string, workspaces: SandboxWorkspace[], opts?: CreateSandboxOpts): Promise<void>
removeSandbox(name: string): Promise<void>
exec(name: string, command: string, opts?: SandboxExecOpts): Promise<CommandResult>
execPipe(name: string, command: string, stdin: string, opts?: ...): Promise<CommandResult>
isRunning(name: string): Promise<boolean>
execPipe(name: string, command: string, stdin: string, opts?: { timeout?: number; abort?: AbortSignal; envFile?: string }): Promise<CommandResult>
getSandboxState(name: string): Promise<SandboxState>
sandboxContainerName(worktreeName: string): string
listSandboxesByPrefix(prefix: string): Promise<string[]>
allowNetworkHost(host: string): Promise<boolean>
Expand Down
80 changes: 78 additions & 2 deletions docs/sandbox.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Sandbox

Forge can run loop iterations or one selected host session inside an isolated `sbx` sandbox while keeping the active project directory mounted at its identical host path for fast host/sandbox file sharing.
Forge can run loop iterations or one selected host session inside an isolated sandbox — either an `sbx` sandbox (CLI + daemon) or a `smolvm` machine (see [smolvm Mode](#smolvm-mode)) — while keeping the active project directory mounted at its identical host path for fast host/sandbox file sharing.

See also: [Configuration](configuration.md), [Tools](tools.md), [Loop System](loop-system.md).

Expand Down Expand Up @@ -164,6 +164,80 @@ Security note: read-write custom mounts give the sandbox write access to host pa

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.

## Keep-Alive

`sbx` auto-stops a sandbox roughly 35 seconds after the last exec session ends. Forge holds one long-lived "sentinel" exec per active sandbox — an in-container `sleep 600` — and renews it when it returns. `sbx` keeps a sandbox running as long as an exec session is in flight, so the sentinel holds it warm with no polling. The 10-minute bound means that if the forge process dies without cleanup, the sandbox stops within that bound rather than staying up forever. On plugin cleanup the sentinel is aborted and the sandboxes are left alone, matching Forge's contract of preserving active loops across restarts. Holding a session is the same "sentinel connection" approach Docker's own `sbx cp` and `sbx kit add` use.

Cold starts are cheap: roughly 0.9s for the first command after a stop, vs ~0.16s warm. Keep-alive is not about latency — a stop is a full VM reboot that destroys in-memory state, while on-disk state (Docker images, containers, and files) persists across it. And because `sbx exec` auto-starts a stopped sandbox, keep-alive is never required for correctness of a single command.

## smolvm Mode

Forge can run sandboxed loops on the `smolvm` CLI (smolmachines.com) instead of the `sbx` daemon. Enable it with:

```jsonc
{
"sandbox": {
"mode": "smolvm"
}
}
```

### Requirements

- The `smolvm` CLI installed: `curl -sSL https://smolmachines.com/install.sh | bash`. There is no daemon — the `smolvm` binary embeds libkrun and drives the local hypervisor directly.
- A supported platform: macOS 11+ on Apple silicon, Linux with `/dev/kvm`, or Windows x86_64 with Windows Hypervisor Platform.
- Docker, used only to build the sandbox template (see below).

### Template Flow

The bundled template is still built with Docker; only the final step differs. Forge keeps a managed image store at `<dataDir>/smolvm-images/`:

```bash
docker build -t oc-forge-sandbox:latest container/
docker save oc-forge-sandbox:latest -o forge-sandbox.tar
# Forge stores the tar as <dataDir>/smolvm-images/<sanitized-ref>.tar
```

Each sandbox create resolves `--image` from that store and passes the tar to `smolvm machine create --image <tar>`, which consumes the `docker save` archive directly — there is no template-store command to run. A registry-qualified ref containing `/` (for example `docker.io/library/oc-forge-sandbox:latest`) is passed through to `machine create` unchanged, letting smolvm pull it. The "Build sandbox template" palette command builds and stores the tar under the active mode.

### Network Semantics

smolvm machines are created with `--net` and have no global proxy: egress is unrestricted by default. `sandbox.network.allow` maps to per-machine `--allow-host` flags applied at create time, so changing the list after a sandbox exists requires recreating the sandbox. Under smolvm an empty (or absent) allow list means unrestricted egress — the opposite of sbx's deny-by-default proxy.

smolvm resolves every `--allow-host` as a literal hostname when the machine starts, and an unresolvable one fails the create outright — there is no wildcard. A wildcard entry (for example sbx's allow-everything `**`) therefore drops **all** `--allow-host` flags, which is the faithful translation: no flags already means unrestricted egress.

Inbound is closed and guest ports never collide with host ports. Forge passes no `-p`, so a guest listener publishes nothing and binds nothing on the host. The machine has its own kernel and network stack, so a guest process binds a port the host is already using, each side keeps serving its own process on that number, and inside the guest `127.0.0.1:<port>` resolves to the guest's own listener.

**Host loopback is reachable for ports the guest is not using.** smolvm's default `tsi` network backend impersonates guest sockets on the host, so a guest connection to `127.0.0.1:<port>` falls through to a *host* service on that port whenever the guest has nothing bound there — guest listeners take precedence, but they are the only thing shadowing the host. sbx's proxy blocks host loopback outright, so this is a smolvm-only exposure: treat host-local dev servers, databases, and unauthenticated ports as reachable from a smolvm loop. Egress filtering (`--allow-host`/`--allow-cidr`) is the only mitigation and requires the `virtio-net` backend, which bundled libkrun builds may not expose; when they do not, any `sandbox.network.allow` entry makes the machine fail to start rather than silently run unfiltered.

### Guest User

smolvm bind-mounts host directories through virtiofs **without uid mapping**: the guest sees the host owner's numeric uid on the worktree, while the image user (`agent`) is a different uid, so that user cannot write a single file in the mount. `smolvm machine exec` has no `--user` flag, so Forge elevates each guest command with `sudo -nE PATH="$PATH"` — root is the only guest user that can write the mounts, and virtiofsd runs as the host user, so files the guest creates are owned by the host user on the host side. `-E` plus an explicit `PATH` is required because sudoers `secure_path` would otherwise strip the image PATH and hide the preinstalled toolchains. When an image offers no passwordless sudo the command still runs, unelevated, rather than failing.

Because the image ships an empty `/etc/hosts`, `sudo` would print `unable to resolve host` on every command's stderr; the guest bootstrap appends the machine hostname once per machine start to silence it, guarded so it can never fail the bootstrap.

This is a smolvm-only concern. `sbx` id-maps its bind mounts to the container user, so its commands stay unprivileged as `agent`.

### Docker in the Machine

Each smolvm sandbox runs the image's own Docker daemon, matching the in-sandbox Docker that `sbx` provides natively. smolvm boots an image as a bare agent and never runs its entrypoint or init scripts, so Forge starts the daemon itself after every machine start (creation and transparent restart alike). Three guest details shape the command:

- The machine root filesystem is itself an overlay and `overlay2` cannot stack on it, so the daemon's data root is pinned to the machine's ext4 `/storage` disk (`--data-root=/storage/docker`). Unlike the bind mount in smolvm's docker-in-vm example, a data root survives stop/start.
- `smolvm machine exec` applies only the user's primary group, so the default `root:docker` socket is unreachable from a loop command. The daemon is started with `--group agent`, matching the image user's primary group.
- Startup is idempotent and non-fatal: it no-ops when the image ships no `dockerd` or a daemon already answers, and a daemon that refuses to start degrades to "no Docker in this sandbox" (logged) rather than failing sandbox creation.

### Environment Passthrough

`sandbox.network.env` variables are written to the same host-side env file. Because `smolvm machine exec` has no `--env-file` flag, Forge mounts the env directory read-only at its identical host path and each exec sources the file in-guest before running the command.

### Shell Routing

The generated shell shim routes bash-tool commands through `smolvm machine exec --name <sandbox> -- bash -c <payload>` instead of `sbx exec`, applying the working directory and env file inside the guest (smolvm exec has no `-w` or `--env-file` flags). The payload rides as a positional argument of the same root-elevation wrapper the runtime exec path uses, so shim and runtime commands run as the same guest user. It fails closed exactly like the sbx shim: if the machine is expected but `smolvm machine exec` fails, the command errors rather than silently running on the host.

### Keep-Alive and Recovery

smolvm machines do not auto-stop the way `sbx` sandboxes do (~35s idle stop), so the sentinel exec is harmless there. If a machine is stopped out-of-band, the next exec fails with a stopped-machine error and Forge restarts it transparently before retrying the command once.

## Large Command Output

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.
Expand All @@ -173,7 +247,9 @@ Shell output truncation is handled by opencode's native bash tool: when output e
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:

- **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.
- **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`.
- **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`.

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.

## Resource Defaults

Expand Down
14 changes: 9 additions & 5 deletions forge-config.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -99,21 +99,25 @@
},

// Sandbox configuration. Sandbox is optional: loops always run in an isolated git worktree, and
// when the sbx CLI and daemon are available a sandbox is provisioned automatically. Set
// "enabled": false to force worktree-only mode even when the sbx daemon is running.
// when a sandbox backend is available a sandbox is provisioned automatically. Set
// "enabled": false to force worktree-only mode even when a backend is available.
"sandbox": {
"enabled": true,
// "mode" selects the sandbox backend: "sbx" (default; CLI + daemon) or "smolvm" (the smolvm
// CLI, no daemon). Unknown or legacy values fall back to "sbx".
"mode": "sbx",
"image": "oc-forge-sandbox:latest",
"imageFeatures": {
"browserControl": false
}
// Mount the source project directory read-only at its identical host path. Defaults to true.
// "mountProjectReadonly": true,
// Network access configuration. The sbx proxy is deny-by-default: host loopback is
// unreachable, and outbound access is allowed only for hosts listed in "allow".
// Network access configuration. With "sbx" the proxy is deny-by-default: host loopback is
// unreachable, and outbound access is allowed only for hosts listed in "allow". With "smolvm"
// the list becomes per-machine --allow-host flags applied at create time, and an empty list
// means unrestricted egress.
// "network": {
// // Hosts the sandbox may reach. Defaults to none (deny-by-default).
// // Hosts the sandbox may reach. Defaults to none (deny-by-default with sbx).
// "allow": ["registry.npmjs.org"],
// // Host environment variable names passed into each sandbox exec via the env file.
// "env": ["MY_VAR"]
Expand Down
Loading