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
80 changes: 70 additions & 10 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,85 @@ submit one that will be an automatic ban from the project.

OpenCode is an AI-powered coding assistant that runs locally on your machine. It provides an agent system with access to powerful tools including shell execution, file operations, and web access.

### No Sandbox
### Sandboxing (macOS only, experimental)

OpenCode does **not** sandbox the agent. The permission system exists as a UX feature to help users stay aware of what actions the agent is taking - it prompts for confirmation before executing commands, writing files, etc. However, it is not designed to provide security isolation.
OpenCode can optionally sandbox certain command execution paths on macOS using `sandbox-exec`. This feature is **opt-in**, **experimental**, and **off by default**. It is not available on Linux or Windows.

If you need true isolation, run OpenCode inside a Docker container or VM.
#### Covered surfaces

| Surface | Sandbox profile | Excluded-command check | Unsandboxed retry |
| ----------------------------------------------------------- | --------------- | ---------------------- | ----------------------------------- |
| **Bash tool** (agent-issued non-interactive commands) | Yes | Yes (pre-spawn) | Yes (`bash:unsandboxed` permission) |
| **Session command path** (user-initiated command execution) | Yes | Yes (pre-spawn) | Yes (`bash:unsandboxed` permission) |
| **PTY interactive sessions** | Yes | Initial spawn only | No |

PTY sessions apply the sandbox profile to the initial process spawn and check `excluded_commands` before spawning. In-band command filtering inside a running PTY session is **not** performed — once a PTY shell is running, commands typed into it are not individually inspected or blocked.

#### Presets

Built-in presets control mode, network, and permission defaults:

| Preset | Mode | Network | Notes |
| ------------- | ----------------- | ------- | ----------------------------------------- |
| **`default`** | `workspace-write` | No | Read system paths, read/write workspace |
| **`strict`** | `read-only` | No | Writes limited to `/tmp`; bash/edit = ask |
| **`network`** | `workspace-write` | Yes | Same as default but allows network access |

Custom presets can be defined under `experimental.sandbox.presets` in `opencode.json`. Selecting a preset via the `preset` field resolves the named preset, then any sibling sandbox fields (`mode`, `network`, `protected_roots`, `extra_read_roots`, `extra_write_roots`) override the preset values.

#### Protected roots

Inside writable workspace roots, `.git` and `.opencode` are always write-protected. If the workspace is a git worktree, the resolved gitdir target (read from the `.git` file) is also write-protected. These deny rules are emitted after the write-allow rules in the `sandbox-exec` profile, so they take precedence.

#### Modes

- **`workspace-write`** (default) — the sandboxed process can read system paths and read/write within the project workspace.
- **`read-only`** — the sandboxed process can read, and writes are limited to `/tmp`, `/private/tmp`, and explicitly configured extra write roots.

There is no `danger-full-access` or unrestricted mode. Even the most permissive built-in preset (`network`) still enforces filesystem boundaries and protected roots.

#### Configuration options

All options live under `experimental.sandbox` in `opencode.json`:

- **`preset`** — selects a built-in or custom preset by name. Defaults to `default`.
- **`presets`** — defines custom presets keyed by name. Each preset can specify `mode`, `network`, `protected_roots`, `permission`, `extra_read_roots`, and `extra_write_roots`.
- **`mode`** — overrides the preset mode (`workspace-write` or `read-only`).
- **`network`** — overrides the preset network policy (`true` or `false`).
- **`protected_roots`** — overrides the preset list of write-protected workspace-relative paths (defaults to `.git` and `.opencode`).
- **`extra_read_roots`** — additional absolute paths the sandbox allows reading.
- **`extra_write_roots`** — additional absolute paths the sandbox allows writing.
- **`excluded_commands`** — a pre-spawn deny list of command prefixes. Matched commands are blocked before execution on all covered surfaces.
- **`fail_if_unavailable`** — when `true`, hard-fails activation if sandboxing is enabled but `sandbox-exec` is missing or the platform is unsupported.
- **`extra_deny_paths`** — extends the default set of denied paths (secrets directories like `.ssh`, `.gnupg`, `.aws`, etc.).
- **`allow_unsandboxed_retry`** — when `true`, adds a distinct `bash:unsandboxed` permission-gated retry for the bash tool and session command path only. If a sandboxed command fails due to a sandbox denial, the user is prompted to allow an unsandboxed re-execution. PTY sessions do **not** support unsandboxed retry.

#### Not covered

The following are explicitly **not** sandboxed:

- MCP server processes (local stdio servers and SSE connections)
- Internal spawn utilities (`util/process.ts`, `cross-spawn-spawner.ts`) not routed through the three surfaces above
- Domain/proxy-mediated network controls
- All non-macOS platforms (Linux, Windows, etc.)

