diff --git a/SECURITY.md b/SECURITY.md index e7e59f4a27ac..b8738e7db7da 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,11 +12,71 @@ 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 @@ -24,13 +84,13 @@ Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to requ ### 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 | --- diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 8a2fbf87f06f..058d3d2f2468 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -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", diff --git a/packages/app/src/pages/session/composer/session-permission-dock.tsx b/packages/app/src/pages/session/composer/session-permission-dock.tsx index 06ff4f4aa715..f5f6c737ca06 100644 --- a/packages/app/src/pages/session/composer/session-permission-dock.tsx +++ b/packages/app/src/pages/session/composer/session-permission-dock.tsx @@ -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[0]) if (value === key) return "" return value diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 355718b6bf39..b7e10b96ca08 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -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" @@ -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 = { build: { @@ -116,6 +125,7 @@ export const layer = Layer.effect( question: "allow", plan_enter: "allow", }), + overlay, user, ), mode: "primary", @@ -152,6 +162,7 @@ export const layer = Layer.effect( Permission.fromConfig({ todowrite: "deny", }), + overlay, user, ), options: {}, @@ -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, } diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0874beee16c8..08d21f56e9be 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -184,7 +184,13 @@ function skill(info: ToolProps) { } function bash(info: ToolProps) { - 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(/[\s\S]*?(?:<\/bash_metadata>|$)/g, "") + .replace(/[\s\S]*?(?:<\/metadata>|$)/g, "") + .trim() + } block( { icon: "$", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 06be5dfbefbf..53d511284054 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -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" @@ -1766,7 +1767,14 @@ function Bash(props: ToolProps) { 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(/[\s\S]*?(?:<\/bash_metadata>|$)/g, "") + .replace(/[\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) @@ -1800,6 +1808,8 @@ function Bash(props: ToolProps) { return `# ${desc} in ${wd}` }) + const command = createMemo(() => SandboxSpawn.directive(props.input.command ?? "").command) + return ( @@ -1810,7 +1820,7 @@ function Bash(props: ToolProps) { onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined} > - $ {props.input.command} + $ {command()} {limited()} @@ -1821,8 +1831,8 @@ function Bash(props: ToolProps) { - - {props.input.command} + + {command()} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 54cc86a40d0a..e8bc44445d9b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -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" @@ -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, @@ -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: ( + + + {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."} + + + {detail} + + + {"$ " + command} + + + ), + } + } + if (permission === "task") { const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown" const desc = typeof data.description === "string" ? data.description : "" diff --git a/packages/opencode/src/cli/cmd/tui/util/transcript.ts b/packages/opencode/src/cli/cmd/tui/util/transcript.ts index 8fa0bc426ef4..2c170c3b4728 100644 --- a/packages/opencode/src/cli/cmd/tui/util/transcript.ts +++ b/packages/opencode/src/cli/cmd/tui/util/transcript.ts @@ -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(/[\s\S]*?(?:<\/bash_metadata>|$)/g, "") + .replace(/[\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` diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 55684fc70dfb..f1d00cec99fc 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -42,6 +42,8 @@ import { ConfigServer } from "./server" import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" import { Npm } from "@/npm" +import { SandboxPreset } from "@/sandbox/preset" +import { makeRuntime } from "@/effect/run-service" const log = Log.create({ service: "config" }) @@ -80,6 +82,114 @@ export const Server = ConfigServer.Server.zod export const Layout = ConfigLayout.Layout.zod export type Layout = ConfigLayout.Layout +const SandboxPresetConfig = Schema.Struct({ + mode: Schema.optional(Schema.Literals(["workspace-write", "read-only"])), + network: Schema.optional(Schema.Boolean), + protected_roots: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + extra_read_roots: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + extra_write_roots: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + permission: Schema.optional(Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })), +}) + +const SandboxConfig = Schema.Struct({ + enabled: Schema.optional(Schema.Boolean).annotate({ + description: "Enable macOS sandboxing for bash, session shell commands, and PTY initial spawns", + }), + preset: Schema.optional(Schema.String).annotate({ + description: "Named sandbox preset (default, strict, network, or a custom preset)", + }), + mode: Schema.optional(Schema.Literals(["workspace-write", "read-only"])).annotate({ + description: "Sandbox mode for command execution (default: preset default, otherwise workspace-write)", + }), + network: Schema.optional(Schema.Boolean).annotate({ + description: "Allow outbound network access inside the macOS sandbox", + }), + protected_roots: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ + description: "Workspace-relative paths that remain write-protected inside writable roots", + }), + extra_read_roots: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ + description: "Additional read-only roots for macOS sandboxing", + }), + extra_write_roots: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ + description: "Additional writable roots for macOS sandboxing", + }), + extra_deny_paths: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ + description: "Additional denied paths for macOS sandboxing", + }), + excluded_commands: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ + description: "Command prefixes that must be blocked before execution", + }), + allow_unsandboxed_retry: Schema.optional(Schema.Boolean).annotate({ + description: "Allow an explicit unsandboxed retry after a sandbox denial", + }), + fail_if_unavailable: Schema.optional(Schema.Boolean).annotate({ + description: "Hard-fail when sandboxing is enabled but cannot activate", + }), + presets: Schema.optional(Schema.Record(Schema.String, SandboxPresetConfig)), +}).annotate({ + [ZodOverride]: z + .object({ + enabled: z + .boolean() + .optional() + .describe("Enable macOS sandboxing for bash, session shell commands, and PTY initial spawns"), + preset: z.string().optional().describe("Named sandbox preset (default, strict, network, or a custom preset)"), + mode: z + .enum(["workspace-write", "read-only"]) + .optional() + .describe("Sandbox mode for command execution (default: preset default, otherwise workspace-write)"), + network: z.boolean().optional().describe("Allow outbound network access inside the macOS sandbox"), + protected_roots: z + .array(z.string()) + .optional() + .describe("Workspace-relative paths that remain write-protected inside writable roots"), + extra_read_roots: z.array(z.string()).optional().describe("Additional read-only roots for macOS sandboxing"), + extra_write_roots: z.array(z.string()).optional().describe("Additional writable roots for macOS sandboxing"), + extra_deny_paths: z.array(z.string()).optional().describe("Additional denied paths for macOS sandboxing"), + excluded_commands: z + .array(z.string()) + .optional() + .describe("Command prefixes that must be blocked before execution"), + allow_unsandboxed_retry: z + .boolean() + .optional() + .describe("Allow an explicit unsandboxed retry after a sandbox denial"), + fail_if_unavailable: z.boolean().optional().describe("Hard-fail when sandboxing is enabled but cannot activate"), + presets: z + .record( + z.string(), + z.object({ + mode: z.enum(["workspace-write", "read-only"]).optional(), + network: z.boolean().optional(), + protected_roots: z.array(z.string()).optional(), + extra_read_roots: z.array(z.string()).optional(), + extra_write_roots: z.array(z.string()).optional(), + permission: ConfigPermission.Info.optional(), + }), + ) + .optional(), + }) + .superRefine((value, ctx) => { + const builtins = new Set(SandboxPreset.names()) + for (const key of Object.keys(value.presets ?? {})) { + if (!builtins.has(key)) continue + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["presets", key], + message: `Custom sandbox preset "${key}" cannot shadow built-in preset "${key}"`, + }) + } + if (!value.preset) return + if (builtins.has(value.preset)) return + if (Object.hasOwn(value.presets ?? {}, value.preset)) return + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["preset"], + message: `Unknown sandbox preset "${value.preset}"`, + }) + }), +}) + // Schemas that still live at the zod layer (have .transform / .preprocess / // .meta not expressible in current Effect Schema) get referenced via a // ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the @@ -220,6 +330,7 @@ const InfoSchema = Schema.Struct({ Schema.Struct({ disable_paste_summary: Schema.optional(Schema.Boolean), batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }), + sandbox: Schema.optional(SandboxConfig), openTelemetry: Schema.optional(Schema.Boolean).annotate({ description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)", }), @@ -258,7 +369,7 @@ type DeepMutable = T extends readonly [unknown, ...unknown[]] // The walker emits `z.object({...})` which is non-strict by default. Config // historically uses `.strict()` (additionalProperties: false in openapi.json), -// so layer that on after derivation. Re-apply the Config ref afterward +// so layer that on after derivation. Re-apply the Config ref afterward // since `.strict()` strips the walker's meta annotation. export const Info = (zod(InfoSchema) as unknown as z.ZodObject) .strict() @@ -788,3 +899,37 @@ export const defaultLayer = layer.pipe( Layer.provide(Account.defaultLayer), Layer.provide(Npm.defaultLayer), ) + +const { runPromise } = makeRuntime(Service, defaultLayer) + +export async function get() { + return runPromise((svc) => svc.get()) +} + +export async function getGlobal() { + return runPromise((svc) => svc.getGlobal()) +} + +export async function getConsoleState() { + return runPromise((svc) => svc.getConsoleState()) +} + +export async function update(config: Info) { + return runPromise((svc) => svc.update(config)) +} + +export async function updateGlobal(config: Info) { + return runPromise((svc) => svc.updateGlobal(config)) +} + +export async function invalidate(wait = false) { + return runPromise((svc) => svc.invalidate(wait)) +} + +export async function directories() { + return runPromise((svc) => svc.directories()) +} + +export async function waitForDependencies() { + return runPromise((svc) => svc.waitForDependencies()) +} diff --git a/packages/opencode/src/file/protected.ts b/packages/opencode/src/file/protected.ts index a316e790b8c4..ff0edf28e878 100644 --- a/packages/opencode/src/file/protected.ts +++ b/packages/opencode/src/file/protected.ts @@ -1,5 +1,6 @@ import path from "path" import os from "os" +import { Filesystem } from "@/util" const home = os.homedir() @@ -56,4 +57,33 @@ export function paths(): string[] { return [] } +export function workspace() { + return [".git", ".opencode"] +} + +function uniq(input: string[]) { + return [...new Set(input.filter(Boolean))].toSorted((a, b) => a.localeCompare(b)) +} + +async function gitdir(file: string) { + const text = await Filesystem.readText(file).catch(() => "") + const match = /^\s*gitdir:\s*(.+)\s*$/m.exec(text) + if (!match?.[1]) return + return path.resolve(path.dirname(file), match[1]) +} + +export async function resolve(root: string, input = workspace()) { + const out: string[] = [] + for (const item of input) { + const next = path.isAbsolute(item) ? path.normalize(item) : path.resolve(root, item) + out.push(next) + if (path.basename(next) !== ".git") continue + const stat = Filesystem.stat(next) + if (!stat?.isFile()) continue + const dir = await gitdir(next) + if (dir) out.push(dir) + } + return uniq(out) +} + export * as Protected from "./protected" diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 72c8931f5b71..6479c12c8b36 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -104,4 +104,7 @@ export const Flag = { get OPENCODE_CLIENT() { return process.env["OPENCODE_CLIENT"] ?? "cli" }, + get OPENCODE_EXPERIMENTAL_SANDBOX() { + return truthy("OPENCODE_EXPERIMENTAL_SANDBOX") + }, } diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index aa519f9f7e79..9011d5f3a5a2 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -190,7 +190,7 @@ export const layer = Layer.effect( root: existing?.root ?? (async (_file, ctx) => ctx.directory), extensions: item.extensions ?? existing?.extensions ?? [], spawn: async (root) => ({ - process: lspspawn(item.command[0], item.command.slice(1), { + process: await lspspawn(item.command[0], item.command.slice(1), { cwd: root, env: { ...process.env, ...item.env }, }), diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 3d00de596a89..47979631229c 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -1,3 +1,4 @@ +import path from "path" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { InstanceState } from "@/effect" @@ -8,6 +9,7 @@ import { Log } from "../util" import { lazy } from "@opencode-ai/shared/util/lazy" import { Shell } from "@/shell/shell" import { Plugin } from "@/plugin" +import { SandboxSpawn } from "@/sandbox/spawn" import { PtyID } from "./schema" import { Effect, Layer, Context } from "effect" import { EffectBridge } from "@/effect" @@ -53,6 +55,17 @@ const meta = (cursor: number) => { const pty = lazy(() => import("#pty")) +function argv(command: string, args: string[], clean: boolean) { + if (args.length > 0) return args + const name = ( + process.platform === "win32" ? path.win32.basename(command, ".exe") : path.basename(command) + ).toLowerCase() + if (name === "zsh") return clean ? ["-f"] : ["-l"] + if (name === "bash") return clean ? ["--noprofile", "--norc"] : ["-l"] + if (name.endsWith("sh")) return clean ? [] : ["-l"] + return args +} + export const Info = z .object({ id: PtyID.zod, @@ -175,12 +188,21 @@ export const layer = Layer.effect( const bridge = yield* EffectBridge.make() const id = PtyID.ascending() const command = input.command || Shell.preferred() - const args = input.args || [] - if (Shell.login(command)) { - args.push("-l") - } - const cwd = input.cwd || s.dir + const cfg = yield* Effect.promise(Instance.bind(() => SandboxSpawn.settings())) + const blocked = SandboxSpawn.excluded([command, ...(input.args ?? [])], cfg.excluded_commands) + if (blocked) { + throw new SandboxSpawn.CommandError(blocked.command, blocked.rule) + } + const root = Instance.worktree === "/" ? Instance.directory : Instance.worktree + const sandbox = yield* Effect.promise(() => + SandboxSpawn.resolve({ + cwd, + project_root: Instance.directory, + worktree_root: root, + }), + ) + const args = argv(command, [...(input.args ?? [])], sandbox.active) const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} }) const env = { ...process.env, @@ -197,9 +219,17 @@ export const layer = Layer.effect( } log.info("creating session", { id, cmd: command, args, cwd }) + const cmd = + sandbox.active && sandbox.profile + ? SandboxSpawn.wrap({ + profile: sandbox.profile, + file: command, + args, + }) + : { file: command, args } const { spawn } = yield* Effect.promise(() => pty()) const proc = yield* Effect.sync(() => - spawn(command, args, { + spawn(cmd.file, cmd.args, { name: "xterm-256color", cwd, env, diff --git a/packages/opencode/src/sandbox/policy.ts b/packages/opencode/src/sandbox/policy.ts new file mode 100644 index 000000000000..636d5027da2e --- /dev/null +++ b/packages/opencode/src/sandbox/policy.ts @@ -0,0 +1,106 @@ +import path from "path" + +export namespace SandboxPolicy { + export type Mode = "workspace-write" | "read-only" + + export interface Input { + cwd: string + project_root: string + worktree_root: string + home: string + mode?: Mode + protected_roots?: string[] + extra_read_roots?: string[] + extra_write_roots?: string[] + extra_deny_paths?: string[] + opencode_roots?: string[] + allow_network?: boolean + } + + export interface Output { + profile: string + read: string[] + write: string[] + deny: string[] + } + + const read = [ + "/bin", + "/sbin", + "/usr", + "/opt/homebrew", + "/System", + "/Library", + "/dev", + "/tmp", + "/private/tmp", + "/private/etc", + ] + const temp = ["/tmp", "/private/tmp"] + const secret = [".ssh", ".gnupg", ".aws", ".azure", path.join(".config", "gcloud"), ".netrc", ".npmrc"] + + function uniq(input: string[]) { + return [...new Set(input.filter(Boolean))].toSorted((a, b) => a.localeCompare(b)) + } + + function quote(input: string) { + return input.replaceAll("\\", "\\\\").replaceAll('"', '\\"') + } + + function allow(action: string, roots: string[]) { + if (roots.length === 0) return [] + return [`(allow ${action}`, ...roots.map((item) => ` (subpath "${quote(item)}")`), ")"] + } + + function deny(roots: string[]) { + return roots.flatMap((item) => [ + `(deny file-read* (subpath "${quote(item)}"))`, + `(deny file-write* (subpath "${quote(item)}"))`, + ]) + } + + function denyWrite(roots: string[]) { + return roots.map((item) => `(deny file-write* (subpath "${quote(item)}"))`) + } + + export function build(input: Input): Output { + const denyRoots = uniq([ + ...secret.map((item) => path.join(input.home, item)), + ...(input.opencode_roots ?? []), + ...(input.extra_deny_paths ?? []), + ]) + const protectedRoots = uniq(input.protected_roots ?? []) + const readRoots = uniq([ + input.cwd, + input.project_root, + input.worktree_root, + ...read, + ...(input.extra_read_roots ?? []), + ]) + const writeRoots = + input.mode === "read-only" + ? uniq([...temp, ...(input.extra_write_roots ?? [])]) + : uniq([input.cwd, input.project_root, input.worktree_root, ...(input.extra_write_roots ?? [])]) + const profile = [ + "(version 1)", + "(deny default)", + '(import "system.sb")', + "(allow process-exec)", + "(allow process-fork)", + "(allow signal (target same-sandbox))", + "(allow process-info* (target same-sandbox))", + '(allow file-write-data (require-all (path "/dev/null") (vnode-type CHARACTER-DEVICE)))', + ...allow("file-read*", readRoots), + ...allow("file-write*", writeRoots), + ...deny(denyRoots), + ...denyWrite(protectedRoots), + ...(input.allow_network ? ["(allow network*)"] : []), + ].join("\n") + return { + profile, + read: readRoots, + write: writeRoots, + deny: denyRoots, + } + } +} diff --git a/packages/opencode/src/sandbox/preset.ts b/packages/opencode/src/sandbox/preset.ts new file mode 100644 index 000000000000..80c90f45f3d3 --- /dev/null +++ b/packages/opencode/src/sandbox/preset.ts @@ -0,0 +1,108 @@ +import { Protected } from "@/file/protected" +import { SandboxPolicy } from "./policy" + +export namespace SandboxPreset { + export type Action = "ask" | "allow" | "deny" + + export type Permission = Record> + + export interface Def { + mode: SandboxPolicy.Mode + network: boolean + protected_roots: string[] + permission: Permission + extra_read_roots: string[] + extra_write_roots: string[] + } + + export interface PartialDef { + mode?: SandboxPolicy.Mode + network?: boolean + protected_roots?: string[] + permission?: Permission + extra_read_roots?: string[] + extra_write_roots?: string[] + } + + export interface Input extends PartialDef { + preset?: string + presets?: Record + } + + const make = (input: { + mode?: SandboxPolicy.Mode + network?: boolean + protected_roots?: string[] + permission?: Permission + extra_read_roots?: string[] + extra_write_roots?: string[] + }): Def => ({ + mode: input.mode ?? "workspace-write", + network: input.network ?? false, + protected_roots: [...(input.protected_roots ?? Protected.workspace())], + permission: { ...(input.permission ?? {}) }, + extra_read_roots: [...(input.extra_read_roots ?? [])], + extra_write_roots: [...(input.extra_write_roots ?? [])], + }) + + const builtin: Record = { + default: make({ + mode: "workspace-write", + network: false, + }), + strict: make({ + mode: "read-only", + network: false, + permission: { + bash: "ask", + edit: "ask", + }, + }), + network: make({ + mode: "workspace-write", + network: true, + }), + } + + export function names() { + return Object.keys(builtin) + } + + export function builtins(): Record { + return Object.fromEntries(Object.entries(builtin).map(([key, value]) => [key, make(value)])) + } + + function merge(base: Def, overrides?: PartialDef): Def { + if (!overrides) return make(base) + return { + mode: overrides.mode ?? base.mode, + network: overrides.network ?? base.network, + protected_roots: overrides.protected_roots ? [...overrides.protected_roots] : [...base.protected_roots], + permission: overrides.permission ? { ...overrides.permission } : { ...base.permission }, + extra_read_roots: overrides.extra_read_roots ? [...overrides.extra_read_roots] : [...base.extra_read_roots], + extra_write_roots: overrides.extra_write_roots ? [...overrides.extra_write_roots] : [...base.extra_write_roots], + } + } + + export function resolve(name: string, input?: { presets?: Record; overrides?: PartialDef }) { + const base = builtin[name] ?? (input?.presets ? input.presets[name] : undefined) + if (!base) throw new Error(`Unknown sandbox preset "${name}"`) + return merge(make(base), input?.overrides) + } + + export function active(input?: Input) { + return resolve(input?.preset ?? "default", { + presets: input?.presets, + overrides: input + ? { + mode: input.mode, + network: input.network, + protected_roots: input.protected_roots, + permission: input.permission, + extra_read_roots: input.extra_read_roots, + extra_write_roots: input.extra_write_roots, + } + : undefined, + }) + } +} diff --git a/packages/opencode/src/sandbox/spawn.ts b/packages/opencode/src/sandbox/spawn.ts new file mode 100644 index 000000000000..fa56904fb1aa --- /dev/null +++ b/packages/opencode/src/sandbox/spawn.ts @@ -0,0 +1,449 @@ +import { Config } from "@/config" +import { Protected } from "@/file/protected" +import { Flag } from "@/flag/flag" +import { Global } from "@/global" +import { BashArity } from "@/permission/arity" +import { Filesystem, Log } from "@/util" +import os from "os" +import path from "path" +import { SandboxPolicy } from "./policy" +import { SandboxPreset } from "./preset" + +const log = Log.create({ service: "sandbox" }) +const bin = "/usr/bin/sandbox-exec" + +export namespace SandboxSpawn { + export type Mode = SandboxPolicy.Mode + export type RetryReason = "sandbox_denial" | "possible_network_sandbox_denial" + export type UnsandboxedReason = RetryReason | "explicit_request" + + export interface Directive { + command: string + detail?: string + } + + export interface Diag { + requested: boolean + active: boolean + reason: "disabled" | "unsupported_platform" | "sandbox_exec_missing" | "unsafe_root" | "enabled" + wrapper: string + cwd: string + mode: Mode + read_roots: string[] + write_roots: string[] + unsafe_roots: string[] + allow_network: boolean + } + + export interface Settings { + requested: boolean + preset?: string + mode?: Mode + network?: boolean + protected_roots?: string[] + presets: Record + extra_read_roots?: string[] + extra_write_roots?: string[] + extra_deny_paths: string[] + excluded_commands: string[] + allow_unsandboxed_retry: boolean + fail_if_unavailable: boolean + } + + export interface ResolveInput { + cwd: string + project_root: string + worktree_root: string + preset?: string + mode?: Mode + allow_network?: boolean + } + + export interface PlanInput extends ResolveInput { + requested: boolean + platform: NodeJS.Platform + available: boolean + home: string + mode?: Mode + fail_if_unavailable?: boolean + protected_roots?: string[] + opencode_roots?: string[] + extra_read_roots?: string[] + extra_write_roots?: string[] + extra_deny_paths?: string[] + } + + export interface Output { + active: boolean + profile?: string + diag: Diag + } + + export interface WrapInput { + profile: string + file: string + args: string[] + } + + export class Error extends globalThis.Error { + readonly diag: Diag + + constructor(diag: Diag) { + super(`macOS sandbox is enabled but unavailable: ${diag.reason}`) + this.name = "SandboxSpawnError" + this.diag = diag + } + } + + export class CommandError extends globalThis.Error { + readonly command: string + readonly rule: string + + constructor(command: string, rule: string) { + super(`Command \"${command}\" is blocked by excluded_commands entry \"${rule}\"`) + this.name = "SandboxCommandError" + this.command = command + this.rule = rule + } + } + + export interface Match { + command: string + rule: string + } + + function uniq(input: string[]) { + return [...new Set(input.filter(Boolean))].toSorted((a, b) => a.localeCompare(b)) + } + + function name(input: string) { + return process.platform === "win32" ? path.win32.basename(input, ".exe") : path.basename(input) + } + + function parts(input: string[]) { + if (input.length === 0) return [] + const head = name(input[0]) || input[0] + return [head, ...input.slice(1)] + } + + function prefix(input: string[]) { + return BashArity.prefix(parts(input)).join(" ") + } + + function trim(input: string) { + return input.replace(/^['"]|['"]$/g, "") + } + + function assign(input: string) { + return /^[A-Za-z_][A-Za-z0-9_]*=/.test(input) + } + + function shell(input: string) { + const out: string[][] = [] + let next: string[] = [] + for (const item of input.match( + /&&|\|\||(?])&(?![0-9])|[|;\n]|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s|;&\n]+/g, + ) ?? []) { + if (["&&", "||", "|", ";", "&", "\n"].includes(item)) { + if (next.length > 0) out.push(next) + next = [] + continue + } + next.push(trim(item)) + } + if (next.length > 0) out.push(next) + return out + } + + export function directive(input: string): Directive { + const lines = input.split("\n") + const idx = lines.findIndex((item) => item.trim().length > 0) + if (idx < 0) return { command: input } + const line = lines[idx] + const match = line && /^\s*#\s*opencode:\s*unsandboxed(?:\s+(.*))?\s*$/.exec(line) + if (!match) return { command: input } + return { + command: lines.filter((_, i) => i !== idx).join("\n"), + detail: match[1]?.trim() || undefined, + } + } + + function list(input: string[]): string[][] { + const next = [...input] + while (assign(next[0] ?? "")) next.shift() + if (next.length === 0) return [] + + const head = name(next[0]).toLowerCase() + if (head === "env") { + const rest = next.slice(1) + while (rest[0]?.startsWith("-")) rest.shift() + while (assign(rest[0] ?? "")) rest.shift() + return list(rest) + } + + if (["sh", "bash", "zsh", "fish", "nu"].includes(head)) { + const idx = next.findIndex((item) => item === "-c" || item === "/c" || item === "-Command") + if (idx >= 0 && next[idx + 1]) { + return shell(next[idx + 1]).flatMap(list) + } + } + + return [next] + } + + function scan(input: string[], home: string) { + return uniq(input).reduce( + (acc, item) => { + if (item === "/") { + acc.bad.push(item) + return acc + } + if (item === home || Filesystem.contains(item, home)) { + acc.bad.push(item) + return acc + } + acc.good.push(item) + return acc + }, + { good: [] as string[], bad: [] as string[] }, + ) + } + + function base(input: PlanInput, reason: Diag["reason"]) { + return { + requested: input.requested, + active: false, + reason, + wrapper: bin, + cwd: input.cwd, + mode: input.mode ?? "workspace-write", + read_roots: [], + write_roots: [], + unsafe_roots: [], + allow_network: input.allow_network === true, + } satisfies Diag + } + + export function settings(): Promise { + return Config.get().then((cfg) => { + const env = process.env["OPENCODE_EXPERIMENTAL_SANDBOX"] + const raw = cfg.experimental?.sandbox + return { + requested: env === undefined ? raw?.enabled === true : Flag.OPENCODE_EXPERIMENTAL_SANDBOX, + preset: raw?.preset, + mode: raw?.mode, + network: raw?.network, + protected_roots: raw?.protected_roots, + presets: raw?.presets ?? {}, + extra_read_roots: raw?.extra_read_roots, + extra_write_roots: raw?.extra_write_roots, + extra_deny_paths: raw?.extra_deny_paths ?? [], + excluded_commands: raw?.excluded_commands ?? [], + allow_unsandboxed_retry: raw?.allow_unsandboxed_retry === true, + fail_if_unavailable: raw?.fail_if_unavailable === true, + } satisfies Settings + }) + } + + export function excluded(input: string[], blocked: string[]): Match | undefined { + for (const candidate of list(input)) { + const command = prefix(candidate) + if (!command) continue + for (const item of blocked) { + const rule = prefix(item.trim().split(/\s+/).filter(Boolean)) + if (!rule) continue + if (command === rule || command.startsWith(`${rule} `)) { + return { command, rule } + } + } + } + } + + export function excludedText(input: string, blocked: string[]) { + for (const item of shell(input)) { + const match = excluded(item, blocked) + if (match) return match + } + } + + function usesText(input: string, target: string) { + return shell(input) + .flatMap(list) + .some((item) => name(item[0]).toLowerCase() === target) + } + + export function retryReason(input: { + active: boolean + code: number + stderr: string + allow_network?: boolean + command?: string + }): RetryReason | undefined { + if (!input.active || input.code === 0) return + if (input.stderr.includes("sandbox-exec: sandbox_apply: Operation not permitted")) return "sandbox_denial" + if (input.stderr.includes("sandbox-exec: execvp()")) return "sandbox_denial" + if (input.stderr.includes("forbidden-sandbox-reinit")) return "sandbox_denial" + if (input.stderr.includes("Sandbox:") && input.stderr.includes("deny(1)")) return "sandbox_denial" + if (input.stderr.includes("Operation not permitted")) return "sandbox_denial" + if ( + input.allow_network === false && + input.command && + usesText(input.command, "curl") && + ((input.code === 6 && input.stderr.includes("Could not resolve host")) || + (input.code === 7 && + ["Failed to connect", "Couldn't connect", "Could not connect"].some((item) => input.stderr.includes(item)))) + ) { + return "possible_network_sandbox_denial" + } + } + + export function shouldRetry(input: { + active: boolean + code: number + stderr: string + allow_network?: boolean + command?: string + }) { + return Boolean(retryReason(input)) + } + + export function unwrap(input: { file: string; args: string[] }) { + if (input.file !== bin) return input + if (input.args[0] !== "-p") return input + const file = input.args[2] + if (!file) return input + return { + file, + args: input.args.slice(3), + } + } + + export function plan(input: PlanInput): Output { + if (!input.requested) { + return { active: false, diag: base(input, "disabled") } + } + + if (input.platform !== "darwin") { + const diag = base(input, "unsupported_platform") + if (input.fail_if_unavailable) throw new Error(diag) + return { active: false, diag } + } + + if (!input.available) { + const diag = base(input, "sandbox_exec_missing") + if (input.fail_if_unavailable) throw new Error(diag) + return { active: false, diag } + } + + const read = scan( + [...(input.extra_read_roots ?? []), input.cwd, input.project_root, input.worktree_root], + input.home, + ) + const write = scan( + input.mode === "read-only" + ? [...(input.extra_write_roots ?? [])] + : [...(input.extra_write_roots ?? []), input.cwd, input.project_root, input.worktree_root], + input.home, + ) + const bad = uniq([...read.bad, ...write.bad]) + + if (bad.length > 0) { + throw new Error({ + ...base(input, "unsafe_root"), + unsafe_roots: bad, + }) + } + + const policy = SandboxPolicy.build({ + cwd: input.cwd, + project_root: input.project_root, + worktree_root: input.worktree_root, + home: input.home, + extra_read_roots: read.good, + extra_write_roots: write.good, + extra_deny_paths: input.extra_deny_paths, + protected_roots: input.protected_roots, + opencode_roots: input.opencode_roots, + mode: input.mode, + allow_network: input.allow_network, + }) + + const diag = { + requested: true, + active: true, + reason: "enabled", + wrapper: bin, + cwd: input.cwd, + mode: input.mode ?? "workspace-write", + read_roots: policy.read, + write_roots: policy.write, + unsafe_roots: [], + allow_network: input.allow_network === true, + } satisfies Diag + + return { + active: true, + profile: policy.profile, + diag, + } + } + + export function wrap(input: WrapInput) { + return { + file: bin, + args: ["-p", input.profile, input.file, ...input.args], + } + } + + export async function resolve(input: ResolveInput, cfg?: Settings): Promise { + const raw = cfg ?? (await settings()) + const preset = + raw.requested || raw.preset || input.preset + ? SandboxPreset.active({ + preset: input.preset ?? raw.preset, + presets: raw.presets, + mode: input.mode ?? raw.mode, + network: input.allow_network ?? raw.network, + protected_roots: raw.protected_roots, + extra_read_roots: raw.extra_read_roots, + extra_write_roots: raw.extra_write_roots, + }) + : undefined + const home = Filesystem.resolve(Global.Path.home) + const tmp = Filesystem.resolve(os.tmpdir()) + const temp = Filesystem.contains(tmp, home) ? [] : [tmp] + const mode = preset?.mode ?? input.mode ?? raw.mode ?? "workspace-write" + const allowNetwork = input.allow_network ?? preset?.network ?? raw.network ?? false + const readRoots = (preset?.extra_read_roots ?? raw.extra_read_roots ?? []).map(Filesystem.resolve) + const writeRoots = (preset?.extra_write_roots ?? raw.extra_write_roots ?? []).map(Filesystem.resolve) + const protectedRoots = await Protected.resolve( + Filesystem.resolve(input.worktree_root), + preset?.protected_roots ?? [], + ) + const out = plan({ + requested: raw.requested, + platform: process.platform, + available: Boolean(Filesystem.stat(bin)?.size), + cwd: Filesystem.resolve(input.cwd), + project_root: Filesystem.resolve(input.project_root), + worktree_root: Filesystem.resolve(input.worktree_root), + home, + mode, + fail_if_unavailable: raw.fail_if_unavailable, + protected_roots: protectedRoots, + opencode_roots: [Global.Path.data, Global.Path.config, Global.Path.state, Global.Path.cache].map( + Filesystem.resolve, + ), + extra_read_roots: [...readRoots, ...temp], + extra_write_roots: mode === "read-only" ? writeRoots : [...writeRoots, ...temp], + extra_deny_paths: raw.extra_deny_paths.map(Filesystem.resolve), + allow_network: allowNetwork, + }) + + if (out.active) log.debug("sandbox active", out.diag) + else if (out.diag.requested) log.info("sandbox inactive", out.diag) + else log.debug("sandbox disabled", out.diag) + + return out + } +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 431189d19cc0..cb0e3426da2f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -36,6 +36,7 @@ import { NamedError } from "@opencode-ai/shared/util/error" import { SessionProcessor } from "./processor" import { Tool } from "@/tool" import { Permission } from "@/permission" +import { Instance } from "@/project/instance" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" @@ -43,12 +44,14 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Truncate } from "@/tool" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util" -import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect" +import { Cause, Effect, Exit, Fiber, Layer, Option, Scope, Context } from "effect" import { EffectLogger } from "@/effect" import { InstanceState } from "@/effect" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect" +import { SandboxSpawn } from "@/sandbox/spawn" +import { commandFamilies } from "@/tool/bash" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -780,66 +783,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the } yield* sessions.updatePart(part) - const sh = Shell.preferred() - const shellName = ( - process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh) - ).toLowerCase() - const invocations: Record = { - nu: { args: ["-c", input.command] }, - fish: { args: ["-c", input.command] }, - zsh: { - args: [ - "-l", - "-c", - ` - __oc_cwd=$PWD - [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true - [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true - cd "$__oc_cwd" - eval ${JSON.stringify(input.command)} - `, - ], - }, - bash: { - args: [ - "-l", - "-c", - ` - __oc_cwd=$PWD - shopt -s expand_aliases - [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true - cd "$__oc_cwd" - eval ${JSON.stringify(input.command)} - `, - ], - }, - cmd: { args: ["/c", input.command] }, - powershell: { args: ["-NoProfile", "-Command", input.command] }, - pwsh: { args: ["-NoProfile", "-Command", input.command] }, - "": { args: ["-c", input.command] }, - } - - const args = (invocations[shellName] ?? invocations[""]).args - const cwd = ctx.directory - const shellEnv = yield* plugin.trigger( - "shell.env", - { cwd, sessionID: input.sessionID, callID: part.callID }, - { env: {} }, - ) - - const cmd = ChildProcess.make(sh, args, { - cwd, - extendEnv: true, - env: { ...shellEnv.env, TERM: "dumb" }, - stdin: "ignore", - forceKillAfter: "3 seconds", - }) - let output = "" let aborted = false + let done = false const finish = Effect.uninterruptible( Effect.gen(function* () { + if (done) return + done = true if (aborted) { output += "\n\n" + ["", "User aborted the command", ""].join("\n") } @@ -861,35 +812,270 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ) - const exit = yield* Effect.gen(function* () { - const handle = yield* spawner.spawn(cmd) - yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) => - Effect.sync(() => { - output += chunk - if (part.state.status === "running") { - part.state.metadata = { output, description: "" } - void run.fork(sessions.updatePart(part)) + return yield* Effect.gen(function* () { + try { + const cfg = yield* Effect.promise(Instance.bind(() => SandboxSpawn.settings())) + const blocked = SandboxSpawn.excludedText(input.command, cfg.excluded_commands) + if (blocked) { + throw new SandboxSpawn.CommandError(blocked.command, blocked.rule) + } + + const sh = Shell.preferred() + const shellName = ( + process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh) + ).toLowerCase() + const request = SandboxSpawn.directive(input.command) + const command = request.command + const invocations: Record = { + nu: { args: ["-c", command] }, + fish: { args: ["-c", command] }, + zsh: { + args: [ + "-l", + "-c", + ` + __oc_cwd=$PWD + [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true + [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true + cd "$__oc_cwd" + eval ${JSON.stringify(command)} + `, + ], + }, + bash: { + args: [ + "-l", + "-c", + ` + __oc_cwd=$PWD + shopt -s expand_aliases + [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true + cd "$__oc_cwd" + eval ${JSON.stringify(command)} + `, + ], + }, + cmd: { args: ["/c", command] }, + powershell: { args: ["-NoProfile", "-Command", command] }, + pwsh: { args: ["-NoProfile", "-Command", command] }, + "": { args: ["-c", command] }, + } + const clean: Record = { + nu: { args: ["-c", command] }, + fish: { args: ["-c", command] }, + zsh: { args: ["-f", "-c", command] }, + bash: { args: ["--noprofile", "--norc", "-c", command] }, + cmd: { args: ["/c", command] }, + powershell: { args: ["-NoProfile", "-Command", command] }, + pwsh: { args: ["-NoProfile", "-Command", command] }, + "": { args: ["-c", command] }, + } + + const cwd = ctx.directory + const shellEnv = yield* plugin.trigger( + "shell.env", + { cwd, sessionID: input.sessionID, callID: part.callID }, + { env: {} }, + ) + const root = ctx.worktree === "/" ? ctx.directory : ctx.worktree + const sandbox = yield* Effect.promise(() => + SandboxSpawn.resolve({ + cwd, + project_root: ctx.directory, + worktree_root: root, + }), + ) + const cleanArgs = clean[shellName]?.args ?? clean[""]?.args ?? ["-c", input.command] + const rawArgs = invocations[shellName]?.args ?? invocations[""].args + const raw = { file: sh, args: sandbox.active ? cleanArgs : rawArgs } + const call = + sandbox.active && sandbox.profile + ? SandboxSpawn.wrap({ profile: sandbox.profile, file: sh, args: cleanArgs }) + : raw + const env = { ...shellEnv.env, TERM: "dumb" } + + const exec = Effect.fnUntraced(function* (call: { file: string; args: string[] }) { + let stderr = "" + const proc = ChildProcess.make(call.file, call.args, { + cwd, + extendEnv: true, + env, + stdin: "ignore", + forceKillAfter: "3 seconds", + }) + const exit = yield* Effect.gen(function* () { + const handle = yield* spawner.spawn(proc) + const stdout = yield* Effect.forkScoped( + Stream.runForEach(Stream.decodeText(handle.stdout), (chunk) => + Effect.sync(() => { + output += chunk + if (part.state.status === "running") { + part.state.metadata = { output, description: "" } + void run.fork(sessions.updatePart(part)) + } + }), + ), + ) + const err = yield* Effect.forkScoped( + Stream.runForEach(Stream.decodeText(handle.stderr), (chunk) => + Effect.sync(() => { + stderr += chunk + output += chunk + if (part.state.status === "running") { + part.state.metadata = { output, description: "" } + void run.fork(sessions.updatePart(part)) + } + }), + ), + ) + const code = yield* handle.exitCode + yield* Fiber.await(stdout) + yield* Fiber.await(err) + return code + }).pipe( + Effect.scoped, + Effect.onInterrupt(() => + Effect.sync(() => { + aborted = true + }), + ), + Effect.exit, + ) + + if (Exit.isFailure(exit)) { + if (Cause.hasInterruptsOnly(exit.cause)) return { code: 1, stderr } + return yield* Effect.failCause(exit.cause) } - }), - ) - yield* handle.exitCode - }).pipe( - Effect.scoped, - Effect.onInterrupt(() => - Effect.sync(() => { - aborted = true - }), - ), - Effect.orDie, - Effect.ensuring(finish), - Effect.exit, - ) - if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) { - return yield* Effect.failCause(exit.cause) - } + return { code: exit.value, stderr } + }) - return { info: msg, parts: [part] } + let proactive = false + let rejected = false + let asked = false + const unsandboxed = cfg.allow_unsandboxed_retry ? yield* Effect.promise(() => commandFamilies(command)) : [] + if (command !== input.command && cfg.allow_unsandboxed_retry && sandbox.active) { + asked = true + const exit = yield* permission + .ask({ + permission: "bash:unsandboxed", + patterns: unsandboxed, + always: unsandboxed, + metadata: { + reason: "explicit_request" satisfies SandboxSpawn.UnsandboxedReason, + detail: request.detail, + command, + }, + sessionID: input.sessionID, + tool: { + messageID: msg.id, + callID: part.callID, + }, + ruleset: Permission.merge(agent.permission, session.permission ?? []), + }) + .pipe(Effect.exit) + if (Exit.isSuccess(exit)) { + proactive = true + } else { + rejected = true + log.info("proactive unsandboxed request rejected", { + error: Cause.squash(exit.cause), + sessionID: input.sessionID, + }) + } + } + + let retried = false + let reason: SandboxSpawn.RetryReason | undefined + let result: { code: number; stderr: string } + const first = yield* exec(proactive ? raw : call).pipe(Effect.exit) + if (Exit.isFailure(first)) { + const error = Cause.squash(first.cause) + if (rejected && !proactive && sandbox.active) { + const message = error instanceof Error ? error.message : String(error) + throw new Error( + `Explicit unsandboxed request was rejected; sandboxed fallback failed before command start: ${message}`, + error instanceof Error ? { cause: error } : undefined, + ) + } + return yield* Effect.failCause(first.cause) + } + result = first.value + + if (!proactive) { + reason = SandboxSpawn.retryReason({ + active: sandbox.active, + code: result.code, + stderr: result.stderr, + allow_network: sandbox.diag.allow_network, + command, + }) + } + + if (cfg.allow_unsandboxed_retry && !asked && !aborted && reason) { + asked = true + const exit = yield* permission + .ask({ + permission: "bash:unsandboxed", + patterns: unsandboxed, + always: unsandboxed, + metadata: { + reason, + command, + }, + sessionID: input.sessionID, + tool: { + messageID: msg.id, + callID: part.callID, + }, + ruleset: Permission.merge(agent.permission, session.permission ?? []), + }) + .pipe(Effect.exit) + if (Exit.isSuccess(exit)) { + retried = true + output = "" + if (part.state.status === "running") { + part.state.metadata = { output: "", description: "" } + yield* sessions.updatePart(part) + } + result = yield* exec(raw) + } else { + log.info("unsandboxed retry rejected", { + error: Cause.squash(exit.cause), + sessionID: input.sessionID, + }) + } + } + + if (rejected) { + output += + "\n\n" + + ["", "Explicit unsandboxed request was rejected; command ran in sandbox", ""].join( + "\n", + ) + } + + if (retried) { + output += + "\n\n" + + [ + "", + reason === "possible_network_sandbox_denial" + ? "Retried command without sandbox after a possible network-related sandbox failure" + : "Retried command without sandbox after sandbox denial", + "", + ].join("\n") + } + + yield* finish + return { info: msg, parts: [part] } + } catch (error) { + output = error instanceof Error ? error.message : String(error) + log.error("session shell failed", { error, sessionID: input.sessionID }) + yield* finish + return { info: msg, parts: [part] } + } + }).pipe(Effect.onInterrupt(() => finish)) }) const getModel = Effect.fn("SessionPrompt.getModel")(function* ( @@ -1540,7 +1726,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the const shell: (input: ShellInput) => Effect.Effect = Effect.fn("SessionPrompt.shell")( function* (input: ShellInput) { - return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input)) + return yield* state.startShell( + input.sessionID, + lastAssistant(input.sessionID), + shellImpl(input).pipe(Effect.orDie), + ) }, ) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 6260b22216e2..3e3800a157dc 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -17,9 +17,10 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import * as Truncate from "./truncate" import { Plugin } from "@/plugin" -import { Effect, Stream } from "effect" +import { Cause, Effect, Exit, Fiber, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { SandboxSpawn } from "@/sandbox/spawn" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -84,6 +85,14 @@ type Chunk = { export const log = Log.create({ service: "bash-tool" }) +function args(shell: string, command: string) { + const name = (process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell)).toLowerCase() + if (name === "powershell" || name === "pwsh") return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command] + if (name === "zsh") return ["-f", "-c", command] + if (name === "bash") return ["--noprofile", "--norc", "-c", command] + return ["-c", command] +} + const resolveWasm = (asset: string) => { if (asset.startsWith("file://")) return fileURLToPath(asset) if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset @@ -281,18 +290,8 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) }) }) -function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { - if (process.platform === "win32" && PS.has(name)) { - return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { - cwd, - env, - stdin: "ignore", - detached: false, - }) - } - - return ChildProcess.make(command, [], { - shell, +function raw(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { + return ChildProcess.make(shell, args(shell, command), { cwd, env, stdin: "ignore", @@ -300,6 +299,69 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod }) } +async function cmd( + shell: string, + name: string, + command: string, + cwd: string, + env: NodeJS.ProcessEnv, + cfg: SandboxSpawn.Settings, +) { + const root = Instance.worktree === "/" ? Instance.directory : Instance.worktree + const sandbox = await SandboxSpawn.resolve( + { + cwd, + project_root: Instance.directory, + worktree_root: root, + }, + cfg, + ) + const plain = raw(shell, command, cwd, env) + + if (sandbox.active && sandbox.profile) { + const wrap = SandboxSpawn.wrap({ + profile: sandbox.profile, + file: shell, + args: args(shell, command), + }) + return { + proc: ChildProcess.make(wrap.file, wrap.args, { + cwd, + env, + stdin: "ignore", + detached: process.platform !== "win32", + }), + plain, + sandbox, + } + } + + if (process.platform === "win32" && PS.has(name)) { + return { + proc: ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { + cwd, + env, + stdin: "ignore", + detached: false, + }), + plain, + sandbox, + } + } + + return { + proc: ChildProcess.make(command, [], { + shell, + cwd, + env, + stdin: "ignore", + detached: process.platform !== "win32", + }), + plain, + sandbox, + } +} + const parser = lazy(async () => { const { Parser } = await import("web-tree-sitter") const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { @@ -327,6 +389,33 @@ const parser = lazy(async () => { return { bash, ps } }) +export async function commandFamilies(cmd: string): Promise { + const tree = await parser().then((p) => p.bash.parse(cmd)) + if (!tree) return [cmd] + const result = new Set() + for (const node of tree.rootNode.descendantsOfType("command")) { + if (!node) continue + const tokens: string[] = [] + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i) + if (!child) continue + if ( + child.type !== "command_name" && + child.type !== "word" && + child.type !== "string" && + child.type !== "raw_string" && + child.type !== "concatenation" + ) + continue + tokens.push(child.text) + } + if (tokens.length && tokens[0] !== "cd") { + result.add(BashArity.prefix(tokens).join(" ") + " *") + } + } + return result.size > 0 ? Array.from(result) : [cmd] +} + // TODO: we may wanna rename this tool so it works better on other shells export const BashTool = Tool.define( "bash", @@ -365,7 +454,13 @@ export const BashTool = Tool.define( return yield* resolvePath(next, cwd, shell) }) - const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) { + const collect = Effect.fn("BashTool.collect")(function* ( + root: Node, + cwd: string, + ps: boolean, + shell: string, + deny: string[], + ) { const scan: Scan = { dirs: new Set(), patterns: new Set(), @@ -375,6 +470,10 @@ export const BashTool = Tool.define( for (const node of commands(root)) { const command = parts(node) const tokens = command.map((item) => item.text) + const blocked = SandboxSpawn.excluded(tokens, deny) + if (blocked) { + throw new SandboxSpawn.CommandError(blocked.command, blocked.rule) + } const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] if (cmd && FILES.has(cmd)) { @@ -413,10 +512,13 @@ export const BashTool = Tool.define( shell: string name: string command: string + source: string + detail?: string cwd: string env: NodeJS.ProcessEnv timeout: number description: string + cfg: SandboxSpawn.Settings }, ctx: Tool.Context, ) { @@ -430,8 +532,62 @@ export const BashTool = Tool.define( let file = "" let sink: ReturnType | undefined let cut = false - let expired = false - let aborted = false + + const write = Effect.fnUntraced(function* (chunk: string) { + const size = Buffer.byteLength(chunk, "utf-8") + list.push({ text: chunk, size }) + used += size + while (used > keep && list.length > 1) { + const item = list.shift() + if (!item) break + used -= item.size + cut = true + } + + last = preview(last + chunk) + + if (file) { + sink?.write(chunk) + } else { + full += chunk + if (Buffer.byteLength(full, "utf-8") > bytes) { + file = yield* trunc.write(full) + cut = true + sink = createWriteStream(file, { flags: "a" }) + full = "" + } + } + + yield* ctx.metadata({ + metadata: { + output: last, + description: input.description, + }, + }) + }) + + const closeSink = Effect.fnUntraced(function* () { + if (!sink) return + const stream = sink + sink = undefined + yield* Effect.promise( + () => + new Promise((resolve) => { + stream.end(() => resolve()) + stream.on("error", () => resolve()) + }), + ) + }) + + const resetOutput = Effect.fnUntraced(function* () { + yield* closeSink() + full = "" + last = "" + list.length = 0 + used = 0 + file = "" + cut = false + }) yield* ctx.metadata({ metadata: { @@ -440,94 +596,170 @@ export const BashTool = Tool.define( }, }) - const code: number | null = yield* Effect.scoped( - Effect.gen(function* () { - const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env)) - - yield* Effect.forkScoped( - Stream.runForEach(Stream.decodeText(handle.all), (chunk) => { - const size = Buffer.byteLength(chunk, "utf-8") - list.push({ text: chunk, size }) - used += size - while (used > keep && list.length > 1) { - const item = list.shift() - if (!item) break - used -= item.size - cut = true - } + const launch = yield* Effect.promise(() => + cmd(input.shell, input.name, input.command, input.cwd, input.env, input.cfg), + ) - last = preview(last + chunk) - - if (file) { - sink?.write(chunk) - } else { - full += chunk - if (Buffer.byteLength(full, "utf-8") > bytes) { - return trunc.write(full).pipe( - Effect.andThen((next) => - Effect.sync(() => { - file = next - cut = true - sink = createWriteStream(next, { flags: "a" }) - full = "" - }), - ), - Effect.andThen( - ctx.metadata({ - metadata: { - output: last, - description: input.description, - }, - }), - ), - ) - } - } + const exec = Effect.fnUntraced(function* (proc: ReturnType) { + let stderr = "" + let timedOut = false + let aborted = false + + const code: number | null = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn(proc) + + const out = yield* Effect.forkScoped( + Stream.runForEach(Stream.decodeText(handle.stdout), (chunk) => { + return write(chunk) + }), + ) + const err = yield* Effect.forkScoped( + Stream.runForEach(Stream.decodeText(handle.stderr), (chunk) => { + stderr += chunk + return write(chunk) + }), + ) + const abort = Effect.callback((resume) => { + if (ctx.abort.aborted) return resume(Effect.void) + const handler = () => resume(Effect.void) + ctx.abort.addEventListener("abort", handler, { once: true }) + return Effect.sync(() => ctx.abort.removeEventListener("abort", handler)) + }) + + const timeout = Effect.sleep(`${input.timeout + 100} millis`) + + const exit = yield* Effect.raceAll([ + handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))), + abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))), + timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))), + ]) + + if (exit.kind === "abort") { + aborted = true + yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + } + if (exit.kind === "timeout") { + timedOut = true + yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + } + + yield* Fiber.await(out) + yield* Fiber.await(err) + + return exit.kind === "exit" ? exit.code : null + }), + ).pipe(Effect.orDie) - return ctx.metadata({ - metadata: { - output: last, - description: input.description, - }, - }) - }), - ) + return { + code, + stderr, + timedOut, + aborted, + } + }) - const abort = Effect.callback((resume) => { - if (ctx.abort.aborted) return resume(Effect.void) - const handler = () => resume(Effect.void) - ctx.abort.addEventListener("abort", handler, { once: true }) - return Effect.sync(() => ctx.abort.removeEventListener("abort", handler)) + let retried = false + let proactive = false + let rejected = false + let asked = false + const unsandboxed = input.cfg.allow_unsandboxed_retry + ? yield* Effect.promise(() => commandFamilies(input.command)) + : [] + + if (input.command !== input.source && input.cfg.allow_unsandboxed_retry && launch.sandbox.active) { + asked = true + const exit = yield* ctx + .ask({ + permission: "bash:unsandboxed", + patterns: unsandboxed, + always: unsandboxed, + metadata: { + reason: "explicit_request" satisfies SandboxSpawn.UnsandboxedReason, + detail: input.detail, + command: input.command, + }, }) + .pipe(Effect.exit) + if (Exit.isSuccess(exit)) { + proactive = true + } else { + rejected = true + log.info("proactive unsandboxed request rejected", { error: Cause.squash(exit.cause) }) + } + } - const timeout = Effect.sleep(`${input.timeout + 100} millis`) - - const exit = yield* Effect.raceAll([ - handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))), - abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))), - timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))), - ]) - - if (exit.kind === "abort") { - aborted = true - yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) - } - if (exit.kind === "timeout") { - expired = true - yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) - } + let reason: SandboxSpawn.RetryReason | undefined + let result: { code: number | null; stderr: string; timedOut: boolean; aborted: boolean } + const first = yield* exec(proactive ? launch.plain : launch.proc).pipe(Effect.exit) + if (Exit.isFailure(first)) { + const error = Cause.squash(first.cause) + if (rejected && !proactive && launch.sandbox.active) { + const message = error instanceof Error ? error.message : String(error) + throw new Error( + `Explicit unsandboxed request was rejected; sandboxed fallback failed before command start: ${message}`, + error instanceof Error ? { cause: error } : undefined, + ) + } + return yield* Effect.failCause(first.cause) + } + result = first.value + + if (!proactive) { + reason = SandboxSpawn.retryReason({ + active: launch.sandbox.active, + code: result.code ?? 1, + stderr: result.stderr, + allow_network: launch.sandbox.diag.allow_network, + command: input.command, + }) + } - return exit.kind === "exit" ? exit.code : null - }), - ).pipe(Effect.orDie) + if (input.cfg.allow_unsandboxed_retry && !asked && !result.timedOut && !result.aborted && reason) { + asked = true + const exit = yield* ctx + .ask({ + permission: "bash:unsandboxed", + patterns: unsandboxed, + always: unsandboxed, + metadata: { + reason, + command: input.command, + }, + }) + .pipe(Effect.exit) + if (Exit.isSuccess(exit)) { + retried = true + yield* resetOutput() + yield* ctx.metadata({ + metadata: { + output: "", + description: input.description, + }, + }) + result = yield* exec(launch.plain) + } else { + log.info("unsandboxed retry rejected", { error: Cause.squash(exit.cause) }) + } + } const meta: string[] = [] - if (expired) { + if (rejected) { + meta.push("Explicit unsandboxed request was rejected; command ran in sandbox") + } + if (retried) { + meta.push( + reason === "possible_network_sandbox_denial" + ? "Retried command without sandbox after a possible network-related sandbox failure" + : "Retried command without sandbox after sandbox denial", + ) + } + if (result.timedOut) { meta.push( `bash tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`, ) } - if (aborted) meta.push("User aborted the command") + if (result.aborted) meta.push("User aborted the command") const raw = list.map((item) => item.text).join("") const end = tail(raw, lines, bytes) if (end.cut) cut = true @@ -541,26 +773,16 @@ export const BashTool = Tool.define( if (cut && file) { output = `...output truncated...\n\nFull output saved to: ${file}\n\n` + output } - if (meta.length > 0) { output += "\n\n\n" + meta.join("\n") + "\n" } - if (sink) { - const stream = sink - yield* Effect.promise( - () => - new Promise((resolve) => { - stream.end(() => resolve()) - stream.on("error", () => resolve()) - }), - ) - } + yield* closeSink() return { title: input.description, metadata: { output: last || preview(output), - exit: code, + exit: result.code, description: input.description, truncated: cut, ...(cut && file ? { outputPath: file } : {}), @@ -573,6 +795,10 @@ export const BashTool = Tool.define( Effect.sync(() => { const shell = Shell.acceptable() const name = Shell.name(shell) + let dir = process.cwd() + try { + dir = Instance.directory + } catch {} const chain = name === "powershell" ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." @@ -580,15 +806,21 @@ export const BashTool = Tool.define( log.info("bash tool using shell", { shell }) return { - description: DESCRIPTION.replaceAll("${directory}", Instance.directory) + description: DESCRIPTION.replaceAll("${directory}", dir) .replaceAll("${os}", process.platform) .replaceAll("${shell}", name) .replaceAll("${chaining}", chain) .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) - .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), + .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)) + .replaceAll( + "${unsandboxed}", + "\n\nIf sandbox settings allow unsandboxed retries and you know a command needs to run outside the sandbox before the first attempt, put `# opencode:unsandboxed ` on the first non-empty line of the command. This asks for the separate `bash:unsandboxed` permission before execution while keeping the normal bash tool schema unchanged.", + ), parameters: Parameters, execute: (params: z.infer, ctx: Tool.Context) => Effect.gen(function* () { + const request = SandboxSpawn.directive(params.command) + const command = request.command const cwd = params.workdir ? yield* resolvePath(params.workdir, Instance.directory, shell) : Instance.directory @@ -596,9 +828,10 @@ export const BashTool = Tool.define( throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } const timeout = params.timeout ?? DEFAULT_TIMEOUT + const cfg = yield* Effect.promise(Instance.bind(() => SandboxSpawn.settings())) const ps = PS.has(name) - const root = yield* parse(params.command, ps) - const scan = yield* collect(root, cwd, ps, shell) + const root = yield* parse(command, ps) + const scan = yield* collect(root, cwd, ps, shell, cfg.excluded_commands) if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) yield* ask(ctx, scan) @@ -606,11 +839,14 @@ export const BashTool = Tool.define( { shell, name, - command: params.command, + command, + source: params.command, + detail: request.detail, cwd, env: yield* shellEnv(ctx, cwd), timeout, description: params.description, + cfg, }, ctx, ) diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index 668cea307ce4..455009745832 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -88,6 +88,8 @@ Important notes: - IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit +${unsandboxed} + # Creating pull requests Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 9f2bf9db9a53..5d79fbc6817a 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -142,6 +142,59 @@ test("loads JSON config file", async () => { }) }) +test("loads experimental sandbox config", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + preset: "strict", + mode: "read-only", + network: false, + protected_roots: [".git", ".opencode", ".env"], + extra_read_roots: ["/tmp/read"], + extra_write_roots: ["/tmp/write"], + extra_deny_paths: ["/tmp/deny"], + excluded_commands: ["rm"], + allow_unsandboxed_retry: false, + fail_if_unavailable: true, + presets: { + ci: { + mode: "workspace-write", + network: true, + protected_roots: [".git", ".opencode"], + extra_read_roots: ["/tmp/ci-read"], + extra_write_roots: ["/tmp/ci-write"], + permission: { + bash: "allow", + }, + }, + }, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.experimental?.sandbox?.enabled).toBe(true) + expect(config.experimental?.sandbox?.preset).toBe("strict") + expect(config.experimental?.sandbox?.mode).toBe("read-only") + expect(config.experimental?.sandbox?.network).toBe(false) + expect(config.experimental?.sandbox?.protected_roots).toEqual([".git", ".opencode", ".env"]) + expect(config.experimental?.sandbox?.extra_read_roots).toEqual(["/tmp/read"]) + expect(config.experimental?.sandbox?.extra_write_roots).toEqual(["/tmp/write"]) + expect(config.experimental?.sandbox?.extra_deny_paths).toEqual(["/tmp/deny"]) + expect(config.experimental?.sandbox?.excluded_commands).toEqual(["rm"]) + expect(config.experimental?.sandbox?.allow_unsandboxed_retry).toBe(false) + expect(config.experimental?.sandbox?.fail_if_unavailable).toBe(true) + expect(config.experimental?.sandbox?.presets?.ci?.network).toBe(true) + expect(config.experimental?.sandbox?.presets?.ci?.permission).toEqual({ bash: "allow" }) + }, + }) +}) + test("loads formatter boolean config", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 3e4d6583557d..d625530d1f92 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" import { AppRuntime } from "../../src/effect/app-runtime" import { Bus } from "../../src/bus" import { Effect } from "effect" @@ -8,6 +10,15 @@ import type { PtyID } from "../../src/pty/schema" import { tmpdir } from "../fixture/fixture" import { setTimeout as sleep } from "node:timers/promises" +const env = { + HOME: process.env.HOME, +} + +afterEach(() => { + if (env.HOME === undefined) delete process.env.HOME + else process.env.HOME = env.HOME +}) + const wait = async (fn: () => boolean, ms = 5000) => { const end = Date.now() + ms while (Date.now() < end) { @@ -21,11 +32,27 @@ const pick = (log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }>, return log.filter((evt) => evt.id === id).map((evt) => evt.type) } +function createPty(input: Pty.CreateInput) { + return AppRuntime.runPromise(Pty.Service.use((svc) => svc.create(input))) +} + +function removePty(id: PtyID) { + return AppRuntime.runPromise(Pty.Service.use((svc) => svc.remove(id))) +} + +function connectPty(id: PtyID, ws: Parameters[1]) { + return AppRuntime.runPromise(Pty.Service.use((svc) => svc.connect(id, ws))) +} + +function writePty(id: PtyID, data: string) { + return AppRuntime.runPromise(Pty.Service.use((svc) => svc.write(id, data))) +} + describe("pty", () => { test("publishes created, exited, deleted in order for a short-lived process", async () => { if (process.platform === "win32") return - await using dir = await tmpdir({ git: true }) + await using dir = await tmpdir() await Instance.provide({ directory: dir.path, @@ -66,7 +93,7 @@ describe("pty", () => { test("publishes created, exited, deleted in order for /bin/sh + remove", async () => { if (process.platform === "win32") return - await using dir = await tmpdir({ git: true }) + await using dir = await tmpdir() await Instance.provide({ directory: dir.path, @@ -99,4 +126,106 @@ describe("pty", () => { ), }) }) + + test("preserves pty io through the sandbox wrapper", async () => { + if (process.platform !== "darwin") return + + await using dir = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + }, + }, + }, + }) + + await Instance.provide({ + directory: dir.path, + fn: async () => { + const info = await createPty({ command: "cat", title: "cat" }) + try { + const out: string[] = [] + const ws: Parameters[1] = { + readyState: 1, + data: { id: info.id }, + send: (data: unknown) => { + out.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) + }, + close: () => {}, + } + + await connectPty(info.id, ws) + out.length = 0 + await writePty(info.id, "AAA\n") + await wait(() => out.join("").includes("AAA")) + } finally { + await removePty(info.id) + } + }, + }) + }) + + test("keeps pty shell startup deterministic in sandbox mode", async () => { + if (process.platform !== "darwin") return + + await using home = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".bashrc"), 'printf hit > "$HOME/bashrc-hit"\n') + }, + }) + await using dir = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + }, + }, + }, + }) + process.env.HOME = home.path + + await Instance.provide({ + directory: dir.path, + fn: async () => { + const info = await createPty({ command: "/bin/bash", title: "bash" }) + try { + await sleep(150) + const hit = await fs + .access(path.join(home.path, "bashrc-hit")) + .then(() => true) + .catch(() => false) + expect(hit).toBe(false) + } finally { + await removePty(info.id) + } + }, + }) + }) + + test("blocks excluded commands on initial pty spawn", async () => { + await using dir = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + excluded_commands: ["python"], + }, + }, + }, + }) + + await Instance.provide({ + directory: dir.path, + fn: async () => { + await expect(createPty({ command: "python", title: "py" })).rejects.toThrow("python") + await expect( + createPty({ command: "env", args: ["FOO=1", "python", "-c", "print(1)"], title: "env" }), + ).rejects.toThrow("python") + await expect(createPty({ command: "sh", args: ["-c", "python -c 'print(1)'"], title: "sh" })).rejects.toThrow( + "python", + ) + }, + }) + }) }) diff --git a/packages/opencode/test/sandbox/policy.test.ts b/packages/opencode/test/sandbox/policy.test.ts new file mode 100644 index 000000000000..a9216cdc3d0b --- /dev/null +++ b/packages/opencode/test/sandbox/policy.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Protected } from "../../src/file/protected" +import { SandboxPolicy } from "../../src/sandbox/policy" +import { tmpdir } from "../fixture/fixture" + +describe("sandbox.policy", () => { + test("builds a deny-by-default profile with explicit roots", () => { + const out = SandboxPolicy.build({ + cwd: "/tmp/project/app", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + extra_read_roots: ["/opt/homebrew"], + extra_write_roots: ["/tmp/project/tmp"], + extra_deny_paths: ["/tmp/blocked"], + }) + + expect(out.profile).toContain("(deny default)") + expect(out.profile).toContain("(allow file-read*") + expect(out.profile).toContain("(allow file-write*") + expect(out.profile).not.toContain("(allow network*)") + expect(out.profile).not.toContain("AF_UNIX") + expect(out.read).toContain("/tmp/project") + expect(out.read).toContain("/opt/homebrew") + expect(out.write).toContain("/tmp/project/tmp") + expect(out.deny).toContain(path.join("/Users/tester", ".ssh")) + expect(out.deny).toContain("/tmp/blocked") + }) + + test("includes /opt/homebrew in default read roots without extra config", () => { + const out = SandboxPolicy.build({ + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + }) + + expect(out.read).toContain("/opt/homebrew") + expect(out.profile).toContain('(subpath "/opt/homebrew")') + }) + + test("adds network rules only when requested", () => { + const out = SandboxPolicy.build({ + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + allow_network: true, + }) + + expect(out.profile).toContain("(allow network*)") + expect(out.profile).not.toContain("AF_UNIX") + expect(out.profile).not.toContain("network-bind") + expect(out.profile).not.toContain("network-outbound") + }) + + test("supports read-only mode without project write roots", () => { + const out = SandboxPolicy.build({ + cwd: "/tmp/project/app", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + mode: "read-only", + extra_write_roots: ["/tmp/project/tmp"], + }) + + expect(out.read).toContain("/tmp/project") + expect(out.write).toEqual(["/private/tmp", "/tmp", "/tmp/project/tmp"]) + expect(out.profile).not.toContain('(allow file-write*\n (subpath "/tmp/project")') + }) + + test("resolves workspace protected roots for a standard repo", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".git"), { recursive: true }) + }, + }) + expect(await Protected.resolve(tmp.path, [".git"])).toEqual([path.join(tmp.path, ".git")]) + }) + + test("resolves both the gitfile and gitdir for a worktree", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, "repo") + const worktree = path.join(tmp.path, "worktree") + const gitdir = path.join(root, ".git", "worktrees", "demo") + await fs.mkdir(gitdir, { recursive: true }) + await fs.mkdir(worktree, { recursive: true }) + await Bun.write(path.join(worktree, ".git"), `gitdir: ../repo/.git/worktrees/demo\n`) + + expect(await Protected.resolve(worktree, [".git"])).toEqual([gitdir, path.join(worktree, ".git")].toSorted()) + }) + + test("emits protected-root write denies after write allows", () => { + const out = SandboxPolicy.build({ + cwd: "/tmp/project/app", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + protected_roots: ["/tmp/project/.git", "/tmp/project/.opencode"], + }) + + const allow = out.profile.indexOf("(allow file-write*") + const git = out.profile.indexOf('(deny file-write* (subpath "/tmp/project/.git"))') + const opencode = out.profile.indexOf('(deny file-write* (subpath "/tmp/project/.opencode"))') + expect(allow).toBeGreaterThanOrEqual(0) + expect(git).toBeGreaterThan(allow) + expect(opencode).toBeGreaterThan(allow) + expect(out.profile).not.toContain('(deny file-read* (subpath "/tmp/project/.git"))') + }) +}) diff --git a/packages/opencode/test/sandbox/preset-permission.test.ts b/packages/opencode/test/sandbox/preset-permission.test.ts new file mode 100644 index 000000000000..9e16aa470f55 --- /dev/null +++ b/packages/opencode/test/sandbox/preset-permission.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { AppRuntime } from "../../src/effect/app-runtime" +import { Agent } from "../../src/agent/agent" +import { Instance } from "../../src/project/instance" +import { Permission } from "../../src/permission" +import { tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await Instance.disposeAll() +}) + +function getAgent(name: string) { + return AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) +} + +describe("sandbox preset permission overlay", () => { + test("applies the preset overlay when no explicit override exists", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + preset: "strict", + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await getAgent("build") + expect(Permission.evaluate("bash", "echo hello", build!.permission).action).toBe("ask") + }, + }) + }) + + test("agent-specific config still overrides the preset overlay", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + preset: "strict", + }, + }, + agent: { + build: { + permission: { + bash: "allow", + }, + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await getAgent("build") + expect(Permission.evaluate("bash", "echo hello", build!.permission).action).toBe("allow") + }, + }) + }) + + test("top-level user config overrides the preset overlay when no agent override exists", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + preset: "strict", + }, + }, + permission: { + bash: "deny", + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await getAgent("build") + expect(Permission.evaluate("bash", "echo hello", build!.permission).action).toBe("deny") + }, + }) + }) + + test("general inherits the preset overlay", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + preset: "strict", + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const general = await getAgent("general") + expect(Permission.evaluate("bash", "ls", general!.permission).action).toBe("ask") + }, + }) + }) + + test("no preset keeps existing behavior", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await getAgent("build") + expect(Permission.evaluate("bash", "echo hello", build!.permission).action).toBe("allow") + }, + }) + }) +}) diff --git a/packages/opencode/test/sandbox/preset.test.ts b/packages/opencode/test/sandbox/preset.test.ts new file mode 100644 index 000000000000..a896c60ee937 --- /dev/null +++ b/packages/opencode/test/sandbox/preset.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Config } from "../../src/config" +import { Instance } from "../../src/project/instance" +import { SandboxPreset } from "../../src/sandbox/preset" +import { tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await Instance.disposeAll() +}) + +describe("sandbox.preset", () => { + test("resolves built-in presets", () => { + expect(SandboxPreset.resolve("default")).toEqual({ + mode: "workspace-write", + network: false, + protected_roots: [".git", ".opencode"], + permission: {}, + extra_read_roots: [], + extra_write_roots: [], + }) + + expect(SandboxPreset.resolve("strict")).toEqual({ + mode: "read-only", + network: false, + protected_roots: [".git", ".opencode"], + permission: { + bash: "ask", + edit: "ask", + }, + extra_read_roots: [], + extra_write_roots: [], + }) + }) + + test("resolves custom presets from config", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + preset: "ci", + presets: { + ci: { + mode: "workspace-write", + network: true, + protected_roots: [".git", ".opencode", ".env"], + extra_read_roots: ["/tmp/ci-read"], + extra_write_roots: ["/tmp/ci-write"], + permission: { + bash: "allow", + }, + }, + }, + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cfg = await Config.get() + expect( + SandboxPreset.resolve("ci", { + presets: cfg.experimental?.sandbox?.presets, + }), + ).toEqual({ + mode: "workspace-write", + network: true, + protected_roots: [".git", ".opencode", ".env"], + permission: { + bash: "allow", + }, + extra_read_roots: ["/tmp/ci-read"], + extra_write_roots: ["/tmp/ci-write"], + }) + }, + }) + }) + + test("lets explicit overrides win over preset defaults", () => { + expect( + SandboxPreset.resolve("default", { + overrides: { + mode: "read-only", + network: true, + protected_roots: [".git", ".opencode", ".env"], + }, + }), + ).toEqual({ + mode: "read-only", + network: true, + protected_roots: [".git", ".opencode", ".env"], + permission: {}, + extra_read_roots: [], + extra_write_roots: [], + }) + }) + + test("rejects custom presets that shadow built-ins", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + presets: { + default: { + mode: "read-only", + }, + }, + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(Config.get()).rejects.toThrow() + }, + }) + }) +}) diff --git a/packages/opencode/test/sandbox/spawn.test.ts b/packages/opencode/test/sandbox/spawn.test.ts new file mode 100644 index 000000000000..44e7866c3655 --- /dev/null +++ b/packages/opencode/test/sandbox/spawn.test.ts @@ -0,0 +1,285 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { SandboxSpawn } from "../../src/sandbox/spawn" +import { tmpdir } from "../fixture/fixture" + +const home = process.env.HOME +const testHome = process.env.OPENCODE_TEST_HOME + +afterEach(() => { + if (home === undefined) delete process.env.HOME + else process.env.HOME = home + if (testHome === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = testHome + delete process.env.OPENCODE_EXPERIMENTAL_SANDBOX +}) + +describe("sandbox.spawn", () => { + test("wraps darwin commands with sandbox-exec", () => { + const out = SandboxSpawn.plan({ + requested: true, + platform: "darwin", + available: true, + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + }) + const cmd = SandboxSpawn.wrap({ + profile: out.profile!, + file: "/bin/zsh", + args: ["-f", "-c", "pwd"], + }) + + expect(out.active).toBe(true) + expect(out.diag.reason).toBe("enabled") + expect(cmd.file).toBe("/usr/bin/sandbox-exec") + expect(cmd.args[0]).toBe("-p") + expect(cmd.args[2]).toBe("/bin/zsh") + }) + + test("keeps non-darwin behavior unchanged", () => { + const out = SandboxSpawn.plan({ + requested: true, + platform: "linux", + available: true, + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + }) + + expect(out.active).toBe(false) + expect(out.diag.reason).toBe("unsupported_platform") + }) + + test("matches excluded command prefixes", () => { + expect(SandboxSpawn.excluded(["rm", "-rf", "/tmp/test"], ["rm"]))?.toEqual({ + command: "rm", + rule: "rm", + }) + expect(SandboxSpawn.excluded(["git", "status"], ["git"]))?.toEqual({ + command: "git status", + rule: "git", + }) + expect(SandboxSpawn.excluded(["printf", "ok"], ["rm"]))?.toBeUndefined() + }) + + test("matches excluded commands through wrappers and shell text", () => { + expect(SandboxSpawn.excluded(["env", "FOO=1", "python", "-c", "print(1)"], ["python"]))?.toEqual({ + command: "python -c", + rule: "python", + }) + expect(SandboxSpawn.excluded(["sh", "-c", "curl https://example.com"], ["curl"]))?.toEqual({ + command: "curl", + rule: "curl", + }) + expect(SandboxSpawn.excludedText("FOO=1 curl https://example.com", ["curl"]))?.toEqual({ + command: "curl", + rule: "curl", + }) + expect(SandboxSpawn.excludedText("echo ok\ncurl https://example.com", ["curl"]))?.toEqual({ + command: "curl", + rule: "curl", + }) + expect(SandboxSpawn.excludedText("echo ok & curl https://example.com", ["curl"]))?.toEqual({ + command: "curl", + rule: "curl", + }) + }) + + test("rejects broad home roots", () => { + expect(() => + SandboxSpawn.plan({ + requested: true, + platform: "darwin", + available: true, + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + extra_read_roots: ["/Users/tester"], + }), + ).toThrow("unsafe_root") + }) + + test("hard-fails when sandbox availability is required", () => { + expect(() => + SandboxSpawn.plan({ + requested: true, + platform: "linux", + available: true, + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + fail_if_unavailable: true, + }), + ).toThrow("unsupported_platform") + + expect(() => + SandboxSpawn.plan({ + requested: true, + platform: "darwin", + available: false, + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + fail_if_unavailable: true, + }), + ).toThrow("sandbox_exec_missing") + }) + + test("falls back when hard-fail is disabled", () => { + const platform = SandboxSpawn.plan({ + requested: true, + platform: "linux", + available: true, + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + }) + const missing = SandboxSpawn.plan({ + requested: true, + platform: "darwin", + available: false, + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + }) + + expect(platform.active).toBe(false) + expect(platform.diag.reason).toBe("unsupported_platform") + expect(missing.active).toBe(false) + expect(missing.diag.reason).toBe("sandbox_exec_missing") + }) + + test("detects likely sandbox denials conservatively", () => { + expect( + SandboxSpawn.retryReason({ + active: true, + code: 1, + stderr: "sandbox-exec: sandbox_apply: Operation not permitted", + }), + ).toBe("sandbox_denial") + expect( + SandboxSpawn.shouldRetry({ + active: true, + code: 1, + stderr: "sandbox-exec: sandbox_apply: Operation not permitted", + }), + ).toBe(true) + expect( + SandboxSpawn.shouldRetry({ + active: true, + code: 1, + stderr: "Sandbox: bash(1) deny(1) file-read-data /Users/tester/.ssh/secret", + }), + ).toBe(true) + expect( + SandboxSpawn.shouldRetry({ + active: true, + code: 1, + stderr: "Operation not permitted", + }), + ).toBe(true) + expect( + SandboxSpawn.shouldRetry({ + active: false, + code: 1, + stderr: "sandbox-exec: sandbox_apply: Operation not permitted", + }), + ).toBe(false) + expect( + SandboxSpawn.shouldRetry({ + active: true, + code: 1, + stderr: "permission denied", + }), + ).toBe(false) + }) + + test("classifies likely curl network failures when sandbox networking is disabled", () => { + expect( + SandboxSpawn.retryReason({ + active: true, + code: 6, + stderr: "curl: (6) Could not resolve host: example.com", + allow_network: false, + command: "FOO=1 curl -I https://example.com", + }), + ).toBe("possible_network_sandbox_denial") + expect( + SandboxSpawn.retryReason({ + active: true, + code: 7, + stderr: "curl: (7) Failed to connect to example.com port 443", + allow_network: false, + command: 'sh -c "curl https://example.com"', + }), + ).toBe("possible_network_sandbox_denial") + expect( + SandboxSpawn.retryReason({ + active: true, + code: 6, + stderr: "curl: (6) Could not resolve host: example.com", + allow_network: true, + command: "curl https://example.com", + }), + ).toBeUndefined() + expect( + SandboxSpawn.retryReason({ + active: true, + code: 6, + stderr: "curl: (6) Could not resolve host: example.com", + allow_network: false, + command: "python script.py", + }), + ).toBeUndefined() + }) + + test("extracts explicit unsandboxed directives from the first non-empty line", () => { + expect(SandboxSpawn.directive("# opencode:unsandboxed needs network\ncurl https://example.com")).toEqual({ + command: "curl https://example.com", + detail: "needs network", + }) + expect(SandboxSpawn.directive("\n # opencode:unsandboxed\ncat foo.txt")).toEqual({ + command: "\ncat foo.txt", + detail: undefined, + }) + expect(SandboxSpawn.directive("echo hi\n# opencode:unsandboxed later")).toEqual({ + command: "echo hi\n# opencode:unsandboxed later", + }) + }) + + test("respects the env override at runtime", async () => { + await using home = await tmpdir() + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: false, + }, + }, + }, + }) + process.env.OPENCODE_EXPERIMENTAL_SANDBOX = "true" + process.env.OPENCODE_TEST_HOME = home.path + process.env.HOME = home.path + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const out = await SandboxSpawn.resolve({ + cwd: tmp.path, + project_root: tmp.path, + worktree_root: tmp.path, + }) + expect(out.diag.requested).toBe(true) + }, + }) + }) +}) diff --git a/packages/opencode/test/session/prompt-sandbox.test.ts b/packages/opencode/test/session/prompt-sandbox.test.ts new file mode 100644 index 000000000000..d6ce8fcdc774 --- /dev/null +++ b/packages/opencode/test/session/prompt-sandbox.test.ts @@ -0,0 +1,564 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { AppRuntime } from "../../src/effect/app-runtime" +import { Permission } from "../../src/permission" +import { Instance } from "../../src/project/instance" +import { SandboxSpawn } from "../../src/sandbox/spawn" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { tmpdir } from "../fixture/fixture" + +const env = { + HOME: process.env.HOME, + OPENCODE_TEST_HOME: process.env.OPENCODE_TEST_HOME, + SHELL: process.env.SHELL, +} + +function listPermissions() { + return AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())) +} + +function replyPermission(input: Permission.ReplyInput) { + return AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply(input))) +} + +function createSession() { + return AppRuntime.runPromise(Session.Service.use((svc) => svc.create({}))) +} + +function removeSession(id: Session.Info["id"]) { + return AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(id))) +} + +function runShell(input: SessionPrompt.ShellInput) { + return AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell(input))) +} + +function cancelShell(sessionID: Session.Info["id"]) { + return AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(sessionID))) +} + +async function waitForPending(count: number) { + for (let i = 0; i < 100; i++) { + const list = await listPermissions() + if (list.length === count) return list + await Bun.sleep(10) + } + return listPermissions() +} + +afterEach(() => { + if (env.HOME === undefined) delete process.env.HOME + else process.env.HOME = env.HOME + if (env.OPENCODE_TEST_HOME === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = env.OPENCODE_TEST_HOME + if (env.SHELL === undefined) delete process.env.SHELL + else process.env.SHELL = env.SHELL +}) + +describe("session.prompt sandbox", () => { + test("keeps shell startup deterministic in sandbox mode", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".zshenv"), "export OPENCODE_ZSHENV_HIT=1\n") + }, + }) + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + }, + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await createSession() + const out = await runShell({ + sessionID: session.id, + agent: "build", + command: "printf '%s' \"${OPENCODE_ZSHENV_HIT:-missing}\"", + }) + const part = out.parts[0] + if (part.type !== "tool") throw new Error("expected tool part") + if (part.state.status !== "completed") throw new Error("expected completed part") + expect(part.state.output).toBe("missing") + await removeSession(session.id) + }, + }) + }) + + test("denies sensitive home reads and preserves abort behavior", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + await Bun.write(path.join(dir, ".zshenv"), "export OPENCODE_ZSHENV_HIT=1\n") + }, + }) + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + }, + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await createSession() + const denied = await runShell({ + sessionID: session.id, + agent: "build", + command: 'cat "$HOME/.ssh/secret"', + }) + const blocked = denied.parts[0] + if (blocked.type !== "tool") throw new Error("expected tool part") + if (blocked.state.status !== "completed") throw new Error("expected completed part") + expect(blocked.state.output).not.toContain("secret\n") + expect(blocked.state.output).toContain("Operation not permitted") + + const next = await createSession() + const run = runShell({ + sessionID: next.id, + agent: "build", + command: "sleep 5", + }) + setTimeout(() => { + void cancelShell(next.id) + }, 50) + const out = await run + const part = out.parts[0] + if (part.type !== "tool") throw new Error("expected tool part") + if (part.state.status !== "completed") throw new Error("expected completed part") + expect(part.state.output).toContain("User aborted the command") + + await removeSession(session.id) + await removeSession(next.id) + }, + }) + }) + + test("blocks excluded commands before spawning", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + excluded_commands: ["curl"], + }, + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await createSession() + const out = await runShell({ + sessionID: session.id, + agent: "build", + command: "FOO=1 curl https://example.com\necho done", + }) + const part = out.parts[0] + if (part.type !== "tool") throw new Error("expected tool part") + if (part.state.status !== "completed") throw new Error("expected completed part") + expect(part.state.output).toContain("curl") + await removeSession(session.id) + }, + }) + }) + + test("retries unsandboxed when permission is pre-allowed", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + }, + }) + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + permission: { + "bash:unsandboxed": "allow", + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await createSession() + const out = await runShell({ + sessionID: session.id, + agent: "build", + command: 'cat "$HOME/.ssh/secret"', + }) + const part = out.parts[0] + if (part.type !== "tool") throw new Error("expected tool part") + if (part.state.status !== "completed") throw new Error("expected completed part") + expect(part.state.output).toContain("secret\n") + expect(part.state.output).toContain("Retried command without sandbox") + expect(part.state.output).not.toContain("1\n") + await removeSession(session.id) + }, + }) + }) + + test("runs unsandboxed on the first attempt after an explicit request", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + await Bun.write(path.join(dir, ".zshenv"), "export OPENCODE_ZSHENV_HIT=1\n") + }, + }) + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + permission: { + "bash:unsandboxed": "allow", + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await createSession() + const out = await runShell({ + sessionID: session.id, + agent: "build", + command: '# opencode:unsandboxed needs secret access\ncat "$HOME/.ssh/secret"', + }) + const part = out.parts[0] + if (part.type !== "tool") throw new Error("expected tool part") + if (part.state.status !== "completed") throw new Error("expected completed part") + expect(part.state.output).toContain("secret\n") + expect(part.state.output).not.toContain("Retried command without sandbox") + expect(part.state.output).not.toContain("1\n") + await removeSession(session.id) + }, + }) + }) + + test("signals when an explicit unsandboxed request is rejected and the command falls back to sandbox", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + }, + }) + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + permission: { + "bash:unsandboxed": "ask", + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await createSession() + const run = runShell({ + sessionID: session.id, + agent: "build", + command: '# opencode:unsandboxed needs secret access\ncat "$HOME/.ssh/secret"', + }) + const pending = await waitForPending(1) + expect(pending).toHaveLength(1) + await replyPermission({ + requestID: pending[0].id, + reply: "reject", + }) + const out = await run + const part = out.parts[0] + if (part.type !== "tool") throw new Error("expected tool part") + if (part.state.status !== "completed") throw new Error("expected completed part") + expect(part.state.output).not.toContain("secret\n") + expect(part.state.output).toContain("Operation not permitted") + expect(part.state.output).toContain("Explicit unsandboxed request was rejected; command ran in sandbox") + await removeSession(session.id) + }, + }) + }) + + test("unsandboxed always-allow reuses generalized pattern across command variants", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "foo"), "foo-content\n") + await Bun.write(path.join(dir, ".ssh", "bar"), "bar-content\n") + }, + }) + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + permission: { + "bash:unsandboxed": "ask", + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await createSession() + + const run1 = runShell({ + sessionID: session.id, + agent: "build", + command: '# opencode:unsandboxed read foo\ncat "$HOME/.ssh/foo"', + }) + const pending1 = await waitForPending(1) + expect(pending1).toHaveLength(1) + expect(pending1[0].permission).toBe("bash:unsandboxed") + expect(pending1[0].patterns).toEqual(["cat *"]) + expect(pending1[0].always).toEqual(["cat *"]) + await replyPermission({ requestID: pending1[0].id, reply: "always" }) + const out1 = await run1 + const part1 = out1.parts[0] + if (part1.type !== "tool") throw new Error("expected tool part") + if (part1.state.status !== "completed") throw new Error("expected completed part") + expect(part1.state.output).toContain("foo-content") + + const run2 = runShell({ + sessionID: session.id, + agent: "build", + command: '# opencode:unsandboxed read bar\ncat "$HOME/.ssh/bar"', + }) + await Bun.sleep(100) + const pending2 = await listPermissions() + expect(pending2).toHaveLength(0) + const out2 = await run2 + const part2 = out2.parts[0] + if (part2.type !== "tool") throw new Error("expected tool part") + if (part2.state.status !== "completed") throw new Error("expected completed part") + expect(part2.state.output).toContain("bar-content") + + await removeSession(session.id) + }, + }) + }) + + test("unsandboxed always-allow covers multi-command env-prefix variant", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "a"), "a-content\n") + await Bun.write(path.join(dir, ".ssh", "b"), "b-content\n") + }, + }) + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + permission: { + "bash:unsandboxed": "ask", + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await createSession() + + const run1 = runShell({ + sessionID: session.id, + agent: "build", + command: '# opencode:unsandboxed env read\nFOO=1 cat "$HOME/.ssh/a" && echo done', + }) + const pending1 = await waitForPending(1) + expect(pending1).toHaveLength(1) + expect(pending1[0].patterns).toContain("cat *") + expect(pending1[0].patterns).toContain("echo *") + await replyPermission({ requestID: pending1[0].id, reply: "always" }) + await run1 + + const run2 = runShell({ + sessionID: session.id, + agent: "build", + command: '# opencode:unsandboxed env read\nBAR=2 cat "$HOME/.ssh/b" && echo finished', + }) + await Bun.sleep(100) + const pending2 = await listPermissions() + expect(pending2).toHaveLength(0) + const out2 = await run2 + const part2 = out2.parts[0] + if (part2.type !== "tool") throw new Error("expected tool part") + if (part2.state.status !== "completed") throw new Error("expected completed part") + expect(part2.state.output).toContain("b-content") + + await removeSession(session.id) + }, + }) + }) + + test("signals when explicit rejection is followed by sandboxed launch failure", async () => { + if (process.platform !== "darwin") return + const wrap = spyOn(SandboxSpawn, "wrap").mockReturnValue({ + file: "/definitely/missing-sandbox-exec", + args: [], + }) + try { + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + permission: { + "bash:unsandboxed": "ask", + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await createSession() + const run = runShell({ + sessionID: session.id, + agent: "build", + command: "# opencode:unsandboxed needs network\nwget google.com", + }) + const pending = await waitForPending(1) + expect(pending).toHaveLength(1) + await replyPermission({ + requestID: pending[0].id, + reply: "reject", + }) + const out = await run + const part = out.parts[0] + if (part.type !== "tool") throw new Error("expected tool part") + if (part.state.status !== "completed") throw new Error("expected completed part") + expect(part.state.output).toContain( + "Explicit unsandboxed request was rejected; sandboxed fallback failed before command start", + ) + await removeSession(session.id) + }, + }) + } finally { + wrap.mockRestore() + } + }) +}) diff --git a/packages/opencode/test/tool/bash-sandbox.test.ts b/packages/opencode/test/tool/bash-sandbox.test.ts new file mode 100644 index 000000000000..55fe07b9e448 --- /dev/null +++ b/packages/opencode/test/tool/bash-sandbox.test.ts @@ -0,0 +1,673 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { BashTool as RawBashTool, commandFamilies } from "../../src/tool/bash" +import { SandboxSpawn } from "../../src/sandbox/spawn" +import { Tool } from "../../src/tool" +import { Instance } from "../../src/project/instance" +import { SessionID, MessageID } from "../../src/session/schema" +import { tmpdir } from "../fixture/fixture" +import { Agent } from "../../src/agent/agent" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Effect, Layer, ManagedRuntime } from "effect" +import { Plugin } from "../../src/plugin" +import { Truncate } from "../../src/tool" + +const env = { + HOME: process.env.HOME, + OPENCODE_TEST_HOME: process.env.OPENCODE_TEST_HOME, + SHELL: process.env.SHELL, +} + +const runtime = ManagedRuntime.make( + Layer.mergeAll( + CrossSpawnSpawner.defaultLayer, + AppFileSystem.defaultLayer, + Plugin.defaultLayer, + Truncate.defaultLayer, + Agent.defaultLayer, + ), +) + +const BashTool = { + init: async () => { + const bash = await runtime.runPromise(RawBashTool.pipe(Effect.flatMap((info) => info.init()))) + return { + ...bash, + execute: (args: Parameters[0], ctx: Parameters[1]) => + runtime.runPromise(bash.execute(args, ctx)), + } + }, +} + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const makeCtx = (ask: (input: Parameters[0]) => void | Promise = () => undefined) => ({ + ...ctx, + ask: (input: Parameters[0]) => + Effect.tryPromise({ + try: () => Promise.resolve(ask(input)).then(() => undefined), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe(Effect.orDie), +}) + +afterEach(() => { + if (env.HOME === undefined) delete process.env.HOME + else process.env.HOME = env.HOME + if (env.OPENCODE_TEST_HOME === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = env.OPENCODE_TEST_HOME + if (env.SHELL === undefined) delete process.env.SHELL + else process.env.SHELL = env.SHELL +}) + +describe("tool.bash sandbox", () => { + test("allows in-project writes and skips zsh startup files in sandbox mode", async () => { + await using home = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".zshenv"), "export OPENCODE_ZSHENV_HIT=1\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const out = await bash.execute( + { + command: "printf '%s\n' \"${OPENCODE_ZSHENV_HIT:-missing}\" && printf 'ok' > hit.txt && cat hit.txt", + description: "Writes inside sandbox", + }, + ctx, + ) + expect(out.metadata.exit).toBe(0) + expect(out.output).toContain("missing") + expect(out.output).toContain("ok") + }, + }) + }) + + test("denies reads from sensitive home paths", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + await Bun.write(path.join(dir, ".zshenv"), "export OPENCODE_ZSHENV_HIT=1\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seen: string[] = [] + const bash = await BashTool.init() + const out = await bash.execute( + { + command: 'cat "$HOME/.ssh/secret"', + description: "Reads blocked home file", + }, + makeCtx(async (req) => { + seen.push(req.permission) + }), + ) + expect(out.output).not.toContain("secret\n") + expect(out.output).toContain("Operation not permitted") + expect(seen).not.toContain("bash:unsandboxed") + }, + }) + }) + + test("denies in-project writes in read-only mode", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir() + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + mode: "read-only", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const out = await bash.execute( + { + command: "printf 'ok' > hit.txt", + description: "Writes in read-only sandbox", + }, + ctx, + ) + expect(out.output).toContain("operation not permitted") + expect(await fs.stat(path.join(tmp.path, "hit.txt")).catch(() => undefined)).toBeUndefined() + }, + }) + }) + + test("allows tmp writes in read-only mode", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir() + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + mode: "read-only", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const file = path.join("/tmp", `opencode-sandbox-${Date.now()}.txt`) + const bash = await BashTool.init() + const out = await bash.execute( + { + command: `printf 'ok' > ${JSON.stringify(file)} && cat ${JSON.stringify(file)} && rm ${JSON.stringify(file)}`, + description: "Writes tmp file in read-only sandbox", + }, + ctx, + ) + expect(out.output).toContain("ok") + }, + }) + }) + + test("blocks excluded commands before execution", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + excluded_commands: ["rm"], + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seen: string[] = [] + const bash = await BashTool.init() + await expect( + bash.execute( + { + command: "rm -rf /tmp/test", + description: "Blocked command", + }, + makeCtx(async (req) => { + seen.push(req.permission) + }), + ), + ).rejects.toThrow("rm") + expect(seen).toEqual([]) + }, + }) + }) + + test("retries unsandboxed when allowed and approved", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seen: string[] = [] + const bash = await BashTool.init() + const out = await bash.execute( + { + command: 'cat "$HOME/.ssh/secret"', + description: "Retries without sandbox", + }, + makeCtx(async (req) => { + seen.push(req.permission) + }), + ) + expect(seen).toContain("bash:unsandboxed") + expect(out.output).toContain("secret\n") + expect(out.output).toContain("Retried command without sandbox") + expect(out.output).not.toContain("1\n") + }, + }) + }) + + test("runs unsandboxed on the first attempt after an explicit request", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + await Bun.write(path.join(dir, ".zshenv"), "export OPENCODE_ZSHENV_HIT=1\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seen: string[] = [] + const bash = await BashTool.init() + const out = await bash.execute( + { + command: '# opencode:unsandboxed needs secret access\ncat "$HOME/.ssh/secret"', + description: "Requests unsandboxed first attempt", + }, + makeCtx(async (req) => { + seen.push(req.permission) + }), + ) + expect(seen).toContain("bash:unsandboxed") + expect(out.output).toContain("secret\n") + expect(out.output).not.toContain("Retried command without sandbox") + expect(out.output).not.toContain("1\n") + }, + }) + }) + + test("keeps the original denial when unsandboxed retry is rejected", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seen: string[] = [] + const bash = await BashTool.init() + const out = await bash.execute( + { + command: 'cat "$HOME/.ssh/secret"', + description: "Rejects unsandboxed retry", + }, + makeCtx(async (req) => { + seen.push(req.permission) + if (req.permission === "bash:unsandboxed") throw new Error("reject") + }), + ) + expect(seen).toContain("bash:unsandboxed") + expect(out.output).not.toContain("secret\n") + expect(out.output).toContain("Operation not permitted") + }, + }) + }) + + test("falls back to sandboxed execution when an explicit request is rejected", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seen: string[] = [] + const bash = await BashTool.init() + const out = await bash.execute( + { + command: '# opencode:unsandboxed needs secret access\ncat "$HOME/.ssh/secret"', + description: "Rejects proactive unsandboxed request", + }, + makeCtx(async (req) => { + seen.push(req.permission) + if (req.permission === "bash:unsandboxed") throw new Error("reject") + }), + ) + expect(seen.filter((item) => item === "bash:unsandboxed")).toEqual(["bash:unsandboxed"]) + expect(out.output).not.toContain("secret\n") + expect(out.output).toContain("Operation not permitted") + expect(out.output).toContain("Explicit unsandboxed request was rejected; command ran in sandbox") + }, + }) + }) + + test("reports sandboxed fallback launch failures after explicit rejection", async () => { + if (process.platform !== "darwin") return + const wrap = spyOn(SandboxSpawn, "wrap").mockReturnValue({ + file: "/definitely/missing-sandbox-exec", + args: [], + }) + try { + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + }, + }) + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + await expect( + bash.execute( + { + command: "# opencode:unsandboxed needs network\nwget google.com", + description: "Rejects proactive unsandboxed request before spawn", + }, + makeCtx(async (req) => { + if (req.permission === "bash:unsandboxed") throw new Error("reject") + }), + ), + ).rejects.toThrow("Explicit unsandboxed request was rejected; sandboxed fallback failed before command start") + }, + }) + } finally { + wrap.mockRestore() + } + }) + + test("preserves timeout and abort through the sandbox wrapper", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir() + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const slow = await bash.execute( + { + command: "sleep 2", + timeout: 50, + description: "Times out in sandbox", + }, + ctx, + ) + expect(slow.output).toContain("terminated command after exceeding timeout") + + const abort = new AbortController() + const run = bash.execute( + { + command: "sleep 5", + description: "Aborts in sandbox", + }, + { ...ctx, abort: abort.signal }, + ) + setTimeout(() => abort.abort(), 50) + const out = await run + expect(out.output).toContain("User aborted the command") + }, + }) + }) + + test("commandFamilies returns generalized command-family patterns", async () => { + expect(await commandFamilies("cat foo.txt")).toEqual(["cat *"]) + expect(await commandFamilies("git push origin main")).toEqual(["git push *"]) + expect(await commandFamilies("FOO=1 npm install react")).toEqual(["npm install *"]) + const multi = await commandFamilies("cat foo.txt\ngit status") + expect(multi).toContain("cat *") + expect(multi).toContain("git status *") + expect(multi).toHaveLength(2) + }) + + test("unsandboxed retry uses generalized patterns instead of raw command", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const reqs: Array<{ permission: string; patterns: readonly string[]; always: readonly string[] }> = [] + const bash = await BashTool.init() + await bash.execute( + { + command: 'cat "$HOME/.ssh/secret"', + description: "Retries with family patterns", + }, + makeCtx(async (req) => { + reqs.push({ permission: req.permission, patterns: req.patterns, always: req.always }) + }), + ) + const unsandboxed = reqs.find((r) => r.permission === "bash:unsandboxed") + expect(unsandboxed).toBeDefined() + expect(unsandboxed!.patterns).toEqual(["cat *"]) + expect(unsandboxed!.always).toEqual(["cat *"]) + }, + }) + }) + + test("explicit unsandboxed request uses generalized patterns with raw command in metadata", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const reqs: Array<{ + permission: string + patterns: readonly string[] + always: readonly string[] + metadata: { command?: string } + }> = [] + const bash = await BashTool.init() + await bash.execute( + { + command: '# opencode:unsandboxed needs secret access\ncat "$HOME/.ssh/secret"', + description: "Proactive unsandboxed with family patterns", + }, + makeCtx(async (req) => { + reqs.push({ + permission: req.permission, + patterns: req.patterns, + always: req.always, + metadata: req.metadata, + }) + }), + ) + const unsandboxed = reqs.find((r) => r.permission === "bash:unsandboxed") + expect(unsandboxed).toBeDefined() + expect(unsandboxed!.patterns).toEqual(["cat *"]) + expect(unsandboxed!.always).toEqual(["cat *"]) + expect(unsandboxed!.metadata.command).toBe('cat "$HOME/.ssh/secret"') + }, + }) + }) + + test("multi-command unsandboxed uses per-command family patterns", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const reqs: Array<{ permission: string; patterns: readonly string[]; always: readonly string[] }> = [] + const bash = await BashTool.init() + await bash.execute( + { + command: '# opencode:unsandboxed env read\nFOO=1 cat "$HOME/.ssh/secret" && echo done', + description: "Multi-command unsandboxed family patterns", + }, + makeCtx(async (req) => { + reqs.push({ permission: req.permission, patterns: req.patterns, always: req.always }) + }), + ) + const unsandboxed = reqs.find((r) => r.permission === "bash:unsandboxed") + expect(unsandboxed).toBeDefined() + expect(unsandboxed!.patterns).toContain("cat *") + expect(unsandboxed!.patterns).toContain("echo *") + expect(unsandboxed!.always).toContain("cat *") + expect(unsandboxed!.always).toContain("echo *") + }, + }) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d14fab191949..5edc2c014ac5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1662,6 +1662,62 @@ export type Config = { * Enable the batch tool */ batch_tool?: boolean + sandbox?: { + /** + * Enable macOS sandboxing for bash, session shell commands, and PTY initial spawns + */ + enabled?: boolean + /** + * Named sandbox preset (default, strict, network, or a custom preset) + */ + preset?: string + /** + * Sandbox mode for command execution (default: preset default, otherwise workspace-write) + */ + mode?: "workspace-write" | "read-only" + /** + * Allow outbound network access inside the macOS sandbox + */ + network?: boolean + /** + * Workspace-relative paths that remain write-protected inside writable roots + */ + protected_roots?: Array + /** + * Additional read-only roots for macOS sandboxing + */ + extra_read_roots?: Array + /** + * Additional writable roots for macOS sandboxing + */ + extra_write_roots?: Array + /** + * Additional denied paths for macOS sandboxing + */ + extra_deny_paths?: Array + /** + * Command prefixes that must be blocked before execution + */ + excluded_commands?: Array + /** + * Allow an explicit unsandboxed retry after a sandbox denial + */ + allow_unsandboxed_retry?: boolean + /** + * Hard-fail when sandboxing is enabled but cannot activate + */ + fail_if_unavailable?: boolean + presets?: { + [key: string]: { + mode?: "workspace-write" | "read-only" + network?: boolean + protected_roots?: Array + extra_read_roots?: Array + extra_write_roots?: Array + permission?: PermissionConfig + } + } + } /** * Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag) */ diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 9c0c90c00076..b413814d3b11 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1825,7 +1825,12 @@ ToolRegistry.register({ const sawPending = pending() const text = createMemo(() => { const cmd = props.input.command ?? props.metadata.command ?? "" - const out = stripAnsi(props.output || props.metadata.output || "") + let out = props.output || props.metadata.output || "" + out = out + .replace(/[\s\S]*?(?:<\/bash_metadata>|$)/g, "") + .replace(/[\s\S]*?(?:<\/metadata>|$)/g, "") + .trim() + out = stripAnsi(out) return `$ ${cmd}${out ? "\n\n" + out : ""}` }) const [copied, setCopied] = createSignal(false) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 52ee1da0a383..fd1e445ee252 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -749,6 +749,51 @@ The `experimental` key contains options that are under active development. Experimental options are not stable. They may change or be removed without notice. ::: +### Sandbox + +OpenCode can sandbox bash commands, session shell commands, and PTY startup on macOS. +Sandboxing is experimental, opt-in, and disabled by default. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "experimental": { + "sandbox": { + "enabled": true, + "preset": "default", + "mode": "workspace-write", + "network": false, + "excluded_commands": ["rm"], + "allow_unsandboxed_retry": true, + "extra_read_roots": ["/Volumes/shared"] + } + } +} +``` + +Available options: + +| Option | Type | Description | +| ------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------- | +| `enabled` | `boolean` | Enable sandboxing for the supported macOS execution paths. | +| `preset` | `string` | Select a built-in preset (`default`, `strict`, `network`) or a custom preset name. | +| `mode` | `"workspace-write" \| "read-only"` | Override the preset mode. | +| `network` | `boolean` | Override whether outbound network access is allowed. | +| `protected_roots` | `string[]` | Workspace-relative paths that stay write-protected even inside writable roots. | +| `extra_read_roots` | `string[]` | Additional absolute paths the sandbox can read. | +| `extra_write_roots` | `string[]` | Additional absolute paths the sandbox can write. | +| `extra_deny_paths` | `string[]` | Additional absolute paths the sandbox must deny. | +| `excluded_commands` | `string[]` | Command prefixes that must be blocked before execution. | +| `allow_unsandboxed_retry` | `boolean` | Allow a separate `bash:unsandboxed` permission-gated retry after a sandbox denial. | +| `fail_if_unavailable` | `boolean` | Hard-fail when sandboxing is enabled but cannot be activated. | +| `presets` | `Record` | Define custom presets with `mode`, `network`, roots, and permission overrides. | + +:::note +Sandboxed commands can read built-in system roots such as `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp`, and `/private/etc`. +Sensitive home paths such as `~/.ssh`, `~/.gnupg`, and cloud credential directories remain denied by default. +See the [security policy](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) for the full threat model, covered surfaces, and current limitations. +::: + --- ## Variables diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index 6383b2a3f2da..639a6d6feebf 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -134,6 +134,7 @@ OpenCode permissions are keyed by tool name, plus a couple of safety guards: - `glob` — file globbing (matches the glob pattern) - `grep` — content search (matches the regex pattern) - `bash` — running shell commands (matches parsed commands like `git status --porcelain`) +- `bash:unsandboxed` — rerunning a shell command outside the sandbox after denial or after an explicit unsandboxed request - `task` — launching subagents (matches the subagent type) - `skill` — loading a skill (matches the skill name) - `lsp` — running LSP queries (currently non-granular) @@ -234,3 +235,11 @@ Only analyze code and suggest changes. :::tip Use pattern matching for commands with arguments. `"grep *"` allows `grep pattern file.txt`, while `"grep"` alone would block it. Commands like `git status` work for default behavior but require explicit permission (like `"git status *"`) when arguments are passed. ::: + +--- + +## Sandbox Interaction + +When macOS sandboxing is enabled, a blocked bash command can trigger a separate `bash:unsandboxed` permission request. +This happens when OpenCode detects a likely sandbox denial, or when the command explicitly asks to skip the sandbox with `# opencode:unsandboxed ` on the first non-empty line. +Configure the sandbox itself in [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/tools.mdx b/packages/web/src/content/docs/tools.mdx index f05e980b8ccf..5782079cf381 100644 --- a/packages/web/src/content/docs/tools.mdx +++ b/packages/web/src/content/docs/tools.mdx @@ -60,6 +60,12 @@ Execute shell commands in your project environment. This tool allows the LLM to run terminal commands like `npm install`, `git status`, or any other shell command. +:::note +When macOS sandboxing is enabled, bash runs with filesystem restrictions and can request a separate unsandboxed retry when needed. +If you know a command must start outside the sandbox, put `# opencode:unsandboxed ` on the first non-empty line of the command. +See [sandbox config](/docs/config#sandbox) for the supported behavior and limits. +::: + --- ### edit