Skip to content
4 changes: 4 additions & 0 deletions packages/docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
"group": "Getting started",
"pages": ["index", "quickstart", "development"],
"openapi": "https://opencode.ai/openapi.json"
},
{
"group": "Features",
"pages": ["sandbox"]
}
]
}
Expand Down
126 changes: 126 additions & 0 deletions packages/docs/sandbox.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
---
title: "Sandboxing"
description: "Confine agent bash operations using a local sandbox runtime."
---

By default, OpenCode executes bash commands with the current user's privileges. Sandboxing limits the agent's filesystem and network access to reduce security risks when executing generated commands.

OpenCode integrates Anthropic's **Sandbox Runtime (`srt`)** to run agent processes in a confined environment. It operates using OS-level primitives (`bubblewrap` on Linux, `sandbox-exec` on macOS) without requiring containers like Docker.

<Note>
Sandboxing is currently supported strictly on macOS and Linux. If you are on Windows, you must disable the sandbox setting.
</Note>

## Enabling the Sandbox

OpenCode's sandboxing is configured within your `opencode.json` configuration file, typically located at `~/.config/opencode/opencode.json`.

By default, sandboxing is disabled. To enable it, define a `bash_sandbox` block with `enabled: true`.

### Configuration Example

Here is an example `bash_sandbox` configuration block:

```json ~/.config/opencode/opencode.json
{
"$schema": "https://opencode.ai/config.json",
"bash_sandbox": {
"enabled": true,
"provider": "srt",
"domains": [
"github.com",
"registry.npmjs.org"
],
"env_whitelist": [
"PATH",
"HOME",
"USER",
"SHELL",
"TERM"
],
"deny_workspace_patterns": [
"**/*.env",
"**/*.secret",
"**/*_rsa"
],
"deny_binaries": [
"terraform",
"aws",
"docker",
"/usr/bin/python3"
]
}
}
```

## MCP Extensions

While `bash_sandbox` secures the agent's interactive shell, you can apply the exact same sandboxing architecture to locally spawned Model Context Protocol (MCP) servers using the `mcp_sandbox` block. This encapsulates community-provided or untrusted MCP servers.

### Global vs Local Constraints

You can define a global `mcp_sandbox` explicitly in `opencode.json` that acts as the default foundation for all locally run tools:

```json
{
"mcp_sandbox": {
"enabled": true,
"provider": "srt",
"domains": ["api.anthropic.com"],
"deny_binaries": ["docker", "kubectl"]
}
}
```

However, because specific tools often require disparate permissions, you can embed a `sandbox` block natively within any `local` MCP configuration.

```json
{
"mcp": {
"postgres_tool": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-postgres", "..."],
"sandbox": {
"deny_workspace_patterns": ["**/*.secret"]
}
}
}
}
```

> [!NOTE]
> **Configuration Inheritance (Union Merging):** When a local `sandbox` block overrides the global default, its arrays are natively union merged (`Array.from(new Set([...global, ...local]))`). This means that local rules purely expand the allowance or restriction base set by the global policy without accidently stripping core security mandates!

> [!WARNING]
> **CWD Sandbox Constraints**: Every local MCP inherently executes mapping to the active `opencode` instance's project root directory. Therefore, the container's allowable file-system is strictly anchored to the workspace root (`Instance.directory`), preserving host-level isolation!

## Feature Details

### Environment Variables

New bash processes are started with an empty environment. Only the environment variables explicitly listed in the `env_whitelist` array are inherited from the host system. This prevents the agent from reading host variables like `AWS_ACCESS_KEY_ID`.

### Network Access

By default, the sandbox blocks all outbound network requests. You can whitelist specific DNS hostnames in the `domains` array to permit necessary network access.

### File System Constraints

The agent's read and write access is restricted. By default, access to your home directory—outside of the current workspace—is blocked natively.

You can prevent the agent from reading or writing to specific files inside your working directory using the `deny_workspace_patterns` array. For example, to prevent the agent from accessing or modifying environments and secrets files:

```bash
> cat .env
/usr/bin/bash: line 1: .env: Permission denied
> echo "MODIFIED" > .env
/usr/bin/bash: line 2: .env: Permission denied
```

### Binary Blocklisting

You can block the agent from executing specific programs by adding them to the `deny_binaries` array. You can use absolute paths or just the executable name (which will be automatically resolved). This prevents the sandbox from reading the associated binary, rendering it unexecutable.

```json
"deny_binaries": ["aws", "terraform", "python"]
```
1 change: 0 additions & 1 deletion packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ import { DialogVariant } from "./component/dialog-variant"