The permission system (confirmation prompts before commands, file writes, etc.) remains a UX layer, not a security boundary. A sandbox denial can still block a command that the permission system allowed.

For stronger isolation, run OpenCode inside a Docker container or VM.

### Server Mode

Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to require HTTP Basic Auth. Without this, the server runs unauthenticated (with a warning). It is the end user's responsibility to secure the server - any functionality it provides is not a vulnerability.

### Out of Scope

| Category | Rationale |
| ------------------------------- | ----------------------------------------------------------------------- |
| **Server access when opted-in** | If you enable server mode, API access is expected behavior |
| **Sandbox escapes** | The permission system is not a sandbox (see above) |
| **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies |
| **MCP server behavior** | External MCP servers you configure are outside our trust boundary |
| **Malicious config files** | Users control their own config; modifying it is not an attack vector |
| Category | Rationale |
| ------------------------------------- | ---------------------------------------------------------------------------- |
| **Server access when opted-in** | If you enable server mode, API access is expected behavior |
| **Sandbox escapes (uncovered paths)** | MCP servers, non-macOS execution, and in-band PTY commands are not sandboxed |
| **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies |
| **MCP server behavior** | External MCP servers you configure are outside our trust boundary |
| **Malicious config files** | Users control their own config; modifying it is not an attack vector |

---

Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,12 @@ export const dict = {
"settings.permissions.tool.list.description": "List files within a directory",
"settings.permissions.tool.bash.title": "Bash",
"settings.permissions.tool.bash.description": "Run shell commands",
"settings.permissions.tool.bash_unsandboxed.title": "Bash (Unsandboxed)",
"settings.permissions.tool.bash_unsandboxed.description": "Retry a shell command without sandbox restrictions",
"settings.permissions.tool.bash_unsandboxed_network.title": "Bash (Unsandboxed)",
"settings.permissions.tool.bash_unsandboxed_network.description": "Sandbox networking is disabled, so the previous attempt may have failed because of the sandbox.",
"settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (Unsandboxed)",
"settings.permissions.tool.bash_unsandboxed_explicit.description": "The command requested to run without sandbox restrictions.",
"settings.permissions.tool.task.title": "Task",
"settings.permissions.tool.task.description": "Launch sub-agents",
"settings.permissions.tool.skill.title": "Skill",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,18 @@ export function SessionPermissionDock(props: {
const language = useLanguage()

const toolDescription = () => {
const key = `settings.permissions.tool.${props.request.permission}.description`
let permission = props.request.permission
if (permission === "bash:unsandboxed") {
const reason = props.request.metadata?.reason
if (reason === "possible_network_sandbox_denial") {
permission = "bash_unsandboxed_network"
} else if (reason === "explicit_request") {
permission = "bash_unsandboxed_explicit"
} else {
permission = "bash_unsandboxed"
}
}
const key = `settings.permissions.tool.${permission}.description`
const value = language.t(key as Parameters<typeof language.t>[0])
if (value === key) return ""
return value
Expand Down
13 changes: 12 additions & 1 deletion packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
import { Permission } from "@/permission"
import { Flag } from "@/flag/flag"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
import path from "path"
import { Plugin } from "@/plugin"
import { SandboxPreset } from "@/sandbox/preset"
import { Skill } from "../skill"
import { Effect, Context, Layer } from "effect"
import { InstanceState } from "@/effect"
Expand Down Expand Up @@ -104,6 +106,13 @@ export const layer = Layer.effect(
})

const user = Permission.fromConfig(cfg.permission ?? {})
const sandbox = cfg.experimental?.sandbox
const enabled =
process.env["OPENCODE_EXPERIMENTAL_SANDBOX"] === undefined
? sandbox?.enabled === true
: Flag.OPENCODE_EXPERIMENTAL_SANDBOX
const preset = enabled ? SandboxPreset.active(sandbox) : undefined
const overlay = preset ? Permission.fromConfig(preset.permission) : []

const agents: Record<string, Info> = {
build: {
Expand All @@ -116,6 +125,7 @@ export const layer = Layer.effect(
question: "allow",
plan_enter: "allow",
}),
overlay,
user,
),
mode: "primary",
Expand Down Expand Up @@ -152,6 +162,7 @@ export const layer = Layer.effect(
Permission.fromConfig({
todowrite: "deny",
}),
overlay,
user,
),
options: {},
Expand Down Expand Up @@ -243,7 +254,7 @@ export const layer = Layer.effect(
item = agents[key] = {
name: key,
mode: "all",
permission: Permission.merge(defaults, user),
permission: Permission.merge(defaults, overlay, user),
options: {},
native: false,
}
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,13 @@ function skill(info: ToolProps<typeof SkillTool>) {
}

function bash(info: ToolProps<typeof BashTool>) {
const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
let output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
if (output) {
output = output
.replace(/<bash_metadata>[\s\S]*?(?:<\/bash_metadata>|$)/g, "")
.replace(/<metadata>[\s\S]*?(?:<\/metadata>|$)/g, "")
.trim()
}
block(
{
icon: "$",
Expand Down
18 changes: 14 additions & 4 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
} from "@opencode-ai/sdk/v2"
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util"
import { SandboxSpawn } from "@/sandbox/spawn"
import type { Tool } from "@/tool"
import type { ReadTool } from "@/tool/read"
import type { WriteTool } from "@/tool/write"
Expand Down Expand Up @@ -1766,7 +1767,14 @@ function Bash(props: ToolProps<typeof BashTool>) {
const { theme } = useTheme()
const sync = useSync()
const isRunning = createMemo(() => props.part.state.status === "running")
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const output = createMemo(() => {
let out = props.metadata.output?.trim() ?? ""
out = out
.replace(/<bash_metadata>[\s\S]*?(?:<\/bash_metadata>|$)/g, "")
.replace(/<metadata>[\s\S]*?(?:<\/metadata>|$)/g, "")
.trim()
return stripAnsi(out)
})
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
Expand Down Expand Up @@ -1800,6 +1808,8 @@ function Bash(props: ToolProps<typeof BashTool>) {
return `# ${desc} in ${wd}`
})

const command = createMemo(() => SandboxSpawn.directive(props.input.command ?? "").command)

return (
<Switch>
<Match when={props.metadata.output !== undefined}>
Expand All @@ -1810,7 +1820,7 @@ function Bash(props: ToolProps<typeof BashTool>) {
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.input.command}</text>
<text fg={theme.text}>$ {command()}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
Expand All @@ -1821,8 +1831,8 @@ function Bash(props: ToolProps<typeof BashTool>) {
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="$" pending="Writing command..." complete={props.input.command} part={props.part}>
{props.input.command}
<InlineTool icon="$" pending="Writing command..." complete={command()} part={props.part}>
{command()}
</InlineTool>
</Match>
</Switch>
Expand Down
34 changes: 33 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Global } from "@/global"
import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
import { SandboxSpawn } from "@/sandbox/spawn"

type PermissionStage = "permission" | "always" | "reject"

Expand Down Expand Up @@ -286,7 +287,8 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
if (permission === "bash") {
const title =
typeof data.description === "string" && data.description ? data.description : "Shell command"
const command = typeof data.command === "string" ? data.command : ""
const rawCommand = typeof data.command === "string" ? data.command : ""
const command = SandboxSpawn.directive(rawCommand).command
return {
icon: "#",
title,
Expand All @@ -300,6 +302,36 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
}
}

if (permission === "bash:unsandboxed") {
const rawCommand = typeof data.command === "string" ? data.command : ""
const command = SandboxSpawn.directive(rawCommand).command
const reason = props.request.metadata?.reason
const detail = typeof props.request.metadata?.detail === "string" ? props.request.metadata.detail : ""
const isNetwork = reason === "possible_network_sandbox_denial"
const isExplicit = reason === "explicit_request"
return {
icon: "#",
title: isExplicit ? "Run shell command without sandbox" : "Retry shell command without sandbox",
body: (
<box paddingLeft={1} flexDirection="column" gap={1}>
<text fg={theme.textMuted}>
{isExplicit
? "The command requested to run without sandbox restrictions."
: isNetwork
? "Sandbox networking is disabled, so the previous attempt may have failed because of the sandbox."
: "The previous sandboxed attempt was denied."}
</text>
<Show when={isExplicit && detail}>
<text fg={theme.textMuted}>{detail}</text>
</Show>
<Show when={command}>
<text fg={theme.text}>{"$ " + command}</text>
</Show>
</box>
),
}
}

if (permission === "task") {
const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown"
const desc = typeof data.description === "string" ? data.description : ""
Expand Down
9 changes: 8 additions & 1 deletion packages/opencode/src/cli/cmd/tui/util/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,14 @@ export function formatPart(part: Part, options: TranscriptOptions): string {
result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`\n`
}
if (options.toolDetails && part.state.status === "completed" && part.state.output) {
result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`\n`
let output = part.state.output
if (part.tool === "bash") {
output = output
.replace(/<bash_metadata>[\s\S]*?(?:<\/bash_metadata>|$)/g, "")
.replace(/<metadata>[\s\S]*?(?:<\/metadata>|$)/g, "")
.trim()
}
result += `\n**Output:**\n\`\`\`\n${output}\n\`\`\`\n`
}
if (options.toolDetails && part.state.status === "error" && part.state.error) {
result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`\n`
Expand Down
Loading
Loading