function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
return {
externalOutputMode: "passthrough",
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
Expand Down
21 changes: 21 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,21 @@ export namespace Config {
return uniqueSpecifiers.toReversed()
}

export const SandboxOptions = z
.object({
enabled: z.union([z.boolean(), z.literal("auto")]).optional().describe("Enable or enforce sandbox containerization"),
provider: z.literal("srt").optional().describe("The sandbox runtime provider to use (default: srt)"),
domains: z.array(z.string()).optional().describe("Whitelisted domains for the sandbox proxy. Empty array airgaps the container"),
env_whitelist: z.array(z.string()).optional().describe("Whitelist of environment variables passed to the sandboxed shell"),
deny_workspace_patterns: z.array(z.string()).optional().describe("Glob patterns to explicitly deny read/write access to in the workspace"),
deny_binaries: z.array(z.string()).optional().describe("Absolute or generic executable names to blocklist from the sandbox"),
})
.meta({
ref: "SandboxOptions",
})

export type SandboxOptions = z.infer<typeof SandboxOptions>

export const McpLocal = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
Expand All @@ -424,6 +439,8 @@ export namespace Config {
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
sandbox: SandboxOptions.optional()
.describe("Override sandbox configuration specifically for this MCP server"),
})
.strict()
.meta({
Expand Down Expand Up @@ -1055,6 +1072,10 @@ export namespace Config {
.describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."),
})
.optional(),
bash_sandbox: SandboxOptions.optional()
.describe("Configuration for executing bash commands within a natively secured sandbox container."),
mcp_sandbox: SandboxOptions.optional()
.describe("Default sandbox configuration applied to locally running MCP servers."),
experimental: z
.object({
disable_paste_summary: z.boolean().optional(),
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export namespace Flag {
Config.withDefault(false),
)
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")

export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
Expand Down
81 changes: 69 additions & 12 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,48 @@ import { McpAuth } from "./auth"
import { BusEvent } from "../bus/bus-event"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { getSandboxProvider } from "../sandbox/provider"
import open from "open"
import { Effect, Exit, Layer, Option, ServiceMap, Stream } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"

// Union merge arrays so global defaults are combined with local overrides
const merge = (a?: string[], b?: string[]) => {
if (!a && !b) return undefined
return Array.from(new Set([...(a ?? []), ...(b ?? [])]))
}

export const wrapMcpCommand = (
command: string[],
cwd: string,
env: Record<string, string | undefined>,
local: Config.SandboxOptions | undefined,
global: Config.SandboxOptions | undefined,
) => {
const enabled = Boolean(local?.enabled ?? global?.enabled ?? false)
if (!enabled) return { command, env, cleanup: undefined }

const provider = getSandboxProvider(local?.provider ?? global?.provider ?? "srt")

const wrapped = provider.wrapCommand(command, {
cwd,
env: env as NodeJS.ProcessEnv,
envWhitelist: merge(global?.env_whitelist, local?.env_whitelist),
networkDomains: merge(global?.domains, local?.domains),
denyWorkspacePatterns: merge(global?.deny_workspace_patterns, local?.deny_workspace_patterns),
denyBinaries: merge(global?.deny_binaries, local?.deny_binaries),
})

return {
command: [wrapped.executable, ...wrapped.args],
env: (wrapped.env as Record<string, string | undefined>) ?? env,
cleanup: wrapped.cleanup,
}
}

export namespace MCP {
const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000
Expand Down Expand Up @@ -378,30 +413,52 @@ export namespace MCP {
})

const connectLocal = Effect.fn("MCP.connectLocal")(function* (key: string, mcp: Config.Mcp & { type: "local" }) {
const [cmd, ...args] = mcp.command
const cwd = Instance.directory
const env = {
...process.env,
...(mcp.command[0] === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...mcp.environment,
}

const cfg = yield* cfgSvc.get()
let wrapped: ReturnType<typeof wrapMcpCommand>
try {
wrapped = wrapMcpCommand(mcp.command, cwd, env, mcp.sandbox, cfg.mcp_sandbox)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
log.error("sandbox setup failed", { key, error: msg })
return { client: undefined as MCPClient | undefined, status: { status: "failed", error: msg } as Status }
}
const cleanup = wrapped.cleanup
const [cmd, ...args] = wrapped.command

const transport = new StdioClientTransport({
stderr: "pipe",
command: cmd,
args,
cwd,
env: {
...process.env,
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...mcp.environment,
},
env: wrapped.env as Record<string, string>,
})
transport.stderr?.on("data", (chunk: Buffer) => {
log.info(`mcp stderr: ${chunk.toString()}`, { key })
})

const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
return yield* connectTransport(transport, connectTimeout).pipe(
Effect.map((client): { client: MCPClient | undefined; status: Status } => ({
client,
status: { status: "connected" },
})),
const timeout = mcp.timeout ?? DEFAULT_TIMEOUT
return yield* connectTransport(transport, timeout).pipe(
Effect.map((client): { client: MCPClient | undefined; status: Status } => {
if (cleanup) {
const close = client.close.bind(client)
client.close = async () => {
try { await close() } finally { cleanup() }
}
}
return {
client,
status: { status: "connected" },
}
}),
Effect.catch((error): Effect.Effect<{ client: MCPClient | undefined; status: Status }> => {
if (cleanup) cleanup()
const msg = error instanceof Error ? error.message : String(error)
log.error("local mcp startup failed", { key, command: mcp.command, cwd, error: msg })
return Effect.succeed({ client: undefined, status: { status: "failed", error: msg } })
Expand Down
Loading
Loading