From 21fbbe6217bc4c8b854b882c6ee03e66efbf103a Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Fri, 27 Mar 2026 18:29:16 +0100 Subject: [PATCH 01/23] phase 1 --- .../plans/macos-command-sandboxing-handoff.md | 631 ++++++++++++++++++ SECURITY.md | 30 +- packages/opencode/src/config/config.ts | 18 +- packages/opencode/src/flag/flag.ts | 3 + packages/opencode/src/sandbox/policy.ts | 84 +++ packages/opencode/src/sandbox/spawn.ts | 203 ++++++ packages/opencode/src/session/prompt.ts | 30 +- packages/opencode/src/tool/bash.ts | 41 +- packages/opencode/test/config/config.test.ts | 25 + packages/opencode/test/sandbox/policy.test.ts | 42 ++ packages/opencode/test/sandbox/spawn.test.ts | 98 +++ .../test/session/prompt-sandbox.test.ts | 129 ++++ .../opencode/test/tool/bash-sandbox.test.ts | 155 +++++ 13 files changed, 1473 insertions(+), 16 deletions(-) create mode 100644 .sisyphus/plans/macos-command-sandboxing-handoff.md create mode 100644 packages/opencode/src/sandbox/policy.ts create mode 100644 packages/opencode/src/sandbox/spawn.ts create mode 100644 packages/opencode/test/sandbox/policy.test.ts create mode 100644 packages/opencode/test/sandbox/spawn.test.ts create mode 100644 packages/opencode/test/session/prompt-sandbox.test.ts create mode 100644 packages/opencode/test/tool/bash-sandbox.test.ts diff --git a/.sisyphus/plans/macos-command-sandboxing-handoff.md b/.sisyphus/plans/macos-command-sandboxing-handoff.md new file mode 100644 index 000000000000..adb96f8b15df --- /dev/null +++ b/.sisyphus/plans/macos-command-sandboxing-handoff.md @@ -0,0 +1,631 @@ +# macOS Command Sandboxing Handoff Plan + +## Status + +This is a handoff plan only. +No implementation work is included. +The goal is to let another agent pick this up later without repeating discovery. + +## Objective + +Add real macOS command sandboxing to opencode for agent-issued shell commands. +The first shippable phase should cover only the two arbitrary shell-command paths: +`packages/opencode/src/tool/bash.ts` and `packages/opencode/src/session/prompt.ts`. +The enforcement mechanism should be macOS Seatbelt via `/usr/bin/sandbox-exec -p `. +The sandbox should sit below the existing permission prompts. + +## Why this is needed + +`SECURITY.md` explicitly says opencode does **not** sandbox the agent. +Today, the permission system is a UX gate, not a security boundary. +Once permission is granted, the command runs with the full privileges of the current user. + +## Confirmed repo facts + +### Existing arbitrary shell-command paths + +- `packages/opencode/src/tool/bash.ts` + - Parses the command for permission prompts. + - Calls `child_process.spawn` directly. + - Uses `Shell.acceptable()` from `packages/opencode/src/shell/shell.ts`. +- `packages/opencode/src/session/prompt.ts` + - Builds shell-specific invocation arguments. + - Calls `child_process.spawn` directly. + - Currently sources shell rc files for some shells before `eval`. + +### Shared spawn layers that exist, but should not be claimed as phase-1 coverage + +- `packages/opencode/src/util/process.ts` +- `packages/opencode/src/effect/cross-spawn-spawner.ts` +- `packages/opencode/src/lsp/launch.ts` +- `packages/opencode/src/lsp/server.ts` + +These are part of the larger spawn surface. +They matter for later expansion, but wiring them in phase 1 would silently broaden scope to LSP and internal tooling. + +### Explicitly out of scope for phase 1 + +- `packages/opencode/src/pty/index.ts` + - PTY / interactive shell path. + - High compatibility risk. +- `packages/opencode/src/mcp/index.ts` + - Local MCP server process launch. + - Separate risk surface. +- Linux. +- Windows. + +### Permission and config paths + +- `packages/opencode/src/config/config.ts` + - Current permission config surface. +- `packages/opencode/src/permission/index.ts` + - Current runtime permission enforcement. +- `packages/opencode/src/flag/flag.ts` + - Current env-flag surface. + +## Confirmed external evidence + +- Codex uses macOS Seatbelt via `/usr/bin/sandbox-exec -p `. +- Claude Code documents macOS Seatbelt and uses a localhost proxy for controlled networking. +- Anthropic's open-source sandbox runtime generates SBPL dynamically, + uses default-deny, + scopes file access to explicit roots, + and treats Unix sockets as dangerous. +- Oracle recommendation: + add one sandbox launcher under local command execution, + ship non-interactive flows first, + and do not pretend PTY has parity in v1. +- Red-team review: + do not overclaim broad spawn coverage, + do not allow broad `$HOME` reads, + do not leave Unix sockets open while claiming network deny, + and do not assume Bun-compiled darwin binaries work correctly with `sandbox-exec` until proven. + +## Phase-1 product statement + +If phase 1 ships successfully, +the correct security statement is: + +> On macOS, +> opencode can optionally sandbox agent-issued non-interactive shell commands from the bash tool and session command path. +> PTY sessions, +> MCP servers, +> LSP servers, +> and non-macOS platforms are not covered by this phase. + +Anything broader than that is inaccurate. + +## Phase-1 design + +### Design principles + +1. Make scope narrow and explicit. +2. Use a reusable sandbox module, + but only wire it into the two in-scope shell-command paths first. +3. Keep permission prompts as the UX layer. +4. Treat the sandbox as the OS enforcement layer. +5. Prefer deny-by-default over permissive compatibility shortcuts. +6. Avoid broad `$HOME` read access. +7. Deny Unix sockets in phase 1. +8. Prove Bun-compiled darwin compatibility before changing core execution paths. + +### New modules to add + +#### `packages/opencode/src/sandbox/policy.ts` + +Responsible for generating the Seatbelt policy string. +Inputs should be plain data. +Avoid hiding filesystem lookups inside the policy builder. + +Suggested inputs: + +- `cwd` +- `project_root` +- `worktree_root` +- `extra_read_roots` +- `extra_write_roots` +- `allow_network` +- `allow_unix_sockets` + +Suggested output: + +- one SBPL string for use with `/usr/bin/sandbox-exec -p ` + +#### `packages/opencode/src/sandbox/spawn.ts` + +Responsible for the macOS-specific wrapper logic. +This module should not decide product policy. +It should only translate validated inputs into a wrapped command. + +Suggested responsibilities: + +- detect whether sandboxing is enabled +- guard on `process.platform === "darwin"` +- guard on `/usr/bin/sandbox-exec` existing +- build the wrapped argv array +- provide structured diagnostics when the wrapper cannot run + +Suggested shape: + +- a pure helper that returns wrapped argv +- a small runtime helper that executes or delegates to existing spawn logic + +### Files to change in phase 1 + +#### `packages/opencode/src/tool/bash.ts` + +Current state: + +- computes permission patterns +- asks for `external_directory` and `bash` +- then spawns the command directly + +Planned change: + +- after permission is granted, + route execution through the macOS sandbox wrapper when sandboxing is enabled +- do not rely on Node's `shell: true` path in sandbox mode +- instead, + build an explicit shell command argv using `Shell.acceptable()` + and pass that argv through the wrapper + +Why: + +- sandboxing an explicit argv is easier to reason about than stacking `shell: true` under the wrapper +- it makes the executed shell binary visible and testable + +#### `packages/opencode/src/session/prompt.ts` + +Current state: + +- builds shell-specific args +- directly spawns the shell +- currently sources shell rc files in some code paths + +Planned change: + +- route the final shell argv through the same sandbox wrapper in sandbox mode +- do **not** expand the policy to broad `$HOME` reads just to preserve shell personalization +- prefer deterministic shell startup in sandbox mode, + even if that means reducing or disabling rc-file sourcing there + +Sharp edge: + +- this path currently reads `~/.zshenv`, + `~/.zshrc`, + and `~/.bashrc` in some cases +- broad `$HOME` reads are too risky +- phase 1 should favor a minimal shell environment plus the existing `shell.env` plugin hook, + not full shell personalization + +#### `packages/opencode/src/config/config.ts` + +Add an opt-in config surface. +Keep it under `experimental` for phase 1. + +Suggested shape: + +```ts +experimental: { + sandbox: { + enabled?: boolean + extra_read_roots?: string[] + extra_write_roots?: string[] + allow_unsandboxed_retry?: boolean + } +} +``` + +Notes: + +- keep the default `enabled` value `false` for the first rollout +- use explicit read and write roots, + not a vague `allowPaths` +- `allow_unsandboxed_retry` must default to `false` + +#### `packages/opencode/src/flag/flag.ts` + +Add an env override for local testing. + +Suggested flag: + +- `OPENCODE_EXPERIMENTAL_SANDBOX` + +This should be an override, +not the only control surface. + +#### `SECURITY.md` + +Update only after validation passes. +The update must name both coverage and exclusions. +Do not describe the feature as general command sandboxing across the product. + +## Phase-1 policy + +### High-level posture + +- default deny +- explicit read roots +- explicit write roots +- no outbound network +- no Unix sockets +- no broad `$HOME` reads + +### Allow rules the implementation will likely need + +These are policy categories, +not exact final SBPL syntax. + +#### Read-only roots + +- project root +- current worktree root +- `/bin` +- `/usr` +- `/System` +- `/Library` +- `/tmp` +- `/private/tmp` +- `/dev` +- `/opt/homebrew` on Apple Silicon machines, + if read-only access is needed +- `/usr/local` on Intel/Homebrew machines, + if read-only access is needed +- `/nix/store` only if the operator explicitly adds it + +#### Writable roots + +- project root +- current worktree root +- temp dir only if required by actual command behavior +- explicitly configured extra write roots + +Keep this list small. +Do not open general cache directories until a concrete failing workflow requires it. + +### Explicit deny intent + +Phase 1 should explicitly deny access to: + +- outbound network +- loopback network unless a later phase introduces controlled mediation +- Unix sockets +- credential-heavy home paths, + including at minimum: + - `~/.ssh` + - `~/.gnupg` + - `~/.aws` + - `~/.azure` + - `~/.config/gcloud` + - `~/.netrc` + - `~/.npmrc` + - opencode config and state directories + +Do not loosen this just to reduce breakage. +Breakage is preferable to fake security. + +## Interaction with existing permissions + +The existing permission system stays in place. + +Correct mental model: + +- permission prompt decides whether the agent is allowed to attempt the tool call +- sandbox decides what the spawned command can actually do on the host + +This means: + +- permission allow + sandbox deny = command still denied by sandbox +- permission deny = command denied before sandbox execution + +If the product wants a rerun outside the sandbox, +that must be explicit. + +Recommended product rule: + +- add a separate unsandboxed retry path only if needed +- gate it behind a distinct permission +- never widen the sandbox automatically after a denial + +## Rollout plan + +### Phase 0 — inventory and proof work + +Goal: +prove the wrapper mechanism before integrating it. + +Steps: + +1. Audit the full spawn surface once, + and write down the classification: + - shell-command + - internal-tool + - mcp + - pty + - cli-only +2. Build a small local proof that a Bun-compiled darwin binary can launch a child via + `/usr/bin/sandbox-exec -p `. +3. Prove that inline `-p` policy strings work correctly, + so the implementation does not need temp profile files. + +Exit criteria: + +- confirmed compatibility on a macOS host +- concrete list of uncovered spawn families for later phases + +QA for this phase: + +- from `packages/opencode`, run `bun run build -- --single` +- on Apple Silicon, run `./dist/opencode-darwin-arm64/bin/opencode --version` +- on Intel, run `./dist/opencode-darwin-x64/bin/opencode --version` +- then run the same compiled binary through Seatbelt with a proof-only profile: + - Apple Silicon: + `/usr/bin/sandbox-exec -p '(version 1) (allow default)' ./dist/opencode-darwin-arm64/bin/opencode --version` + - Intel: + `/usr/bin/sandbox-exec -p '(version 1) (allow default)' ./dist/opencode-darwin-x64/bin/opencode --version` +- expected result: + - both commands exit `0` + - both print a version string + - the wrapped binary does not crash or fail due to Bun compile/runtime issues +- optional extra proof from the same macOS shell: + `/usr/bin/sandbox-exec -p '(version 1) (allow default) (deny network*)' /bin/bash -lc 'curl -I https://example.com'` +- expected result: + - the curl command fails with a network denial + - this proves the host accepts inline `-p` policies before opencode integration starts + +### Phase 1a — reusable sandbox modules + +Goal: +land `sandbox/policy.ts` and `sandbox/spawn.ts` with no behavior change by default. + +Steps: + +1. Add config parsing and env override. +2. Add the policy builder. +3. Add the wrapper helper. +4. Add unit tests for profile generation and wrapper argv generation. + +Exit criteria: + +- code compiles +- tests pass +- feature remains off by default + +QA for this phase: + +- add dedicated tests at: + - `packages/opencode/test/sandbox/policy.test.ts` + - `packages/opencode/test/sandbox/spawn.test.ts` +- from `packages/opencode`, run `bun run typecheck` +- from `packages/opencode`, run `bun test --timeout 30000 test/sandbox/policy.test.ts test/sandbox/spawn.test.ts` +- expected result: + - typecheck exits `0` + - both sandbox unit test files pass + - with sandbox disabled by default, existing behavior is unchanged + +### Phase 1b — integrate `bash.ts` + +Goal: +sandbox the bash tool on macOS when enabled. + +Steps: + +1. keep the existing permission flow intact +2. switch sandboxed execution to explicit shell argv +3. add integration tests for allowed and denied behavior +4. verify abort and timeout behavior still work through the wrapper + +Exit criteria: + +- bash tool still works for in-project read and write operations +- denied operations fail predictably +- timeout and abort still terminate the child correctly + +QA for this phase: + +- add focused coverage in either: + - `packages/opencode/test/tool/bash.test.ts`, or + - a new `packages/opencode/test/tool/bash-sandbox.test.ts` +- from `packages/opencode`, run: + - `bun test --timeout 30000 test/tool/bash.test.ts` + if the existing file is extended, or + - `bun test --timeout 30000 test/tool/bash-sandbox.test.ts` + if a dedicated file is added +- expected result: + - a command that reads or writes inside the fixture project succeeds + - a command that writes outside the project root fails + - a command that attempts a sensitive home-path read fails + - timeout still terminates the wrapped child + - abort still terminates the wrapped child + +### Phase 1c — integrate `session/prompt.ts` + +Goal: +sandbox the session-command execution path on macOS when enabled. + +Steps: + +1. route the final shell argv through the wrapper +2. simplify shell startup in sandbox mode if needed +3. do not grant broad home-directory reads to preserve rc loading +4. test command output streaming and abort behavior + +Exit criteria: + +- session command execution works in sandbox mode for allowed operations +- denied operations fail cleanly +- no broad `$HOME` policy expansion was added to preserve shell rc loading + +QA for this phase: + +- add focused coverage in either: + - `packages/opencode/test/session/prompt.test.ts`, or + - a new `packages/opencode/test/session/prompt-sandbox.test.ts` +- from `packages/opencode`, run: + - `bun test --timeout 30000 test/session/prompt.test.ts` + if the existing file is extended, or + - `bun test --timeout 30000 test/session/prompt-sandbox.test.ts` + if a dedicated file is added +- expected result: + - an allowed command still streams output correctly + - a denied filesystem action fails cleanly + - rc-file behavior in sandbox mode is deterministic and documented + - no test requires broad `$HOME` reads to pass + +### Phase 1d — docs and release guardrails + +Goal: +ship accurate operator-facing documentation. + +Steps: + +1. update `SECURITY.md` +2. document opt-in enablement +3. document exclusions: + PTY, + MCP, + LSP, + non-macOS +4. document how sandbox denials should be interpreted + +Exit criteria: + +- docs match actual coverage +- no overclaiming remains + +QA for this phase: + +- from the repo root, run: + `grep -n "non-interactive shell commands\|PTY\|MCP\|LSP\|non-macOS" SECURITY.md` +- expected result: + - `SECURITY.md` explicitly states macOS-only, + opt-in, + non-interactive shell-command coverage + - `SECURITY.md` explicitly names PTY, + MCP, + LSP, + and non-macOS as exclusions + - no section implies that all local process execution is sandboxed + +## Validation plan + +### Unit tests + +- profile generation for default-deny policy +- wrapper argv generation on darwin +- no-op wrapper behavior on non-darwin +- config parsing for `experimental.sandbox` + +### macOS integration tests + +These must run on a macOS host. + +#### Core command behavior + +1. allowed read inside project root succeeds +2. allowed write inside project root succeeds +3. write outside project root fails +4. read of `~/.ssh` fails +5. outbound HTTP request fails +6. Unix socket access fails + +#### Control flow behavior + +7. timeout still kills the wrapped process +8. abort still kills the wrapped process +9. stderr and stdout still stream correctly + +#### Product behavior + +10. sandbox disabled => current behavior preserved +11. non-darwin => current behavior preserved +12. permission allow does not override sandbox deny + +### Manual verification + +Before updating `SECURITY.md`: + +1. build the darwin binary with the existing Bun compile pipeline +2. run a smoke test using the compiled binary, + not just source mode +3. verify that the wrapper still works there +4. inspect failure output for usability + +## Risks and sharp edges + +### `sandbox-exec` deprecation + +This is a real risk. +The current evidence still points to Seatbelt via `sandbox-exec` as the practical macOS path, +and Codex plus Claude Code both rely on Seatbelt today. +Still, +the implementation should document this as a phase-1 compromise, +not a forever-stable API choice. + +### Shell startup behavior drift + +`session/prompt.ts` currently sources user shell rc files. +Sandbox mode likely cannot preserve that safely. +Expect behavioral differences there. +Prefer deterministic execution over expansive read permissions. + +### Hidden path dependencies + +Toolchains may need read access to paths not obvious from the first pass, +especially Homebrew and Xcode-related locations. +This is exactly why the feature should ship opt-in first. + +### Timeout and kill semantics through the wrapper + +The wrapper adds another process layer. +Abort and timeout behavior must be revalidated, +not assumed. + +### False security if scope is described too broadly + +If documentation or release notes imply that all local process execution is sandboxed, +that will be incorrect. +Phase 1 is intentionally narrower. + +## Open questions + +1. Should the session-command path in sandbox mode skip rc-file sourcing entirely, + or allow a small explicit set of startup files? +2. Which read-only system paths are actually required on Apple Silicon versus Intel Macs? +3. Does any in-scope workflow require loopback access in phase 1, + or can loopback stay denied? +4. Is an explicit unsandboxed retry path needed in phase 1, + or can it wait until phase 2? +5. Should MCP sandboxing be the next follow-up after phase 1, + given its risk profile? + +## Pickup checklist for the next agent + +1. Read this file fully before touching code. +2. Re-read these repo files before planning edits: + - `packages/opencode/src/tool/bash.ts` + - `packages/opencode/src/session/prompt.ts` + - `packages/opencode/src/config/config.ts` + - `packages/opencode/src/permission/index.ts` + - `packages/opencode/src/shell/shell.ts` +3. Start with Phase 0 proof work. +4. Do **not** start by wiring `util/process.ts` or `cross-spawn-spawner.ts` broadly. +5. Keep phase-1 implementation limited to `bash.ts` and `session/prompt.ts` unless the proof work changes scope explicitly. +6. Do **not** allow broad `$HOME` reads. +7. Do **not** leave Unix sockets open while claiming network deny. +8. Do **not** update `SECURITY.md` until the macOS validation matrix passes. + +## Suggested first implementation sequence + +1. prove `sandbox-exec -p` works correctly with the Bun-compiled darwin binary +2. add sandbox config and env-flag parsing +3. add `sandbox/policy.ts` +4. add `sandbox/spawn.ts` +5. wire `bash.ts` +6. validate +7. wire `session/prompt.ts` +8. validate again +9. update docs + +That sequence minimizes the chance of broad regressions, +and it keeps the first shipped claim narrow and accurate. diff --git a/SECURITY.md b/SECURITY.md index e7e59f4a27ac..c979e505cdce 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,11 +12,21 @@ 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 -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. +On macOS, OpenCode can optionally sandbox agent-issued non-interactive shell commands executed through the bash tool and the session command execution path. This is opt-in and off by default. -If you need true isolation, run OpenCode inside a Docker container or VM. +The following are **not** covered by the sandbox: + +- PTY sessions (interactive shells) +- MCP server processes +- LSP server processes +- Other local process launches +- All non-macOS platforms + +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 +34,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)** | PTY, MCP, LSP, and non-macOS execution 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/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 55684fc70dfb..2fd19eba5ff9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -220,6 +220,22 @@ 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( + Schema.Struct({ + enabled: Schema.optional(Schema.Boolean).annotate({ + description: "Enable macOS sandboxing for non-interactive shell commands", + }), + 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", + }), + allow_unsandboxed_retry: Schema.optional(Schema.Boolean).annotate({ + description: "Allow an explicit unsandboxed retry after a sandbox denial", + }), + }), + ), openTelemetry: Schema.optional(Schema.Boolean).annotate({ description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)", }), @@ -258,7 +274,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() 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/sandbox/policy.ts b/packages/opencode/src/sandbox/policy.ts new file mode 100644 index 000000000000..8774d790efa6 --- /dev/null +++ b/packages/opencode/src/sandbox/policy.ts @@ -0,0 +1,84 @@ +import path from "path" + +export namespace SandboxPolicy { + export interface Input { + cwd: string + project_root: string + worktree_root: string + home: string + extra_read_roots?: string[] + extra_write_roots?: string[] + opencode_roots?: string[] + allow_network?: boolean + allow_unix_sockets?: boolean + } + + export interface Output { + profile: string + read: string[] + write: string[] + deny: string[] + } + + const read = ["/bin", "/sbin", "/usr", "/System", "/Library", "/dev", "/tmp", "/private/tmp", "/private/etc"] + 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)}"))`, + ]) + } + + export function build(input: Input): Output { + const denyRoots = uniq([...secret.map((item) => path.join(input.home, item)), ...(input.opencode_roots ?? [])]) + const readRoots = uniq([ + input.cwd, + input.project_root, + input.worktree_root, + ...read, + ...(input.extra_read_roots ?? []), + ]) + const writeRoots = 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), + ...(input.allow_network ? ["(allow network*)"] : []), + ...(input.allow_unix_sockets + ? [ + "(allow system-socket (socket-domain AF_UNIX))", + "(allow network-bind (local unix-socket))", + "(allow network-outbound (remote unix-socket))", + ] + : []), + ].join("\n") + return { + profile, + read: readRoots, + write: writeRoots, + deny: denyRoots, + } + } +} diff --git a/packages/opencode/src/sandbox/spawn.ts b/packages/opencode/src/sandbox/spawn.ts new file mode 100644 index 000000000000..d803c195786b --- /dev/null +++ b/packages/opencode/src/sandbox/spawn.ts @@ -0,0 +1,203 @@ +import { Config } from "@/config/config" +import { Flag } from "@/flag/flag" +import { Global } from "@/global" +import { Log } from "@/util/log" +import { Filesystem } from "@/util/filesystem" +import os from "os" +import { SandboxPolicy } from "./policy" + +const log = Log.create({ service: "sandbox" }) +const bin = "/usr/bin/sandbox-exec" + +export namespace SandboxSpawn { + export interface Diag { + requested: boolean + active: boolean + reason: "disabled" | "unsupported_platform" | "sandbox_exec_missing" | "unsafe_root" | "enabled" + wrapper: string + cwd: string + read_roots: string[] + write_roots: string[] + unsafe_roots: string[] + allow_network: boolean + allow_unix_sockets: boolean + } + + export interface ResolveInput { + cwd: string + project_root: string + worktree_root: string + allow_network?: boolean + allow_unix_sockets?: boolean + } + + export interface PlanInput extends ResolveInput { + requested: boolean + platform: NodeJS.Platform + available: boolean + home: string + opencode_roots?: string[] + extra_read_roots?: string[] + extra_write_roots?: 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 + } + } + + function uniq(input: string[]) { + return [...new Set(input.filter(Boolean))].toSorted((a, b) => a.localeCompare(b)) + } + + 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, + read_roots: [], + write_roots: [], + unsafe_roots: [], + allow_network: input.allow_network === true, + allow_unix_sockets: input.allow_unix_sockets === true, + } satisfies Diag + } + + export function plan(input: PlanInput): Output { + if (!input.requested) { + return { active: false, diag: base(input, "disabled") } + } + + if (input.platform !== "darwin") { + return { active: false, diag: base(input, "unsupported_platform") } + } + + if (!input.available) { + throw new Error(base(input, "sandbox_exec_missing")) + } + + const read = scan( + [...(input.extra_read_roots ?? []), input.cwd, input.project_root, input.worktree_root], + input.home, + ) + const write = scan( + [...(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, + opencode_roots: input.opencode_roots, + allow_network: input.allow_network, + allow_unix_sockets: input.allow_unix_sockets, + }) + + const diag = { + requested: true, + active: true, + reason: "enabled", + wrapper: bin, + cwd: input.cwd, + read_roots: policy.read, + write_roots: policy.write, + unsafe_roots: [], + allow_network: input.allow_network === true, + allow_unix_sockets: input.allow_unix_sockets === 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): Promise { + const cfg = await Config.get() + const env = process.env["OPENCODE_EXPERIMENTAL_SANDBOX"] + const raw = cfg.experimental?.sandbox + const home = Filesystem.resolve(Global.Path.home) + const tmp = Filesystem.resolve(os.tmpdir()) + const temp = Filesystem.contains(tmp, home) ? [] : [tmp] + const requested = env === undefined ? raw?.enabled === true : Flag.OPENCODE_EXPERIMENTAL_SANDBOX + const out = plan({ + 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, + opencode_roots: [Global.Path.data, Global.Path.config, Global.Path.state, Global.Path.cache].map( + Filesystem.resolve, + ), + extra_read_roots: [...(raw?.extra_read_roots ?? []), ...temp].map(Filesystem.resolve), + extra_write_roots: [...(raw?.extra_write_roots ?? []), ...temp].map(Filesystem.resolve), + allow_network: input.allow_network, + allow_unix_sockets: input.allow_unix_sockets, + }) + + 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..b9f635388114 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -819,15 +819,39 @@ NOTE: At any point in time through this workflow you should feel free to ask the "": { args: ["-c", input.command] }, } - const args = (invocations[shellName] ?? invocations[""]).args + const clean: Record = { + nu: { args: ["-c", input.command] }, + fish: { args: ["-c", input.command] }, + zsh: { args: ["-f", "-c", input.command] }, + bash: { args: ["--noprofile", "--norc", "-c", input.command] }, + cmd: { args: ["/c", input.command] }, + powershell: { args: ["-NoProfile", "-Command", input.command] }, + pwsh: { args: ["-NoProfile", "-Command", input.command] }, + "": { args: ["-c", input.command] }, + } + 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, { + const root = ctx.worktree === "/" ? ctx.directory : ctx.worktree + const sandbox = yield* Effect.promise(() => + SandboxSpawn.resolve({ + cwd, + project_root: ctx.directory, + worktree_root: root, + }), + ) + const args = + (sandbox.active ? clean : invocations)[shellName]?.args ?? (sandbox.active ? clean[""] : invocations[""]).args + const call = + sandbox.active && sandbox.profile + ? SandboxSpawn.wrap({ profile: sandbox.profile, file: sh, args }) + : { file: sh, args } + + const cmd = ChildProcess.make(call.file, call.args, { cwd, extendEnv: true, env: { ...shellEnv.env, TERM: "dumb" }, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 6260b22216e2..f7877ff87350 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -20,6 +20,7 @@ import { Plugin } from "@/plugin" import { Effect, 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,13 @@ 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 === "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,7 +289,35 @@ 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) { +function argv(name: string, command: string) { + 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] +} + +async function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { + const root = Instance.worktree === "/" ? Instance.directory : Instance.worktree + const sandbox = await SandboxSpawn.resolve({ + cwd, + project_root: Instance.directory, + worktree_root: root, + }) + + if (sandbox.active && sandbox.profile) { + const wrap = SandboxSpawn.wrap({ + profile: sandbox.profile, + file: shell, + args: argv(name, command), + }) + return ChildProcess.make(wrap.file, wrap.args, { + cwd, + env, + stdin: "ignore", + detached: process.platform !== "win32", + }) + } + if (process.platform === "win32" && PS.has(name)) { return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { cwd, @@ -442,7 +478,8 @@ 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)) + const proc = yield* Effect.promise(() => cmd(input.shell, input.name, input.command, input.cwd, input.env)) + const handle = yield* spawner.spawn(proc) yield* Effect.forkScoped( Stream.runForEach(Stream.decodeText(handle.all), (chunk) => { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 9f2bf9db9a53..41170feea90d 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -142,6 +142,31 @@ test("loads JSON config file", async () => { }) }) +test("loads experimental sandbox config", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + extra_read_roots: ["/tmp/read"], + extra_write_roots: ["/tmp/write"], + allow_unsandboxed_retry: false, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.experimental?.sandbox?.enabled).toBe(true) + expect(config.experimental?.sandbox?.extra_read_roots).toEqual(["/tmp/read"]) + expect(config.experimental?.sandbox?.extra_write_roots).toEqual(["/tmp/write"]) + expect(config.experimental?.sandbox?.allow_unsandboxed_retry).toBe(false) + }, + }) +}) + test("loads formatter boolean config", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/sandbox/policy.test.ts b/packages/opencode/test/sandbox/policy.test.ts new file mode 100644 index 000000000000..64fd784ab46a --- /dev/null +++ b/packages/opencode/test/sandbox/policy.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { SandboxPolicy } from "../../src/sandbox/policy" + +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"], + }) + + 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")) + }) + + test("adds network and unix socket 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, + allow_unix_sockets: true, + }) + + expect(out.profile).toContain("(allow network*)") + expect(out.profile).toContain("AF_UNIX") + expect(out.profile).toContain("network-bind") + expect(out.profile).toContain("network-outbound") + }) +}) diff --git a/packages/opencode/test/sandbox/spawn.test.ts b/packages/opencode/test/sandbox/spawn.test.ts new file mode 100644 index 000000000000..3483b1e08143 --- /dev/null +++ b/packages/opencode/test/sandbox/spawn.test.ts @@ -0,0 +1,98 @@ +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("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("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..cf571435a993 --- /dev/null +++ b/packages/opencode/test/session/prompt-sandbox.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Instance } from "../../src/project/instance" +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, +} + +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({ + 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 Session.create({}) + const out = await SessionPrompt.shell({ + 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 Session.remove(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 using tmp = await tmpdir({ + 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 Session.create({}) + const denied = await SessionPrompt.shell({ + 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 Session.create({}) + const run = SessionPrompt.shell({ + sessionID: next.id, + agent: "build", + command: "sleep 5", + }) + setTimeout(() => { + void SessionPrompt.cancel(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 Session.remove(session.id) + await Session.remove(next.id) + }, + }) + }) +}) 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..057341a0ec8f --- /dev/null +++ b/packages/opencode/test/tool/bash-sandbox.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { BashTool } from "../../src/tool/bash" +import { Instance } from "../../src/project/instance" +import { SessionID, MessageID } from "../../src/session/schema" +import { tmpdir } from "../fixture/fixture" + +const env = { + HOME: process.env.HOME, + OPENCODE_TEST_HOME: process.env.OPENCODE_TEST_HOME, + SHELL: process.env.SHELL, +} + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +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 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: 'cat "$HOME/.ssh/secret"', + description: "Reads blocked home file", + }, + ctx, + ) + expect(out.output).not.toContain("secret\n") + expect(out.output).toContain("Operation not permitted") + }, + }) + }) + + 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") + }, + }) + }) +}) From fbdd85ef0a1702d3857ed25cec177352f813a677 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Fri, 27 Mar 2026 20:15:05 +0100 Subject: [PATCH 02/23] docs: update SECURITY.md for macOS sandbox phase 2 --- ...macos-command-sandboxing-phase-2-parity.md | 710 +++++++++++++ ...-sandboxing-phase-3-presets-and-runtime.md | 953 ++++++++++++++++++ SECURITY.md | 52 +- 3 files changed, 1702 insertions(+), 13 deletions(-) create mode 100644 .sisyphus/plans/macos-command-sandboxing-phase-2-parity.md create mode 100644 .sisyphus/plans/macos-command-sandboxing-phase-3-presets-and-runtime.md diff --git a/.sisyphus/plans/macos-command-sandboxing-phase-2-parity.md b/.sisyphus/plans/macos-command-sandboxing-phase-2-parity.md new file mode 100644 index 000000000000..f6ce24aa92e2 --- /dev/null +++ b/.sisyphus/plans/macos-command-sandboxing-phase-2-parity.md @@ -0,0 +1,710 @@ +# macOS Command Sandboxing Phase 2: Parity Plan + +## Status + +This is a handoff plan for phase 2 of macOS command sandboxing. +No implementation work is included. +Another engineer MUST be able to implement phase 2 from this plan without redoing discovery. + +## Objective + +Extend macOS Seatbelt sandboxing to cover all agent-visible local command surfaces, +implement sandbox-first execution with explicit host-level retry, +and close the most impactful gaps relative to Codex and Claude Code sandbox capabilities. + +Phase 2 does NOT aim for full parity with either product. +It aims to get materially closer while keeping claims truthful. + +## Phase-1 baseline + +Phase 1 shipped sandbox coverage for exactly two non-interactive shell-command paths: + +- `packages/opencode/src/tool/bash.ts` (the bash tool) +- `packages/opencode/src/session/prompt.ts` (`SessionPrompt.shell()`) + +Phase 1 also delivered: + +- `packages/opencode/src/sandbox/policy.ts` — SBPL profile builder +- `packages/opencode/src/sandbox/spawn.ts` — macOS wrapper, plan/resolve/wrap helpers +- Config surface under `experimental.sandbox` with `enabled`, `extra_read_roots`, `extra_write_roots`, `allow_unsandboxed_retry` +- `OPENCODE_EXPERIMENTAL_SANDBOX` env override via `flag/flag.ts` +- Default-deny posture with explicit read/write roots, credential-path deny rules, no network, no Unix sockets + +Phase 1 explicitly does NOT cover: + +- `packages/opencode/src/pty/index.ts` (PTY / interactive shell) +- MCP server process launch (`mcp/index.ts`) +- LSP server launch (`lsp/launch.ts`, `lsp/server.ts`) +- Shared spawn layers (`util/process.ts`, `effect/cross-spawn-spawner.ts`) +- Linux or Windows +- `allow_unsandboxed_retry` runtime behavior (config key exists, runtime does not implement it) + +--- + +## Parity targets + +### What Codex documents + +Codex's sandbox model exposes three documented knobs: + +- `sandbox_mode`: `read-only`, `workspace-write`, `danger-full-access` +- `approval_policy`: separate layer from sandbox +- `sandbox_workspace_write.writable_roots`: explicit writable path set + +Sandbox and approval are independent layers. +The agent can run with a restrictive sandbox and a permissive approval policy, or vice versa. + +### What Claude Code documents + +Claude Code's sandbox model documents: + +- Allow/deny filesystem controls scoped to explicit paths +- Domain-based and proxy-mediated network policy +- Excluded commands (command-level deny list) +- Intentional unsandboxed escape hatch +- `sandbox.failIfUnavailable` to hard-fail when the sandbox cannot activate + +### What phase 2 targets + +Phase 2 SHOULD close these gaps: + +| Capability | Codex | Claude | Phase 2 target | +| ---------------------------------------------------------- | --------------------------------- | ------------------------------- | ----------------- | +| PTY / interactive shell sandboxing | Supported (manual verification) | Supported (manual verification) | Yes | +| Sandbox-first with explicit host retry | Implicit via `danger-full-access` | Explicit escape hatch | Yes | +| Minimal sandbox modes | Three modes | N/A | Two modes minimum | +| Richer filesystem policy (excluded paths, credential deny) | Writable roots | Allow/deny lists | Yes | +| Excluded commands | Not documented | Documented | Yes | +| Fail-if-unavailable | Implicit (always sandboxed) | `sandbox.failIfUnavailable` | Yes | +| Domain/proxy network mediation | Not documented | Documented | No (later phase) | +| MCP sandboxing | Not documented | Not documented | No (later phase) | +| LSP sandboxing | N/A | N/A | No (later phase) | + +PTY interactive shell support by Codex and Claude Code was confirmed through manual verification, +not through public documentation stronger than what those docs support. +Phase 2 treats this as established practice, not a guaranteed stable API contract. + +--- + +## Non-goals for phase 2 + +Phase 2 MUST NOT: + +1. Sandbox MCP server launches (`mcp/index.ts`). + MCP processes run operator-configured commands with their own trust model. + Sandboxing them requires separate design work around MCP-specific policy. + +2. Sandbox LSP server launches (`lsp/launch.ts`, `lsp/server.ts`). + LSP servers are internal infrastructure, not agent-visible command surfaces. + +3. Wire `util/process.ts` or `effect/cross-spawn-spawner.ts` through the sandbox. + These are shared spawn utilities used by internal tooling, formatters, and infrastructure. + Broadly wiring them would silently sandbox non-agent processes. + +4. Implement domain-based or proxy-mediated network controls. + Claude Code's localhost proxy model is a substantial piece of infrastructure. + Phase 2 SHOULD keep the current posture: network denied by default, optionally allowed via policy. + +5. Support Linux or Windows sandboxing. + macOS Seatbelt only. + +6. Move sandbox config out of `experimental`. + Phase 2 expands capabilities but the feature remains opt-in and experimental. + +--- + +## Concrete files and modules + +### Files to change + +#### `packages/opencode/src/pty/index.ts` + +Current state: + +- `Pty.create()` calls `bun-pty` `spawn()` directly at line 201 +- The spawn call passes `command`, `args`, `cwd`, and `env` to the native PTY +- No sandbox integration exists +- This is a major uncovered surface: interactive shells have full user privileges + +Planned change: + +- Before spawning, resolve sandbox plan via `SandboxSpawn.resolve()` +- When sandbox is active, wrap the PTY spawn command through `sandbox-exec` +- Pass the wrapped command/args to `bun-pty` `spawn()` instead of the raw shell +- The PTY process itself runs inside the sandbox; I/O streaming is unaffected because `sandbox-exec` preserves stdin/stdout/stderr +- Sandbox mode for PTY SHOULD use the same filesystem policy as `bash.ts` +- Shell rc-file sourcing in PTY sandbox mode SHOULD be addressed the same way as `session/prompt.ts`: prefer deterministic startup, do not grant broad `$HOME` reads + +Sharp edges: + +- `bun-pty` spawns a native PTY via `forkpty()`. + The sandbox wraps the _command_ that the PTY executes, not the PTY allocation itself. + This means the call becomes `bun-pty.spawn("sandbox-exec", ["-p", profile, shell, ...args], ...)`. + This MUST be validated: the PTY allocator needs to handle `sandbox-exec` as the executable. +- Interactive programs (vim, htop) inside the sandbox will be subject to filesystem policy. + This is expected behavior, not a bug. +- Terminal resize, `onData`, and `onExit` callbacks SHOULD be unaffected because they operate on the PTY fd, not the child process identity. + +#### `packages/opencode/src/sandbox/spawn.ts` + +Current state: + +- `resolve()` reads config, detects platform, builds plan +- `plan()` validates roots, builds policy, returns active/inactive state +- `wrap()` produces `{ file: "sandbox-exec", args: ["-p", profile, ...] }` + +Planned changes: + +- Add `mode` support: accept a sandbox mode parameter and translate it to policy constraints +- Add `excluded_commands` checking: before wrapping, check the command against a deny list and throw a structured error if matched +- Add `fail_if_unavailable` behavior: when config says sandbox MUST be active but `sandbox-exec` is missing, throw a hard error instead of falling back to unsandboxed execution +- Add `unsandboxed_retry` support: export a helper that re-runs a command without the sandbox wrapper, gated behind explicit permission + +#### `packages/opencode/src/sandbox/policy.ts` + +Current state: + +- `SandboxPolicy.build()` generates SBPL from structured input +- Supports read roots, write roots, deny roots, network, Unix sockets + +Planned changes: + +- Accept a `mode` input that maps to predefined policy profiles +- Add `excluded_commands` to the input; these are not SBPL rules but pre-spawn checks +- Optionally accept `extra_deny_paths` for operator-configured deny rules beyond the default credential set + +#### `packages/opencode/src/config/config.ts` + +Current state (lines 1020-1036): + +```ts +sandbox: z.object({ + enabled: z.boolean().optional(), + extra_read_roots: z.array(z.string()).optional(), + extra_write_roots: z.array(z.string()).optional(), + allow_unsandboxed_retry: z.boolean().optional(), +}).optional() +``` + +Planned changes — evolve the schema to: + +```ts +sandbox: z.object({ + enabled: z.boolean().optional(), + mode: z.enum(["workspace-write", "read-only"]).optional(), + extra_read_roots: z.array(z.string()).optional(), + extra_write_roots: z.array(z.string()).optional(), + extra_deny_paths: z.array(z.string()).optional(), + excluded_commands: z.array(z.string()).optional(), + allow_unsandboxed_retry: z.boolean().optional(), + fail_if_unavailable: z.boolean().optional(), +}).optional() +``` + +Field semantics: + +- `mode`: defaults to `"workspace-write"`. + `"workspace-write"` allows writes to project root and configured extra write roots. + `"read-only"` denies all writes except `/tmp` and explicitly configured paths. +- `extra_deny_paths`: operator-specified paths to deny beyond the default credential set. +- `excluded_commands`: command prefixes that MUST NOT execute even inside the sandbox. + If the agent attempts an excluded command, execution MUST fail with a structured error before spawning. +- `fail_if_unavailable`: when `true`, sandbox activation failure (missing `sandbox-exec`, unsupported platform) is a hard error. + Default `false` for backward compatibility. + +#### `packages/opencode/src/tool/bash.ts` + +Current state: + +- Already wired through `SandboxSpawn.resolve()` and `SandboxSpawn.wrap()` +- No excluded-command checking +- No unsandboxed retry + +Planned changes: + +- Before sandbox resolution, check the parsed command against `excluded_commands` and fail early if matched +- After a sandbox denial, if `allow_unsandboxed_retry` is `true`, present a distinct permission prompt for unsandboxed re-execution +- Pass the selected `mode` to `SandboxSpawn.resolve()` + +#### `packages/opencode/src/session/prompt.ts` + +Current state: + +- `SessionPrompt.shell()` (line 1542) already wired through `SandboxSpawn.resolve()` +- No excluded-command checking +- No unsandboxed retry + +Planned changes: + +- Mirror the same excluded-command and unsandboxed-retry changes as `bash.ts` +- Pass the selected `mode` to `SandboxSpawn.resolve()` + +#### `packages/opencode/src/permission/index.ts` + +Current state: + +- `Permission.ask()` evaluates rulesets and prompts the user +- No awareness of sandbox retry + +Planned change: + +- No structural change required. + The unsandboxed retry prompt SHOULD use the existing `Permission.ask()` with a distinct permission key (e.g., `"bash:unsandboxed"`) so that operators can pre-allow or pre-deny it via config. + +### Files explicitly NOT changed in phase 2 + +| File | Reason | +| ----------------------------------------------------- | ------------------------------------------ | +| `packages/opencode/src/util/process.ts` | Internal spawn utility, not agent-visible | +| `packages/opencode/src/effect/cross-spawn-spawner.ts` | Effect-layer spawn, used by infrastructure | +| `packages/opencode/src/mcp/index.ts` | MCP server launch, separate trust model | +| `packages/opencode/src/lsp/launch.ts` | LSP launch, internal tooling | +| `packages/opencode/src/lsp/server.ts` | LSP server management, internal tooling | + +--- + +## Sequencing + +### Phase 2a — PTY sandbox integration + +Goal: sandbox interactive shell sessions on macOS when enabled. + +Steps: + +1. Validate that `bun-pty` `spawn()` correctly executes `sandbox-exec -p ` as the PTY command. + This is the critical proof step. + If `bun-pty` cannot run a wrapped command, the approach needs revision. +2. Wire `SandboxSpawn.resolve()` into `Pty.create()` before the `spawn()` call. +3. When sandbox is active, replace the raw `command` + `args` with the wrapped version. +4. Validate that terminal I/O (`onData`, `onExit`, resize) still works through the wrapper. +5. Validate that interactive programs (shell builtins, vim, git interactive rebase) work inside the sandbox. + +Exit criteria: + +- PTY sessions respect sandbox policy when enabled +- Terminal I/O is not broken +- Interactive programs that stay within allowed paths work +- Programs that attempt denied operations fail with Seatbelt denials visible in stderr + +QA: + +- From `packages/opencode`, run `bun run typecheck` +- Add test coverage in `packages/opencode/test/pty/` or extend existing PTY tests +- Manual verification: create a PTY session with sandbox enabled, run `ls`, `cat`, `echo`, confirm output streams correctly +- Manual verification: attempt `cat ~/.ssh/id_rsa` inside sandboxed PTY, confirm denial + +### Phase 2b — sandbox modes + +Goal: implement `workspace-write` and `read-only` modes. + +Steps: + +1. Add `mode` to `SandboxPolicy.Input` +2. In `SandboxPolicy.build()`, when mode is `"read-only"`, move project root and worktree root from write roots to read-only roots. + Only `/tmp`, `/private/tmp`, and explicitly configured extra write roots remain writable. +3. In `SandboxSpawn.resolve()`, read `mode` from config and pass it through. +4. Update `bash.ts`, `session/prompt.ts`, and `pty/index.ts` to forward the mode. + +Exit criteria: + +- `"workspace-write"` behaves identically to current phase-1 behavior +- `"read-only"` denies writes to the project root +- Default is `"workspace-write"` for backward compatibility + +QA: + +- Unit tests in `packages/opencode/test/sandbox/policy.test.ts` for both modes +- Integration test: with `mode: "read-only"`, `touch newfile.txt` in project root MUST fail +- Integration test: with `mode: "workspace-write"`, `touch newfile.txt` in project root MUST succeed + +### Phase 2c — excluded commands + +Goal: block specific commands before they reach the sandbox. + +Steps: + +1. Add `excluded_commands` to config schema. +2. In `SandboxSpawn` (or a new helper), add a check function that takes the parsed command and the exclusion list, + and returns a structured error if the command matches. +3. Wire the check into `bash.ts` (after tree-sitter parse, before spawn) and `session/prompt.ts` (before spawn). +4. Wire the check into `pty/index.ts` for the initial PTY command (not subsequent interactive input — that is out of scope for phase 2). + +Design note: + +- Excluded commands are a pre-spawn deny list, not an SBPL rule. + SBPL cannot reason about command names; it operates on file paths and syscalls. +- Matching SHOULD use the same command prefix logic as `BashArity.prefix()` in `packages/opencode/src/permission/arity.ts`. +- For PTY, the check applies only to the initial spawn command. + Once the interactive shell is running, the user (or agent) can type anything. + This is a known limitation and SHOULD be documented. + +Exit criteria: + +- A command matching the exclusion list fails with a clear error message before spawning +- Non-excluded commands are unaffected + +QA: + +- Add or extend tests at `packages/opencode/test/sandbox/spawn.test.ts` covering the exclusion checker. +- From `packages/opencode`, run: + `bun run typecheck` +- From `packages/opencode`, run: + `bun test --timeout 30000 test/sandbox/spawn.test.ts` +- Expected result for unit tests: + - A command whose prefix matches an entry in `excluded_commands` returns a structured error before any spawn call + - A command that does not match any exclusion proceeds normally +- Add an integration test in `packages/opencode/test/tool/bash.test.ts` (or a dedicated `bash-sandbox.test.ts`): + - Configure `excluded_commands: ["rm"]` + - Invoke the bash tool with `rm -rf /tmp/test` + - Expected result: the tool rejects the command with an error containing the excluded command name; no child process is spawned +- Add an integration test for `session/prompt.ts`: + - Configure `excluded_commands: ["curl"]` + - Call `SessionPrompt.shell()` with `curl https://example.com` + - Expected result: shell execution fails with a structured error before spawning +- Manual verification for PTY: + - Set `excluded_commands: ["python"]` in `opencode.json` under `experimental.sandbox` + - Create a PTY session with `command: "python"` + - Expected result: PTY creation fails with a clear error; no interactive session starts + - Create a PTY session with the default shell (not in the exclusion list) + - Expected result: PTY session starts normally + +### Phase 2d — fail-if-unavailable + +Goal: hard-fail when sandbox is required but cannot activate. + +Steps: + +1. Add `fail_if_unavailable` to config schema. +2. In `SandboxSpawn.resolve()`, when `fail_if_unavailable` is `true` and the plan returns `active: false` with reason `unsupported_platform` or `sandbox_exec_missing`, throw `SandboxSpawn.Error` instead of returning an inactive plan. +3. Currently, `SandboxSpawn.plan()` already throws for `sandbox_exec_missing` when `requested` is `true`. + Extend this to also throw for `unsupported_platform` when `fail_if_unavailable` is `true`. + +Exit criteria: + +- On a non-macOS platform with `fail_if_unavailable: true`, command execution fails with a structured error +- On macOS without `sandbox-exec` (unlikely but testable), same behavior +- With `fail_if_unavailable: false` (default), current fallback behavior is preserved + +QA: + +- Add or extend tests at `packages/opencode/test/sandbox/spawn.test.ts`. +- From `packages/opencode`, run: + `bun run typecheck` +- From `packages/opencode`, run: + `bun test --timeout 30000 test/sandbox/spawn.test.ts` +- Test case 1 — hard failure on unsupported platform: + - Call `SandboxSpawn.plan()` with `requested: true`, `platform: "linux"`, and `fail_if_unavailable: true` + - Expected result: throws `SandboxSpawn.Error` with reason `unsupported_platform` +- Test case 2 — hard failure when `sandbox-exec` is missing: + - Call `SandboxSpawn.plan()` with `requested: true`, `platform: "darwin"`, `available: false`, and `fail_if_unavailable: true` + - Expected result: throws `SandboxSpawn.Error` with reason `sandbox_exec_missing` +- Test case 3 — graceful fallback when `fail_if_unavailable` is `false`: + - Call `SandboxSpawn.plan()` with `requested: true`, `platform: "linux"`, and `fail_if_unavailable: false` + - Expected result: returns `{ active: false }` with reason `unsupported_platform`; no error thrown +- Test case 4 — backward compatibility: + - Call `SandboxSpawn.plan()` with `requested: true`, `platform: "linux"`, and no `fail_if_unavailable` field + - Expected result: same as test case 3; defaults to `false` +- Manual verification: + - On a macOS host, set `fail_if_unavailable: true` and `enabled: true` in `opencode.json` + - Run a bash tool command + - Expected result: command executes inside the sandbox normally (sandbox-exec is present) + - On a Linux host (or by temporarily renaming `/usr/bin/sandbox-exec`), repeat + - Expected result: command fails immediately with a structured error mentioning sandbox unavailability + +### Phase 2e — unsandboxed retry + +Goal: implement the `allow_unsandboxed_retry` runtime path. + +Steps: + +1. Define a new permission key `"bash:unsandboxed"` (or similar) that operators can pre-configure. +2. In `bash.ts`, after a sandbox denial (child exits non-zero with Seatbelt-related stderr), detect the denial. +3. If `allow_unsandboxed_retry` is `true`, prompt the user via `Permission.ask()` with the `"bash:unsandboxed"` key and the original command as the pattern. +4. If approved, re-run the command without the sandbox wrapper. +5. Mirror the same logic in `session/prompt.ts`. +6. For PTY, unsandboxed retry is NOT implemented in phase 2. + Restarting a PTY session outside the sandbox mid-stream is complex and error-prone. + Document this as a known limitation. + +Sharp edges: + +- Detecting a Seatbelt denial from the child's exit code and stderr is heuristic. + `sandbox-exec` does not set a distinct exit code for policy violations. + The implementation SHOULD look for `deny` or `Sandbox:` in stderr as a signal, but MUST NOT treat this as authoritative. +- The retry path MUST re-run the entire command, not attempt to resume partial output. +- The retry permission MUST be distinct from the original bash permission so operators can deny retries globally. + +Exit criteria: + +- When `allow_unsandboxed_retry` is `false` (default), no retry is offered +- When `true` and a sandbox denial occurs, the user is prompted +- If the user approves, the command runs unsandboxed and produces output normally +- If the user rejects, the original sandbox denial stands + +QA: + +- Add or extend tests at `packages/opencode/test/tool/bash.test.ts` (or a dedicated `bash-sandbox.test.ts`). +- From `packages/opencode`, run: + `bun run typecheck` +- From `packages/opencode`, run: + `bun test --timeout 30000 test/tool/bash.test.ts` + (or `bun test --timeout 30000 test/tool/bash-sandbox.test.ts` if a dedicated file is added) +- Test case 1 — retry disabled (default): + - Configure `allow_unsandboxed_retry: false` (or omit the field) + - Run a bash tool command that triggers a sandbox denial (e.g., write outside project root) + - Expected result: command fails with sandbox denial output; no retry prompt is issued; `Permission.ask()` is NOT called with `"bash:unsandboxed"` +- Test case 2 — retry enabled, user approves: + - Configure `allow_unsandboxed_retry: true` + - Run a bash tool command that triggers a sandbox denial + - Stub or pre-allow the `"bash:unsandboxed"` permission + - Expected result: after the initial sandbox denial, the command re-runs without the sandbox wrapper and produces normal output +- Test case 3 — retry enabled, user rejects: + - Configure `allow_unsandboxed_retry: true` + - Run a bash tool command that triggers a sandbox denial + - Stub or pre-deny the `"bash:unsandboxed"` permission + - Expected result: the original sandbox denial stands; no unsandboxed execution occurs +- Add a parallel test in `packages/opencode/test/session/prompt.test.ts` (or `prompt-sandbox.test.ts`): + - Configure `allow_unsandboxed_retry: true` + - Call `SessionPrompt.shell()` with a command that triggers a sandbox denial + - Pre-allow the `"bash:unsandboxed"` permission + - Expected result: command re-runs unsandboxed; output streams correctly +- Manual verification: + - Set `allow_unsandboxed_retry: true` and `enabled: true` in `opencode.json` + - In the TUI, run a bash command that writes to a path outside the project root (e.g., `touch /tmp/outside-project/test.txt` where the parent dir is not in extra write roots) + - Expected result: a permission prompt appears asking to retry without the sandbox + - Accept the prompt + - Expected result: the command runs unsandboxed and succeeds + - Repeat, but reject the prompt + - Expected result: the command remains failed with the original sandbox denial + +### Phase 2f — config evolution and docs + +Goal: ship accurate documentation for phase 2 capabilities. + +Steps: + +1. Update `SECURITY.md` to reflect expanded coverage: bash tool, session command, and PTY sessions. +2. Document the new config fields: `mode`, `excluded_commands`, `fail_if_unavailable`, `extra_deny_paths`. +3. Document exclusions: MCP, LSP, internal spawn layers, non-macOS, domain-mediated network. +4. Document the unsandboxed retry flow and its limitations. +5. Document that PTY excluded-command checking applies only to the initial spawn, not interactive input. + +Exit criteria: + +- Docs match actual coverage +- No overclaiming + +QA: + +- From the repo root, run: + `grep -n "PTY\|interactive\|MCP\|LSP\|non-macOS\|excluded.command\|unsandboxed.retry\|fail.if.unavailable" SECURITY.md` +- Expected result: + - `SECURITY.md` explicitly states macOS-only, opt-in, and experimental + - `SECURITY.md` lists bash tool, session command, and PTY interactive sessions as covered surfaces + - `SECURITY.md` explicitly names MCP, LSP, internal process utilities, domain-mediated network, and non-macOS as exclusions + - `SECURITY.md` documents that PTY excluded-command checking applies only to the initial spawn, not interactive input + - No section implies all local process execution is sandboxed +- Verify config documentation: + - From the repo root, run: + `grep -n "mode\|excluded_commands\|fail_if_unavailable\|extra_deny_paths" SECURITY.md` + - Expected result: each new config field is mentioned with a brief description of its behavior and default value +- Verify unsandboxed retry documentation: + - `SECURITY.md` or a linked doc describes the retry flow: sandbox denial, permission prompt with `"bash:unsandboxed"` key, re-execution without wrapper + - The doc notes that PTY sessions do not support unsandboxed retry in phase 2 +- Manual review: + - Read `SECURITY.md` end to end after edits + - Confirm no sentence claims coverage beyond what the validation matrix proved + - Confirm the phase-2 product statement in this plan matches the claims in `SECURITY.md` + +--- + +## Validation matrix + +### Unit tests + +| Test | Location | +| -------------------------------------------- | ----------------------------- | +| Policy generation for `workspace-write` mode | `test/sandbox/policy.test.ts` | +| Policy generation for `read-only` mode | `test/sandbox/policy.test.ts` | +| Excluded command matching | `test/sandbox/spawn.test.ts` | +| `fail_if_unavailable` throws on non-darwin | `test/sandbox/spawn.test.ts` | +| Unsandboxed retry permission key | `test/sandbox/spawn.test.ts` | +| Config parsing for new fields | `test/config/` | + +### macOS integration tests + +| # | Test | Expected | +| --- | -------------------------------------------------------------------- | ---------------------------------------- | +| 1 | PTY session with sandbox enabled, `ls` in project root | Succeeds, output streams | +| 2 | PTY session with sandbox enabled, `cat ~/.ssh/id_rsa` | Fails with Seatbelt denial | +| 3 | PTY session with sandbox enabled, terminal resize | Resize works | +| 4 | PTY session with sandbox enabled, interactive program (e.g., `less`) | Runs if file is in allowed path | +| 5 | `read-only` mode, `touch newfile.txt` in project root | Fails | +| 6 | `read-only` mode, `cat` a project file | Succeeds | +| 7 | `workspace-write` mode, `touch newfile.txt` in project root | Succeeds | +| 8 | Excluded command in bash tool | Fails before spawn with structured error | +| 9 | Excluded command in PTY initial spawn | Fails before spawn | +| 10 | `fail_if_unavailable: true` on non-darwin | Hard error | +| 11 | `allow_unsandboxed_retry: true`, sandbox denial, user approves | Command re-runs unsandboxed | +| 12 | `allow_unsandboxed_retry: true`, sandbox denial, user rejects | Original denial stands | +| 13 | `allow_unsandboxed_retry: false`, sandbox denial | No retry offered | + +### Manual verification + +Before updating docs: + +1. Build the darwin binary with the Bun compile pipeline. +2. Open a PTY session with sandbox enabled and run common interactive workflows. +3. Verify that `bun-pty` correctly spawns `sandbox-exec` as the PTY command. +4. Verify that sandbox denials are visible in PTY stderr output. +5. Test excluded-command blocking in both bash tool and PTY. + +--- + +## Risks and sharp edges + +### `bun-pty` and `sandbox-exec` interaction + +This is the highest-risk item in phase 2. +`bun-pty` uses `forkpty()` to allocate a pseudo-terminal and exec the command. +The command becomes `sandbox-exec -p /bin/zsh -l`. +If `forkpty()` + `execvp("sandbox-exec", ...)` does not work correctly, +the entire PTY sandboxing approach needs revision. + +Mitigation: validate this in phase 2a before any other work. + +### PTY excluded-command limitation + +Excluded-command checking for PTY applies only to the initial spawn command. +Once an interactive shell is running, the user or agent can type any command. +This is an inherent limitation of pre-spawn deny lists. + +Mitigation: document this clearly. Do not claim that excluded commands are enforced inside interactive sessions. + +### Seatbelt denial detection heuristic + +There is no reliable programmatic way to distinguish a Seatbelt denial from other non-zero exits. +The retry path relies on stderr heuristics. + +Mitigation: make the heuristic conservative. Better to miss a retry opportunity than to offer retry on a non-sandbox failure. + +### `sandbox-exec` deprecation + +Apple has not removed `sandbox-exec` but has deprecated the Seatbelt API. +Both Codex and Claude Code still rely on it. +This risk is inherited from phase 1 and unchanged. + +### Mode default and backward compatibility + +Phase 1 has no `mode` concept. +Phase 2 MUST default to `"workspace-write"` so that existing phase-1 users see no behavior change. + +### Shell rc-file sourcing in PTY sandbox mode + +PTY sessions typically source `~/.zshrc`, `~/.bashrc`, etc. +Sandbox mode cannot safely allow broad `$HOME` reads for this. +Users MAY experience different shell behavior in sandboxed PTY sessions. + +Mitigation: document this. Prefer deterministic shell startup in sandbox mode. + +--- + +## Open questions + +1. Does `bun-pty` `spawn()` correctly execute `sandbox-exec` as the PTY command? + This MUST be validated before committing to the PTY approach. + +2. Should `read-only` mode allow writes to `/tmp` and `/private/tmp`, + or should it deny all writes except explicitly configured paths? + +3. What is the right UX for the unsandboxed retry prompt? + Should it show the original command, the denial reason, or both? + +4. Should excluded-command matching be exact prefix or glob-based? + Prefix matching via `BashArity.prefix()` is simpler and consistent with existing permission arity. + +5. Should `fail_if_unavailable` be separate from `enabled`, + or should there be a three-state `enabled` field (`true`, `false`, `"required"`)? + +6. What default `excluded_commands` list (if any) should ship with phase 2? + Candidates: `rm -rf /`, `chmod 777`, `curl | sh`, `eval`. + +--- + +## Phase-2 product statement + +If phase 2 ships successfully, +the correct security statement is: + +> On macOS, +> opencode can optionally sandbox agent-issued shell commands from the bash tool, session command path, and PTY interactive sessions. +> The sandbox supports workspace-write and read-only modes, +> excluded-command blocking (pre-spawn only), +> and an explicit unsandboxed retry path gated behind a distinct permission prompt. +> MCP servers, +> LSP servers, +> internal process utilities, +> domain-mediated network controls, +> and non-macOS platforms are not covered by this phase. + +Anything broader than that is inaccurate. + +--- + +## Later phases + +Phase 2 intentionally defers the following. +Each item has its own design considerations. + +| Item | Why deferred | Likely phase | +| ------------------------------------- | ---------------------------------------------------- | ---------------- | +| MCP server sandboxing | Separate trust model; operator-configured commands | Phase 3 | +| LSP server sandboxing | Internal infrastructure, not agent-visible | Phase 3 or later | +| `util/process.ts` wiring | Shared by non-agent code paths | Phase 3 or later | +| `cross-spawn-spawner.ts` wiring | Effect-layer spawn, infrastructure | Phase 3 or later | +| Domain/proxy network mediation | Substantial infrastructure (localhost proxy) | Phase 4+ | +| Linux sandboxing | Different mechanism (e.g., bubblewrap, namespaces) | Phase 4+ | +| Windows sandboxing | Different mechanism entirely | Phase 5+ | +| Interactive-session excluded commands | Requires in-band command interception, not pre-spawn | Research | + +--- + +## Rollout plan + +1. Ship phase 2a (PTY integration) behind the existing `experimental.sandbox.enabled` flag. + No new flag required. +2. Ship phase 2b (modes) with `mode` defaulting to `"workspace-write"`. + No behavior change for existing users. +3. Ship phase 2c (excluded commands) with an empty default list. + Operators opt in by configuring `excluded_commands`. +4. Ship phase 2d (fail-if-unavailable) with `fail_if_unavailable` defaulting to `false`. +5. Ship phase 2e (unsandboxed retry) with `allow_unsandboxed_retry` defaulting to `false` (unchanged from phase 1). +6. Ship phase 2f (docs) only after manual verification of the full validation matrix. + +Each sub-phase MAY ship independently. +They have no hard ordering dependencies except that 2a SHOULD land first because it validates the PTY approach. + +--- + +## Pickup checklist for the next agent + +1. Read this file fully before touching code. +2. Read the phase-1 handoff plan at `.sisyphus/plans/macos-command-sandboxing-handoff.md` for context. +3. Re-read these repo files before planning edits: + - `packages/opencode/src/pty/index.ts` (PTY spawn at line 201) + - `packages/opencode/src/sandbox/policy.ts` (SBPL builder) + - `packages/opencode/src/sandbox/spawn.ts` (wrapper, plan, resolve) + - `packages/opencode/src/tool/bash.ts` (current sandbox wiring) + - `packages/opencode/src/session/prompt.ts` (`shell()` at line 1542, current sandbox wiring at line 1729) + - `packages/opencode/src/config/config.ts` (sandbox config at line 1020) + - `packages/opencode/src/permission/index.ts` (permission ask/reply) + - `packages/opencode/src/permission/evaluate.ts` (rule evaluation) + - `packages/opencode/src/permission/arity.ts` (command prefix matching) +4. Start with phase 2a proof work: validate `bun-pty` + `sandbox-exec`. + Do NOT proceed with other sub-phases until this is confirmed. +5. Do NOT wire `util/process.ts`, `cross-spawn-spawner.ts`, `mcp/index.ts`, or `lsp/*`. +6. Do NOT claim domain-mediated network controls. +7. Do NOT allow broad `$HOME` reads for PTY shell rc-file sourcing. +8. Do NOT update `SECURITY.md` until the validation matrix passes. +9. Default all new config fields to backward-compatible values. +10. Use the existing `Permission.ask()` flow for unsandboxed retry; do not create a parallel permission system. diff --git a/.sisyphus/plans/macos-command-sandboxing-phase-3-presets-and-runtime.md b/.sisyphus/plans/macos-command-sandboxing-phase-3-presets-and-runtime.md new file mode 100644 index 000000000000..fdd9e82e26a6 --- /dev/null +++ b/.sisyphus/plans/macos-command-sandboxing-phase-3-presets-and-runtime.md @@ -0,0 +1,953 @@ +# macOS Command Sandboxing Phase 3: Presets and Runtime + +## Status + +This is a handoff plan for phase 3 of macOS command sandboxing. +No implementation work is included. +Another engineer MUST be able to implement phase 3 from this plan without redoing discovery. + +## Entry gate + +Phase 3 builds on phase 2 as its baseline. +Phase 2 MUST land before phase 3 work begins. + +Phase 2 delivers: + +- PTY interactive shell sandboxing via `bun-pty` + `sandbox-exec` +- `workspace-write` and `read-only` sandbox modes +- Excluded-command pre-spawn blocking +- `fail_if_unavailable` hard-fail behavior +- `allow_unsandboxed_retry` runtime path with distinct permission key +- Coverage of `bash.ts`, `session/prompt.ts`, and `pty/index.ts` + +Phase 3 MUST NOT overclaim coverage of any spawn surface that phase 2 did not ship. +If phase 2 has not landed when this plan is picked up, +the implementer MUST either wait or explicitly scope down to phase-1 coverage only (bash tool and session command, no PTY). + +--- + +## Objective + +Add Codex-style approval and sandbox presets as a first-class configuration surface, +introduce a small shared spawn-policy helper for sandbox resolution, +extend sandbox coverage to LSP runtime launches, +and harden workspace-root protection with Codex-inspired protected-path behavior. + +Phase 3 is a **preset-and-opt-in-runtime** phase. +It is NOT a blanket shared-spawn-centralization phase. + +--- + +## Non-goals + +Phase 3 MUST NOT: + +1. **Sandbox MCP local server launches** (`mcp/index.ts`). + MCP processes run operator-configured commands with their own trust model. + `connectLocal()` in `mcp/index.ts` (line 380) passes the command directly to `StdioClientTransport`. + Sandboxing that path requires MCP-specific policy design — a separate workstream. + +2. **Wire `util/process.ts` or `cross-spawn-spawner.ts` through the sandbox automatically.** + These are shared spawn utilities used by LSP servers, formatters, `BunProc`, and internal tooling. + Broadly wiring them would silently sandbox non-agent processes. + Phase 3 wraps LSP launch specifically, not the general-purpose spawn layers. + +3. **Implement domain-based or proxy-mediated network controls.** + Claude Code's localhost-proxy model is substantial infrastructure. + Phase 3 keeps the current posture: network denied by default, optionally allowed via policy or per-preset override. + +4. **Support Linux or Windows sandboxing.** + macOS Seatbelt only. + +5. **Claim full Codex parity.** + Phase 3 closes the gap on presets and protected roots. + It does not replicate Codex's `danger-full-access` mode, automatic worktree discovery, or full approval-policy matrix. + +6. **Move sandbox config out of `experimental`.** + The preset surface is new and needs real-world feedback before promotion. + +--- + +## Parity targets + +### What Codex documents + +| Concept | Codex behavior | +| ---------------------------------------- | ------------------------------------------------------------------------------------------- | +| `sandbox_mode` | `read-only`, `workspace-write`, `danger-full-access` | +| `approval_policy` | Separate layer from sandbox; controls when the agent must ask | +| `profiles.` | Named preset profiles composing sandbox + approval | +| `sandbox_workspace_write.writable_roots` | Explicit writable path set | +| `sandbox_workspace_write.network_access` | Per-mode network toggle | +| Protected roots inside writable roots | `.git`, resolved gitdir, `.agents`, `.codex` are protected even when the parent is writable | + +### What phase 3 targets + +| Capability | Codex | Phase 3 target | +| ----------------------------------------------- | --------------------------- | ------------------------------------------------ | +| Named presets composing sandbox + permission | `profiles.` | Yes — `experimental.sandbox.preset` | +| Protected roots inside writable workspace | `.git`, `.agents`, `.codex` | Yes — `.git`, resolved gitdir, `.opencode` | +| Per-preset network toggle | `network_access` per mode | Yes | +| Approval policy as independent layer | `approval_policy` | Partial — maps to existing `permission` rulesets | +| `danger-full-access` mode | Supported | No — too risky for opt-in experimental | +| Automatic worktree discovery for writable roots | Automatic | No — explicit only | +| LSP runtime sandboxing | N/A | Yes — `lsp/launch.ts` | +| Shared spawn-policy helper | N/A | Yes — `SandboxRuntime` / `SpawnPlan` | + +--- + +## Concrete files and modules + +### New modules + +#### `packages/opencode/src/sandbox/preset.ts` + +Responsible for resolving a named preset into concrete sandbox and permission configuration. + +A preset is a named bundle of: + +- sandbox mode (`workspace-write`, `read-only`) +- network access toggle +- protected roots list +- permission ruleset overlay +- optional extra read/write roots + +Suggested shape: + +```ts +export namespace SandboxPreset { + export interface Def { + mode: "workspace-write" | "read-only" + network: boolean + protected_roots: string[] + permission: Permission.Ruleset + extra_read_roots?: string[] + extra_write_roots?: string[] + } + + export function resolve(name: string, overrides?: Partial): Def + export function builtins(): Record +} +``` + +Built-in presets: + +| Name | Mode | Network | Protected roots | Permission overlay | +| --------- | ----------------- | ------- | ------------------------------------ | ------------------------ | +| `default` | `workspace-write` | `false` | `.git`, resolved gitdir, `.opencode` | none | +| `strict` | `read-only` | `false` | `.git`, resolved gitdir, `.opencode` | `bash: ask`, `edit: ask` | +| `network` | `workspace-write` | `true` | `.git`, resolved gitdir, `.opencode` | none | + +Operators MAY define custom presets under `experimental.sandbox.presets` in config. +Built-in presets MUST NOT be overridable by user config — they serve as known-good baselines. +Custom presets extend the set; they do not replace built-ins. + +#### `packages/opencode/src/sandbox/runtime.ts` + +A small shared spawn-policy helper that consolidates sandbox resolution logic. +This replaces the pattern of each call site independently calling `SandboxSpawn.resolve()` + `SandboxSpawn.wrap()`. + +Suggested shape: + +```ts +export namespace SandboxRuntime { + export interface SpawnPlan { + active: boolean + file: string + args: string[] + env?: Record + diag: SandboxSpawn.Diag + } + + export function plan(input: { + file: string + args: string[] + cwd: string + project_root: string + worktree_root: string + preset?: string + }): SpawnPlan +} +``` + +This helper: + +- Reads the active preset (from config or explicit parameter) +- Resolves protected roots and merges them into deny rules +- Calls `SandboxSpawn.plan()` and `SandboxSpawn.wrap()` internally +- Returns a ready-to-use `SpawnPlan` with final `file` + `args` +- Remains a pure data transformer — no side effects, no spawning + +Call sites (`bash.ts`, `session/prompt.ts`, `pty/index.ts`, `lsp/launch.ts`) switch from direct `SandboxSpawn` calls to `SandboxRuntime.plan()`. +This is a refactor of existing wiring, not new coverage. + +### Files to change + +#### `packages/opencode/src/sandbox/policy.ts` + +Current state: + +- `SandboxPolicy.build()` generates SBPL from `Input` with read roots, write roots, deny roots, network, Unix sockets +- `secret` array holds credential-path deny list +- No awareness of protected workspace roots + +Planned changes: + +- Accept `protected_roots` in `Input`. + These are paths inside writable roots that MUST be denied for writes. + They are added to the deny list after write-root expansion. +- The SBPL generation MUST emit deny rules for protected roots _after_ the write-allow rules, + so that Seatbelt's last-match-wins evaluation denies writes to protected paths even inside writable directories. +- Accept `mode` in `Input` to support `read-only` moving project/worktree to read roots (phase 2 may already have this; if so, no change needed). + +#### `packages/opencode/src/sandbox/spawn.ts` + +Current state (after phase 2): + +- `resolve()` reads config, detects platform, builds plan +- `plan()` validates roots, builds policy, returns active/inactive +- `wrap()` produces `{ file: "sandbox-exec", args: ["-p", profile, ...] }` +- Mode, excluded commands, fail-if-unavailable, and unsandboxed retry are wired + +Planned changes: + +- Accept an optional `preset` name in `ResolveInput`. + When provided, resolve the preset via `SandboxPreset.resolve()` and merge its settings before building the plan. +- Preset settings MUST be overridable by explicit `experimental.sandbox.*` fields. + Priority order (highest wins): explicit config fields > preset defaults > hardcoded defaults. +- When a preset specifies `network: true`, set `allow_network: true` in the policy input. +- When a preset specifies `protected_roots`, pass them through to `SandboxPolicy.build()`. + +#### `packages/opencode/src/config/config.ts` + +Current sandbox schema (after phase 2): + +```ts +sandbox: z.object({ + enabled: z.boolean().optional(), + mode: z.enum(["workspace-write", "read-only"]).optional(), + extra_read_roots: z.array(z.string()).optional(), + extra_write_roots: z.array(z.string()).optional(), + extra_deny_paths: z.array(z.string()).optional(), + excluded_commands: z.array(z.string()).optional(), + allow_unsandboxed_retry: z.boolean().optional(), + fail_if_unavailable: z.boolean().optional(), +}).optional() +``` + +Planned evolution: + +```ts +sandbox: z.object({ + enabled: z.boolean().optional(), + preset: z.string().optional(), + 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(), + extra_deny_paths: z.array(z.string()).optional(), + excluded_commands: z.array(z.string()).optional(), + allow_unsandboxed_retry: z.boolean().optional(), + fail_if_unavailable: z.boolean().optional(), + 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: Permission.optional(), + }), + ) + .optional(), +}).optional() +``` + +Field semantics: + +- `preset`: name of the active preset. + When set, the preset's defaults apply. + Explicit sibling fields (`mode`, `network`, etc.) override the preset's defaults. +- `network`: controls whether the sandbox allows outbound network. + Default `false`. + Overrides the preset's `network` setting. +- `protected_roots`: workspace-relative paths that MUST be write-denied even inside writable roots. + Defaults to `[".git", ".opencode"]` when any preset is active. + Operators MAY add custom protected roots (e.g., `.env`, `secrets/`). +- `presets`: operator-defined named presets. + These extend the built-in set. + A custom preset with the same name as a built-in MUST be rejected at config parse time with a clear error. + +#### `packages/opencode/src/lsp/launch.ts` + +Current state: + +- `spawn()` delegates to `Process.spawn()` from `util/process.ts` +- Passes `stdin: "pipe"`, `stdout: "pipe"`, `stderr: "pipe"` +- No sandbox awareness + +Planned change: + +- Before spawning, call `SandboxRuntime.plan()` to resolve a spawn plan. +- When sandbox is active, replace the `cmd` + `args` with the wrapped version from the plan. +- Pass the wrapped command to `Process.spawn()`. +- LSP servers need read access to the project root and standard system paths. + They SHOULD NOT need write access outside `/tmp` and explicitly configured write roots. +- The sandbox mode for LSP launches SHOULD default to `read-only` regardless of the active preset's mode. + LSP servers are infrastructure; they SHOULD NOT write to the workspace. + +Sharp edges: + +- LSP servers often need to read the entire project tree plus globally installed toolchains. + The read-root set MUST include Homebrew paths (`/opt/homebrew`, `/usr/local`) and any Nix store paths configured in `extra_read_roots`. +- Some LSP servers write to cache directories under `$HOME` (e.g., `~/.cache/typescript`). + Phase 3 SHOULD NOT grant broad `$HOME` writes. + If a specific LSP fails, the operator SHOULD add the cache path to `extra_write_roots`. +- `lsp/server.ts` manages LSP server lifecycle and calls `spawn()` from `lsp/launch.ts`. + The sandbox integration is in `launch.ts`; `server.ts` does not need changes. + +#### `packages/opencode/src/lsp/server.ts` + +No changes planned. +`server.ts` calls `spawn()` from `launch.ts`. +The sandbox wrapping happens inside `launch.ts`. + +#### `packages/opencode/src/file/protected.ts` + +Current state: + +- `Protected.names()` returns macOS TCC-protected directory basenames +- `Protected.paths()` returns absolute paths that should never be watched or scanned +- Used for file watcher exclusion, not sandbox policy + +Planned change: + +- Add a `Protected.workspace()` export that returns the default protected roots for sandbox policy: + `[".git", ".opencode"]`. +- Add a `Protected.resolve(project_root: string)` that resolves `.git` to the actual gitdir + (handles worktrees where `.git` is a file pointing elsewhere) + and returns absolute paths. +- `SandboxPreset` and `SandboxRuntime` call `Protected.resolve()` to build the deny list for protected workspace roots. + +#### `packages/opencode/src/tool/bash.ts` + +Current state (after phase 2): + +- Wired through `SandboxSpawn.resolve()` and `SandboxSpawn.wrap()` +- Has excluded-command checking and unsandboxed retry + +Planned change: + +- Switch from direct `SandboxSpawn` calls to `SandboxRuntime.plan()`. +- The preset name flows from config through the runtime helper. +- No behavioral change beyond the refactor. + +#### `packages/opencode/src/session/prompt.ts` + +Same refactor as `bash.ts`: switch to `SandboxRuntime.plan()`. + +#### `packages/opencode/src/pty/index.ts` + +Same refactor as `bash.ts`: switch to `SandboxRuntime.plan()`. + +#### `packages/opencode/src/agent/agent.ts` + +Current state: + +- Builds per-agent permission rulesets via `Permission.merge()` of defaults, agent-specific config, and user config +- No awareness of sandbox presets + +Planned change: + +- When a sandbox preset specifies a `permission` overlay, + merge that overlay into the agent's ruleset at the correct priority. +- Priority order (lowest to highest): + 1. Hardcoded defaults + 2. Preset permission overlay + 3. Agent-specific config (`cfg.agent..permission`) + 4. Top-level user config (`cfg.permission`) +- This preserves the existing merge behavior. + The preset overlay sits between defaults and explicit user config. + +#### `packages/opencode/src/permission/index.ts` + +No structural change required. +`Permission.merge()` and `Permission.fromConfig()` already support arbitrary rulesets. +The preset's permission overlay is just another ruleset passed to `merge()`. + +#### `packages/opencode/src/permission/evaluate.ts` + +No change. +Last-match-wins evaluation is already correct for the preset overlay merge order. + +### Files explicitly NOT changed in phase 3 + +| File | Reason | +| ----------------------------------------------------- | ------------------------------------------ | +| `packages/opencode/src/util/process.ts` | Shared spawn utility, not agent-visible | +| `packages/opencode/src/effect/cross-spawn-spawner.ts` | Effect-layer spawn, used by infrastructure | +| `packages/opencode/src/mcp/index.ts` | MCP server launch, separate trust model | + +--- + +## Preset model + +### Resolution order + +When sandbox is enabled: + +1. Read `experimental.sandbox.preset` from config (e.g., `"default"`, `"strict"`, `"network"`, or a custom name). +2. Look up the preset definition: first in built-ins, then in `experimental.sandbox.presets`. +3. Merge the preset's defaults with explicit config overrides. + Explicit fields always win. +4. The merged result feeds into `SandboxRuntime.plan()`. + +If no preset is specified but sandbox is enabled, +the `"default"` preset applies implicitly. + +### Relationship to existing config + +The `preset` field is sugar. +Every field a preset sets can be set directly. +Operators who prefer granular control MAY ignore presets entirely and set `mode`, `network`, `protected_roots`, etc. directly. + +The raw `permission` and `experimental.sandbox.*` fields remain as explicit overrides. +Presets do not replace them; they provide convenient defaults. + +### Example configs + +Minimal opt-in with default preset: + +```json +{ + "experimental": { + "sandbox": { + "enabled": true + } + } +} +``` + +Strict preset with custom protected root: + +```json +{ + "experimental": { + "sandbox": { + "enabled": true, + "preset": "strict", + "protected_roots": [".git", ".opencode", ".env"] + } + } +} +``` + +Custom preset definition: + +```json +{ + "experimental": { + "sandbox": { + "enabled": true, + "preset": "ci", + "presets": { + "ci": { + "mode": "workspace-write", + "network": true, + "protected_roots": [".git", ".opencode"], + "permission": { + "bash": "allow", + "edit": "allow" + } + } + } + } + } +} +``` + +Preset with explicit override: + +```json +{ + "experimental": { + "sandbox": { + "enabled": true, + "preset": "default", + "network": true + } + } +} +``` + +Here `"default"` preset has `network: false`, +but the explicit `"network": true` overrides it. + +--- + +## Protected-root model + +### Codex behavior + +Codex protects certain paths inside writable roots: + +- `.git` (and resolved gitdir for worktrees) +- `.agents` +- `.codex` + +These paths are write-denied even when the parent directory is writable. +This prevents the agent from modifying version control state or its own configuration. + +### Phase 3 behavior + +Default protected roots when sandbox is active: `.git` and `.opencode`. + +Resolution: + +1. `Protected.resolve(project_root)` resolves `.git` to the actual gitdir. + For standard repos, this is `/.git`. + For worktrees, `.git` is a file whose content points to the real gitdir (e.g., `../../.git/worktrees/foo`). + Both paths MUST be protected. +2. `.opencode` resolves to `/.opencode`. +3. Operator-configured `protected_roots` are resolved relative to the project root. + +SBPL implementation: + +The deny rules for protected roots MUST appear after the write-allow rules in the profile. +Seatbelt evaluates rules in order; later rules take precedence. +This ensures that `(deny file-write* (subpath ""))` overrides `(allow file-write* (subpath ""))`. + +### Workspace boundary + +Phase 3 MUST NOT treat all discovered worktrees as automatically writable. +Only the explicitly configured project root and worktree root are writable. +Additional writable paths require explicit `extra_write_roots` configuration. + +--- + +## LSP runtime coverage + +### Scope + +Phase 3 extends sandbox coverage to LSP server process launches via `lsp/launch.ts`. +This is the only new spawn surface covered in phase 3. + +### Why LSP + +LSP servers are long-running processes spawned by opencode to provide language intelligence. +They read the entire project tree and execute with full user privileges. +A compromised or misconfigured LSP server can read credentials, exfiltrate code, or modify files. + +Sandboxing LSP servers with a read-only policy and restricted network +reduces the blast radius of language server vulnerabilities. + +### Why not MCP + +MCP local servers (`mcp/index.ts` line 380) use `StdioClientTransport` which manages its own process lifecycle. +The transport creates the child process internally; opencode does not call `spawn()` directly. +Sandboxing MCP requires either patching the SDK transport or wrapping the command before passing it to the transport. +This is a separate design problem and SHOULD be a phase 4 workstream. + +### LSP sandbox policy + +- Mode: `read-only` (regardless of the active preset's mode) +- Read roots: project root, worktree root, standard system paths, Homebrew paths, configured `extra_read_roots` +- Write roots: `/tmp`, `/private/tmp` only (LSP servers SHOULD NOT need to write to the workspace) +- Network: denied by default (LSP servers SHOULD NOT need network access) +- Protected roots: not applicable (project root is read-only for LSP) + +If a specific LSP server needs write access (e.g., to a cache directory), +the operator SHOULD add the path to `extra_write_roots`. + +--- + +## Validation matrix + +### Unit tests + +| Test | Location | +| --------------------------------------------------------------------------------- | ------------------------------ | +| Preset resolution for built-in names | `test/sandbox/preset.test.ts` | +| Custom preset definition and lookup | `test/sandbox/preset.test.ts` | +| Preset override by explicit config fields | `test/sandbox/preset.test.ts` | +| Built-in preset name collision rejection | `test/sandbox/preset.test.ts` | +| Protected-root resolution for standard repo | `test/sandbox/policy.test.ts` | +| Protected-root resolution for worktree | `test/sandbox/policy.test.ts` | +| SBPL deny-after-allow ordering for protected roots | `test/sandbox/policy.test.ts` | +| `SandboxRuntime.plan()` returns correct `SpawnPlan` | `test/sandbox/runtime.test.ts` | +| `SandboxRuntime.plan()` applies preset defaults | `test/sandbox/runtime.test.ts` | +| `SandboxRuntime.plan()` respects explicit overrides | `test/sandbox/runtime.test.ts` | +| Config parsing for new fields (`preset`, `network`, `protected_roots`, `presets`) | `test/config/` | +| Preset permission overlay merge order in `agent.ts` | `test/agent/` | + +### macOS integration tests + +| # | Test | Expected | +| --- | ---------------------------------------------------------------------- | ------------------------------ | +| 1 | Default preset active, write to project file | Succeeds | +| 2 | Default preset active, write to `.git/config` | Fails with Seatbelt denial | +| 3 | Default preset active, write to `.opencode/state.json` | Fails with Seatbelt denial | +| 4 | Strict preset active, write to project file | Fails (read-only mode) | +| 5 | Strict preset active, read project file | Succeeds | +| 6 | Network preset active, `curl https://example.com` | Succeeds | +| 7 | Default preset active, `curl https://example.com` | Fails (network denied) | +| 8 | Custom preset with `network: true`, explicit `network: false` override | Network denied (explicit wins) | +| 9 | LSP server launch with sandbox enabled, reads project file | Succeeds | +| 10 | LSP server launch with sandbox enabled, writes to project root | Fails | +| 11 | LSP server launch with sandbox enabled, writes to `/tmp` | Succeeds | +| 12 | LSP server launch with sandbox enabled, outbound HTTP | Fails | +| 13 | `SandboxRuntime.plan()` used from `bash.ts` | Same behavior as pre-refactor | +| 14 | `SandboxRuntime.plan()` used from `pty/index.ts` | Same behavior as pre-refactor | +| 15 | Worktree `.git` file resolution, write to resolved gitdir | Fails | + +### Manual verification + +Before updating docs: + +1. Build the darwin binary with the Bun compile pipeline. +2. Enable sandbox with `"preset": "default"` and run common bash tool commands. +3. Verify `.git/config` write is denied while project-file write succeeds. +4. Start an LSP server (e.g., TypeScript) with sandbox enabled and verify diagnostics work. +5. Verify LSP server cannot write to the project root. +6. Test with a git worktree to confirm `.git` file resolution protects the real gitdir. + +--- + +## Sequencing + +### Phase 3a — preset infrastructure + +Goal: land `sandbox/preset.ts` with built-in presets and config parsing. + +Steps: + +1. Add `preset`, `network`, `protected_roots`, and `presets` to the config schema. +2. Implement `SandboxPreset.resolve()` and `SandboxPreset.builtins()`. +3. Add config-time validation rejecting custom presets that collide with built-in names. +4. Add unit tests for preset resolution, override semantics, and collision rejection. + +Exit criteria: + +- Config parses correctly with new fields +- Preset resolution returns expected defaults +- Explicit fields override preset defaults +- Feature remains behavioral no-op until wired into spawn path + +QA: + +- From `packages/opencode`, run `bun run typecheck` +- From `packages/opencode`, run `bun test --timeout 30000 test/sandbox/preset.test.ts` + +### Phase 3b — protected roots + +Goal: implement protected-root resolution and SBPL deny-after-allow ordering. + +Steps: + +1. Add `Protected.workspace()` and `Protected.resolve()` to `file/protected.ts`. +2. Add `protected_roots` to `SandboxPolicy.Input`. +3. In `SandboxPolicy.build()`, emit deny rules for protected roots after write-allow rules. +4. Add unit tests verifying SBPL output order and worktree `.git` resolution. + +Exit criteria: + +- `.git` and `.opencode` are write-denied inside writable project root +- Worktree gitdir is correctly resolved and protected +- SBPL deny rules appear after write-allow rules + +QA: + +- From `packages/opencode`, run `bun run typecheck` +- From `packages/opencode`, run `bun test --timeout 30000 test/sandbox/policy.test.ts` +- macOS integration: `touch .git/test` inside sandbox MUST fail + +### Phase 3c — `SandboxRuntime` helper + +Goal: consolidate sandbox resolution into `SandboxRuntime.plan()`. + +Steps: + +1. Implement `sandbox/runtime.ts` with `SandboxRuntime.plan()`. +2. Refactor `bash.ts` to use `SandboxRuntime.plan()` instead of direct `SandboxSpawn` calls. +3. Refactor `session/prompt.ts` the same way. +4. Refactor `pty/index.ts` the same way. +5. Verify all existing sandbox behavior is preserved through the refactor. + +Exit criteria: + +- All three existing call sites use `SandboxRuntime.plan()` +- No behavioral change +- Existing tests pass without modification + +QA: + +- From `packages/opencode`, run `bun run typecheck` +- From `packages/opencode`, run `bun test --timeout 30000` for all existing sandbox tests +- Manual smoke test: enable sandbox, run bash commands, verify same behavior as before + +### Phase 3d — preset permission overlay + +Goal: merge preset permission overlays into agent rulesets. + +Steps: + +1. In `agent/agent.ts`, when a sandbox preset is active, read its `permission` overlay. +2. Insert the overlay into the `Permission.merge()` chain between defaults and agent-specific config. +3. Add tests verifying the merge order. + +Exit criteria: + +- Strict preset's `bash: ask` overlay takes effect for the build agent +- Explicit user permission config overrides the preset overlay +- Agents without permission config inherit the preset overlay + +QA: + +- From `packages/opencode`, run `bun run typecheck` +- Add a dedicated test file at `packages/opencode/test/sandbox/preset-permission.test.ts`. +- From `packages/opencode`, run: + `bun test --timeout 30000 test/sandbox/preset-permission.test.ts` +- Test case 1 — preset overlay applies when no agent or user override exists: + - Configure the `strict` preset (which sets `bash: ask`). + - Resolve the `build` agent's permission ruleset. + - Evaluate `Permission.evaluate("bash", "echo hello", ruleset)`. + - Expected result: action is `"ask"`. +- Test case 2 — agent-specific config overrides the preset overlay: + - Configure the `strict` preset (`bash: ask`). + - Configure `agent.build.permission` with `bash: allow`. + - Resolve the `build` agent's permission ruleset. + - Evaluate `Permission.evaluate("bash", "echo hello", ruleset)`. + - Expected result: action is `"allow"` (agent config wins over preset overlay). +- Test case 3 — top-level user config overrides both preset and agent config: + - Configure the `strict` preset (`bash: ask`). + - Configure `agent.build.permission` with `bash: allow`. + - Configure top-level `permission` with `bash: deny`. + - Resolve the `build` agent's permission ruleset. + - Evaluate `Permission.evaluate("bash", "echo hello", ruleset)`. + - Expected result: action is `"deny"` (top-level user config wins over both). +- Test case 4 — agent without explicit permission inherits preset overlay: + - Configure the `strict` preset (`bash: ask`, `edit: ask`). + - Do NOT set any `agent.general.permission`. + - Resolve the `general` agent's permission ruleset. + - Evaluate `Permission.evaluate("bash", "ls", ruleset)`. + - Expected result: action is `"ask"` (preset overlay is inherited). +- Test case 5 — no preset active, existing behavior preserved: + - Do NOT set a `preset` or enable sandbox. + - Resolve the `build` agent's permission ruleset. + - Evaluate `Permission.evaluate("bash", "echo hello", ruleset)`. + - Expected result: action matches the existing phase-2 default (no preset influence). + +### Phase 3e — LSP runtime sandboxing + +Goal: sandbox LSP server launches on macOS when sandbox is enabled. + +Steps: + +1. In `lsp/launch.ts`, call `SandboxRuntime.plan()` before spawning. +2. When active, replace `cmd` + `args` with the wrapped version. +3. Force `read-only` mode for LSP launches regardless of preset. +4. Validate that LSP servers can still read the project tree and provide diagnostics. +5. Validate that LSP servers cannot write to the project root. + +Exit criteria: + +- LSP servers start correctly inside the sandbox +- Language diagnostics work (TypeScript, etc.) +- Write attempts to the project root fail +- Outbound network is denied + +QA: + +- From `packages/opencode`, run `bun run typecheck` +- Manual verification: enable sandbox, open a TypeScript file, verify diagnostics appear +- Manual verification: monitor sandbox denials to ensure no unexpected reads are blocked +- Add integration tests in `packages/opencode/test/lsp/` if testable without a running LSP server + +### Phase 3f — docs + +Goal: ship accurate documentation for phase 3 capabilities. + +Steps: + +1. Update `SECURITY.md` to reflect preset support and LSP coverage. +2. Document preset model: built-ins, custom definitions, override semantics. +3. Document protected-root behavior. +4. Document LSP sandboxing and its limitations. +5. Document exclusions: MCP, internal spawn layers, domain-mediated network, non-macOS. + +Exit criteria: + +- Docs match actual coverage +- No overclaiming + +QA: + +- From the repo root, run: + `grep -n "preset\|protected.root\|LSP\|MCP\|non-macOS" SECURITY.md` +- Expected: each new capability is documented with scope and limitations +- No sentence implies all process execution is sandboxed + +--- + +## Rollout plan + +1. Ship phase 3a (preset infrastructure) behind the existing `experimental.sandbox.enabled` flag. + No new flag required. +2. Ship phase 3b (protected roots) alongside 3a. + Default protected roots activate when sandbox is enabled. +3. Ship phase 3c (runtime helper refactor) as a no-behavioral-change refactor. +4. Ship phase 3d (preset permission overlay) with presets active only when `preset` is explicitly set. +5. Ship phase 3e (LSP sandboxing) behind the existing `enabled` flag. + LSP sandboxing activates when sandbox is enabled; no additional opt-in. +6. Ship phase 3f (docs) only after manual verification of the full validation matrix. + +Each sub-phase MAY ship independently. +Phase 3c SHOULD land before 3d and 3e because they depend on `SandboxRuntime.plan()`. +Phases 3a and 3b have no ordering dependency on each other. + +--- + +## Risks and sharp edges + +### SBPL rule ordering for protected roots + +Seatbelt uses last-match-wins for conflicting rules. +If deny rules for `.git` appear before the write-allow rule for the project root, +the allow rule will override the deny. +The implementation MUST emit deny rules after allow rules. + +Mitigation: add a dedicated unit test that verifies the SBPL output order. + +### LSP server compatibility + +LSP servers may read paths not obvious from the first pass — +global npm modules, toolchain caches, language-specific state directories. +A too-restrictive read policy will break language intelligence silently. + +Mitigation: ship LSP sandboxing behind the existing experimental flag. +Monitor sandbox denials (visible in macOS Console.app) during early testing. +Document the `extra_read_roots` escape hatch prominently. + +### Preset name collisions + +If a custom preset shares a name with a built-in, +the config parser MUST reject it. +Silent override of built-in presets would break safety guarantees. + +Mitigation: validate at config parse time and emit a clear error message. + +### `sandbox-exec` deprecation + +Inherited from phases 1 and 2. +Apple has not removed `sandbox-exec` but has deprecated the Seatbelt API. +Both Codex and Claude Code still rely on it. + +### Worktree gitdir resolution + +Git worktrees store a `.git` file (not directory) that contains the path to the real gitdir. +The resolver must handle both cases and protect both the `.git` entry and the resolved target. + +Mitigation: `Protected.resolve()` reads `.git` as a file, parses the `gitdir:` line, and resolves it. +Add unit tests for both standard repos and worktrees. + +### MCP `StdioClientTransport` ownership + +The MCP SDK's `StdioClientTransport` owns the child process lifecycle. +Wrapping the command before passing it to the transport is possible but fragile. +Phase 3 explicitly defers this. + +--- + +## Open questions + +1. Should the `"default"` preset activate implicitly when `enabled: true` and no `preset` is specified, + or should preset activation require an explicit `"preset": "default"` field? + Implicit activation is more ergonomic; explicit is safer for backward compatibility. + +2. Should LSP sandbox denials be surfaced to the user in the TUI, + or only logged? + Surfacing them helps debugging but may be noisy. + +3. Should custom presets be allowed to set `fail_if_unavailable` and `allow_unsandboxed_retry`, + or should those remain top-level-only fields? + +4. What is the right default for `protected_roots` when no preset is active but sandbox is enabled? + Options: empty (backward compat with phase 2), or `[".git", ".opencode"]` (safer default). + +5. Should phase 3 add a `"permissive"` built-in preset that allows network and has no extra protected roots, + as a stepping stone toward Codex's `danger-full-access`? + This would be useful for CI but weakens the safety story. + +6. Do any built-in LSP servers (TypeScript, Go, Rust Analyzer, etc.) need write access beyond `/tmp`? + This needs empirical testing before finalizing the LSP sandbox policy. + +--- + +## Phase-3 product statement + +If phase 3 ships successfully, +the correct security statement is: + +> On macOS, +> opencode can optionally sandbox agent-issued shell commands from the bash tool, session command path, PTY interactive sessions, and LSP server launches. +> The sandbox supports named presets composing mode, network, and permission settings. +> Workspace-critical paths (`.git`, `.opencode`) are write-protected even inside writable roots. +> Operators can define custom presets and override any preset default with explicit config fields. +> MCP servers, +> internal process utilities, +> domain-mediated network controls, +> and non-macOS platforms are not covered by this phase. + +Anything broader than that is inaccurate. + +--- + +## Later phases + +Phase 3 intentionally defers the following. + +| Item | Why deferred | Likely phase | +| ------------------------------------------ | -------------------------------------------------- | ---------------- | +| MCP local server sandboxing | SDK transport owns process lifecycle | Phase 4 | +| `util/process.ts` auto-sandboxing | Shared by non-agent code paths | Phase 4 or later | +| `cross-spawn-spawner.ts` auto-sandboxing | Effect-layer spawn, infrastructure | Phase 4 or later | +| `danger-full-access` mode | Too risky for experimental | Phase 4+ | +| Domain/proxy network mediation | Substantial infrastructure (localhost proxy) | Phase 5+ | +| Automatic worktree writable-root discovery | Requires safe heuristics for multi-worktree setups | Phase 4 | +| Linux sandboxing | Different mechanism (bubblewrap, namespaces) | Phase 5+ | +| Windows sandboxing | Different mechanism entirely | Phase 6+ | + +--- + +## Pickup checklist for the next agent + +1. Read this file fully before touching code. +2. Read the phase-1 plan at `.sisyphus/plans/macos-command-sandboxing-handoff.md` for historical context. +3. Read the phase-2 plan at `.sisyphus/plans/macos-command-sandboxing-phase-2-parity.md` for the immediate baseline. +4. Verify phase 2 has landed by checking that `pty/index.ts` has sandbox wiring, + `config.ts` has `mode`, `excluded_commands`, and `fail_if_unavailable` fields, + and `sandbox/spawn.ts` has mode and excluded-command support. + If phase 2 has NOT landed, STOP and either wait or scope down to phase-1 coverage. +5. Re-read these repo files before planning edits: + - `packages/opencode/src/sandbox/policy.ts` (SBPL builder) + - `packages/opencode/src/sandbox/spawn.ts` (wrapper, plan, resolve) + - `packages/opencode/src/config/config.ts` (sandbox config, around line 1020) + - `packages/opencode/src/lsp/launch.ts` (LSP spawn, 21 lines) + - `packages/opencode/src/lsp/server.ts` (LSP lifecycle, calls `spawn()` from `launch.ts`) + - `packages/opencode/src/file/protected.ts` (TCC-protected paths) + - `packages/opencode/src/agent/agent.ts` (permission merge in `InstanceState.make`, around line 80) + - `packages/opencode/src/permission/index.ts` (`merge()`, `fromConfig()`, `evaluate()`) + - `packages/opencode/src/permission/evaluate.ts` (last-match-wins rule evaluation) +6. Start with phase 3a and 3b (preset infrastructure + protected roots). + These can land in parallel. +7. Land phase 3c (runtime helper refactor) before 3d and 3e. +8. Do NOT wire `util/process.ts`, `cross-spawn-spawner.ts`, or `mcp/index.ts`. +9. Do NOT implement `danger-full-access` mode. +10. Do NOT claim domain-mediated network controls. +11. Do NOT treat all discovered worktrees as automatically writable. +12. Do NOT allow custom presets to shadow built-in preset names. +13. Do NOT update `SECURITY.md` until the validation matrix passes. +14. Preserve raw `permission` and `experimental.sandbox.*` as explicit overrides over preset defaults. diff --git a/SECURITY.md b/SECURITY.md index c979e505cdce..b36f988cde2d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,17 +12,43 @@ 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. -### Sandboxing +### Sandboxing (macOS only, experimental) -On macOS, OpenCode can optionally sandbox agent-issued non-interactive shell commands executed through the bash tool and the session command execution path. This is opt-in and off by default. +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. -The following are **not** covered by the sandbox: +#### 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. + +#### Modes + +- **`workspace-write`** (default) — the sandboxed process can read system paths and read/write within the project workspace. +- **`read-only`** — the sandboxed process can only read; writes are limited to explicitly configured extra write roots. + +#### Configuration options + +All options live under `experimental.sandbox` in `opencode.json`: + +- **`excluded_commands`** — a pre-spawn deny list of command prefixes. Matched commands are blocked before execution on all three 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: -- PTY sessions (interactive shells) - MCP server processes - LSP server processes -- Other local process launches -- All non-macOS platforms +- Internal and shared spawn utilities 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. @@ -34,13 +60,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 (uncovered paths)** | PTY, MCP, LSP, and non-macOS execution 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 | +| Category | Rationale | +| ------------------------------------- | ------------------------------------------------------------------------- | +| **Server access when opted-in** | If you enable server mode, API access is expected behavior | +| **Sandbox escapes (uncovered paths)** | MCP, LSP, 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 | --- From 3b0c90aaeee5015d58eff5c49442ab25b2da1ae0 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Fri, 27 Mar 2026 20:27:57 +0100 Subject: [PATCH 03/23] phase 2 --- SECURITY.md | 2 +- packages/opencode/src/config/config.ts | 14 +- packages/opencode/src/pty/index.ts | 42 ++- packages/opencode/src/sandbox/policy.ts | 16 +- packages/opencode/src/sandbox/spawn.ts | 192 +++++++++- packages/opencode/src/session/prompt.ts | 279 ++++++++------ packages/opencode/src/tool/bash.ts | 343 +++++++++++------- packages/opencode/test/config/config.test.ts | 8 + .../opencode/test/pty/pty-session.test.ts | 69 +++- packages/opencode/test/sandbox/policy.test.ts | 17 + packages/opencode/test/sandbox/spawn.test.ts | 127 +++++++ .../test/session/prompt-sandbox.test.ts | 84 +++++ .../opencode/test/tool/bash-sandbox.test.ts | 198 ++++++++++ 13 files changed, 1140 insertions(+), 251 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index b36f988cde2d..c87ec44d29f2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -29,7 +29,7 @@ PTY sessions apply the sandbox profile to the initial process spawn and check `e #### Modes - **`workspace-write`** (default) — the sandboxed process can read system paths and read/write within the project workspace. -- **`read-only`** — the sandboxed process can only read; writes are limited to explicitly configured extra write roots. +- **`read-only`** — the sandboxed process can read, and writes are limited to `/tmp`, `/private/tmp`, and explicitly configured extra write roots. #### Configuration options diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2fd19eba5ff9..225401e3c12e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -223,7 +223,10 @@ const InfoSchema = Schema.Struct({ sandbox: Schema.optional( Schema.Struct({ enabled: Schema.optional(Schema.Boolean).annotate({ - description: "Enable macOS sandboxing for non-interactive shell commands", + description: "Enable macOS sandboxing for bash, session shell commands, and PTY initial spawns", + }), + mode: Schema.optional(Schema.Literals(["workspace-write", "read-only"])).annotate({ + description: "Sandbox mode for command execution (default: workspace-write)", }), extra_read_roots: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional read-only roots for macOS sandboxing", @@ -231,9 +234,18 @@ const InfoSchema = Schema.Struct({ 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", + }), }), ), openTelemetry: Schema.optional(Schema.Boolean).annotate({ diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 3d00de596a89..83a2c54286b0 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(() => 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 index 8774d790efa6..e2f2752cf492 100644 --- a/packages/opencode/src/sandbox/policy.ts +++ b/packages/opencode/src/sandbox/policy.ts @@ -1,13 +1,17 @@ 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 extra_read_roots?: string[] extra_write_roots?: string[] + extra_deny_paths?: string[] opencode_roots?: string[] allow_network?: boolean allow_unix_sockets?: boolean @@ -21,6 +25,7 @@ export namespace SandboxPolicy { } const read = ["/bin", "/sbin", "/usr", "/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[]) { @@ -44,7 +49,11 @@ export namespace SandboxPolicy { } export function build(input: Input): Output { - const denyRoots = uniq([...secret.map((item) => path.join(input.home, item)), ...(input.opencode_roots ?? [])]) + const denyRoots = uniq([ + ...secret.map((item) => path.join(input.home, item)), + ...(input.opencode_roots ?? []), + ...(input.extra_deny_paths ?? []), + ]) const readRoots = uniq([ input.cwd, input.project_root, @@ -52,7 +61,10 @@ export namespace SandboxPolicy { ...read, ...(input.extra_read_roots ?? []), ]) - const writeRoots = uniq([input.cwd, input.project_root, input.worktree_root, ...(input.extra_write_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)", diff --git a/packages/opencode/src/sandbox/spawn.ts b/packages/opencode/src/sandbox/spawn.ts index d803c195786b..829693314245 100644 --- a/packages/opencode/src/sandbox/spawn.ts +++ b/packages/opencode/src/sandbox/spawn.ts @@ -1,21 +1,26 @@ import { Config } from "@/config/config" import { Flag } from "@/flag/flag" import { Global } from "@/global" +import { BashArity } from "@/permission/arity" import { Log } from "@/util/log" import { Filesystem } from "@/util/filesystem" import os from "os" +import path from "path" import { SandboxPolicy } from "./policy" const log = Log.create({ service: "sandbox" }) const bin = "/usr/bin/sandbox-exec" export namespace SandboxSpawn { + export type Mode = SandboxPolicy.Mode + 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[] @@ -23,6 +28,17 @@ export namespace SandboxSpawn { allow_unix_sockets: boolean } + export interface Settings { + requested: boolean + mode: Mode + 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 @@ -36,9 +52,12 @@ export namespace SandboxSpawn { platform: NodeJS.Platform available: boolean home: string + mode?: Mode + fail_if_unavailable?: boolean opencode_roots?: string[] extra_read_roots?: string[] extra_write_roots?: string[] + extra_deny_paths?: string[] } export interface Output { @@ -63,10 +82,89 @@ export namespace SandboxSpawn { } } + 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 + } + + 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) => { @@ -92,6 +190,7 @@ export namespace SandboxSpawn { reason, wrapper: bin, cwd: input.cwd, + mode: input.mode ?? "workspace-write", read_roots: [], write_roots: [], unsafe_roots: [], @@ -100,17 +199,80 @@ export namespace SandboxSpawn { } 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, + mode: raw?.mode ?? "workspace-write", + 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 + } + } + + export function shouldRetry(input: { active: boolean; code: number; stderr: string }) { + if (!input.active || input.code === 0) return false + if (input.stderr.includes("sandbox-exec: sandbox_apply: Operation not permitted")) return true + if (input.stderr.includes("sandbox-exec: execvp()")) return true + if (input.stderr.includes("forbidden-sandbox-reinit")) return true + if (input.stderr.includes("Sandbox:") && input.stderr.includes("deny(1)")) return true + if (input.stderr.includes("Operation not permitted")) return true + return false + } + + 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") { - return { active: false, diag: base(input, "unsupported_platform") } + const diag = base(input, "unsupported_platform") + if (input.fail_if_unavailable) throw new Error(diag) + return { active: false, diag } } if (!input.available) { - throw new Error(base(input, "sandbox_exec_missing")) + const diag = base(input, "sandbox_exec_missing") + if (input.fail_if_unavailable) throw new Error(diag) + return { active: false, diag } } const read = scan( @@ -118,7 +280,9 @@ export namespace SandboxSpawn { input.home, ) const write = scan( - [...(input.extra_write_roots ?? []), input.cwd, input.project_root, input.worktree_root], + 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]) @@ -137,7 +301,9 @@ export namespace SandboxSpawn { home: input.home, extra_read_roots: read.good, extra_write_roots: write.good, + extra_deny_paths: input.extra_deny_paths, opencode_roots: input.opencode_roots, + mode: input.mode, allow_network: input.allow_network, allow_unix_sockets: input.allow_unix_sockets, }) @@ -148,6 +314,7 @@ export namespace SandboxSpawn { reason: "enabled", wrapper: bin, cwd: input.cwd, + mode: input.mode ?? "workspace-write", read_roots: policy.read, write_roots: policy.write, unsafe_roots: [], @@ -169,27 +336,30 @@ export namespace SandboxSpawn { } } - export async function resolve(input: ResolveInput): Promise { - const cfg = await Config.get() - const env = process.env["OPENCODE_EXPERIMENTAL_SANDBOX"] - const raw = cfg.experimental?.sandbox + export async function resolve(input: ResolveInput, cfg?: Settings): Promise { + const raw = cfg ?? (await settings()) const home = Filesystem.resolve(Global.Path.home) const tmp = Filesystem.resolve(os.tmpdir()) const temp = Filesystem.contains(tmp, home) ? [] : [tmp] - const requested = env === undefined ? raw?.enabled === true : Flag.OPENCODE_EXPERIMENTAL_SANDBOX const out = plan({ - requested, + 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: raw.mode, + fail_if_unavailable: raw.fail_if_unavailable, opencode_roots: [Global.Path.data, Global.Path.config, Global.Path.state, Global.Path.cache].map( Filesystem.resolve, ), - extra_read_roots: [...(raw?.extra_read_roots ?? []), ...temp].map(Filesystem.resolve), - extra_write_roots: [...(raw?.extra_write_roots ?? []), ...temp].map(Filesystem.resolve), + extra_read_roots: [...raw.extra_read_roots, ...temp].map(Filesystem.resolve), + extra_write_roots: + raw.mode === "read-only" + ? raw.extra_write_roots.map(Filesystem.resolve) + : [...raw.extra_write_roots, ...temp].map(Filesystem.resolve), + extra_deny_paths: raw.extra_deny_paths.map(Filesystem.resolve), allow_network: input.allow_network, allow_unix_sockets: input.allow_unix_sockets, }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b9f635388114..7e42fb7ca1c4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -780,85 +780,6 @@ 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 clean: Record = { - nu: { args: ["-c", input.command] }, - fish: { args: ["-c", input.command] }, - zsh: { args: ["-f", "-c", input.command] }, - bash: { args: ["--noprofile", "--norc", "-c", input.command] }, - cmd: { args: ["/c", input.command] }, - powershell: { args: ["-NoProfile", "-Command", input.command] }, - pwsh: { args: ["-NoProfile", "-Command", input.command] }, - "": { args: ["-c", input.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 args = - (sandbox.active ? clean : invocations)[shellName]?.args ?? (sandbox.active ? clean[""] : invocations[""]).args - const call = - sandbox.active && sandbox.profile - ? SandboxSpawn.wrap({ profile: sandbox.profile, file: sh, args }) - : { file: sh, args } - - const cmd = ChildProcess.make(call.file, call.args, { - cwd, - extendEnv: true, - env: { ...shellEnv.env, TERM: "dumb" }, - stdin: "ignore", - forceKillAfter: "3 seconds", - }) - let output = "" let aborted = false @@ -885,35 +806,185 @@ 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)) - } - }), + try { + const cfg = yield* Effect.promise(() => 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 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 clean: Record = { + nu: { args: ["-c", input.command] }, + fish: { args: ["-c", input.command] }, + zsh: { args: ["-f", "-c", input.command] }, + bash: { args: ["--noprofile", "--norc", "-c", input.command] }, + cmd: { args: ["/c", input.command] }, + powershell: { args: ["-NoProfile", "-Command", input.command] }, + pwsh: { args: ["-NoProfile", "-Command", input.command] }, + "": { args: ["-c", input.command] }, + } + + const cwd = ctx.directory + const shellEnv = yield* plugin.trigger( + "shell.env", + { cwd, sessionID: input.sessionID, callID: part.callID }, + { env: {} }, ) - yield* handle.exitCode - }).pipe( - Effect.scoped, - Effect.onInterrupt(() => - Effect.sync(() => { - aborted = true + const root = ctx.worktree === "/" ? ctx.directory : ctx.worktree + const sandbox = yield* Effect.promise(() => + SandboxSpawn.resolve({ + cwd, + project_root: ctx.directory, + worktree_root: root, }), - ), - Effect.orDie, - Effect.ensuring(finish), - Effect.exit, - ) + ) + const cleanArgs = clean[shellName]?.args ?? clean[""]?.args ?? ["-c", input.command] + const raw = { file: sh, args: invocations[shellName]?.args ?? invocations[""].args } + 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) + 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)) + } + }), + ), + ) + 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)) + } + }), + ), + ) + return yield* handle.exitCode + }).pipe( + Effect.scoped, + Effect.onInterrupt(() => + Effect.sync(() => { + aborted = true + }), + ), + Effect.exit, + ) - if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) { - return yield* Effect.failCause(exit.cause) - } + if (Exit.isFailure(exit)) { + if (Cause.hasInterruptsOnly(exit.cause)) return { code: 1, stderr } + return yield* Effect.failCause(exit.cause) + } - return { info: msg, parts: [part] } + return { code: exit.value, stderr } + }) + + let retried = false + let result = yield* exec(call) + + if ( + cfg.allow_unsandboxed_retry && + !aborted && + SandboxSpawn.shouldRetry({ active: sandbox.active, code: result.code, stderr: result.stderr }) + ) { + try { + yield* permission.ask({ + permission: "bash:unsandboxed", + patterns: [input.command], + always: [input.command], + metadata: { + reason: "sandbox_denial", + }, + sessionID: input.sessionID, + tool: { + messageID: msg.id, + callID: part.callID, + }, + ruleset: Permission.merge(agent.permission, session.permission ?? []), + }) + retried = true + output = "" + if (part.state.status === "running") { + part.state.metadata = { output: "", description: "" } + yield* sessions.updatePart(part) + } + result = yield* exec(raw) + } catch (error) { + log.info("unsandboxed retry rejected", { error, sessionID: input.sessionID }) + } + } + + if (retried) { + output += + "\n\n" + ["", "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] } + } }) const getModel = Effect.fn("SessionPrompt.getModel")(function* ( diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index f7877ff87350..cd7aabf9e9aa 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -87,6 +87,7 @@ 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] @@ -289,51 +290,76 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) }) }) -function argv(name: string, command: string) { - 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] +function raw(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { + return ChildProcess.make(shell, args(shell, command), { + cwd, + env, + stdin: "ignore", + detached: process.platform !== "win32", + }) } -async function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { +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, - }) + 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: argv(name, command), - }) - return ChildProcess.make(wrap.file, wrap.args, { - cwd, - env, - stdin: "ignore", - detached: process.platform !== "win32", + 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 ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { + 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: false, - }) + detached: process.platform !== "win32", + }), + plain, + sandbox, } - - return ChildProcess.make(command, [], { - shell, - cwd, - env, - stdin: "ignore", - detached: process.platform !== "win32", - }) } const parser = lazy(async () => { @@ -401,7 +427,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(), @@ -411,6 +443,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)) { @@ -453,6 +489,7 @@ export const BashTool = Tool.define( env: NodeJS.ProcessEnv timeout: number description: string + cfg: SandboxSpawn.Settings }, ctx: Tool.Context, ) { @@ -466,8 +503,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: { @@ -476,95 +567,107 @@ export const BashTool = Tool.define( }, }) - const code: number | null = yield* Effect.scoped( - Effect.gen(function* () { - const proc = yield* Effect.promise(() => cmd(input.shell, input.name, input.command, input.cwd, input.env)) - const handle = yield* spawner.spawn(proc) - - 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) + + yield* Effect.forkScoped( + Stream.runForEach(Stream.decodeText(handle.stdout), (chunk) => { + return write(chunk) + }), + ) + 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) + } + + 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 result = yield* exec(launch.proc) + + if ( + input.cfg.allow_unsandboxed_retry && + !result.timedOut && + !result.aborted && + SandboxSpawn.shouldRetry({ active: launch.sandbox.active, code: result.code ?? 1, stderr: result.stderr }) + ) { + try { + yield* ctx.ask({ + permission: "bash:unsandboxed", + patterns: [input.command], + always: [input.command], + metadata: { + reason: "sandbox_denial", + }, }) - - 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) - } - - return exit.kind === "exit" ? exit.code : null - }), - ).pipe(Effect.orDie) + retried = true + yield* resetOutput() + yield* ctx.metadata({ + metadata: { + output: "", + description: input.description, + }, + }) + result = yield* exec(launch.plain) + } catch (error) { + log.info("unsandboxed retry rejected", { error }) + } + } const meta: string[] = [] - if (expired) { + if (retried) meta.push("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 @@ -578,26 +681,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 } : {}), @@ -633,9 +726,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(() => SandboxSpawn.settings()) const ps = PS.has(name) const root = yield* parse(params.command, ps) - const scan = yield* collect(root, cwd, ps, shell) + const scan = yield* collect(root, cwd, ps, shell, cfg.excluded_commands) if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) yield* ask(ctx, scan) @@ -648,6 +742,7 @@ export const BashTool = Tool.define( env: yield* shellEnv(ctx, cwd), timeout, description: params.description, + cfg, }, ctx, ) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 41170feea90d..b94c372b8d34 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -148,9 +148,13 @@ test("loads experimental sandbox config", async () => { experimental: { sandbox: { enabled: true, + mode: "read-only", 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, }, }, }, @@ -160,9 +164,13 @@ test("loads experimental sandbox config", async () => { fn: async () => { const config = await load() expect(config.experimental?.sandbox?.enabled).toBe(true) + expect(config.experimental?.sandbox?.mode).toBe("read-only") 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) }, }) }) diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 3e4d6583557d..53986aa05bc7 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -25,7 +25,7 @@ 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 +66,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 +99,69 @@ 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 Pty.create({ 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 Pty.connect(info.id, ws) + out.length = 0 + await Pty.write(info.id, "AAA\n") + await wait(() => out.join("").includes("AAA")) + } finally { + await Pty.remove(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(Pty.create({ command: "python", title: "py" })).rejects.toThrow("python") + await expect( + Pty.create({ command: "env", args: ["FOO=1", "python", "-c", "print(1)"], title: "env" }), + ).rejects.toThrow("python") + await expect(Pty.create({ 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 index 64fd784ab46a..0cd6e072f69f 100644 --- a/packages/opencode/test/sandbox/policy.test.ts +++ b/packages/opencode/test/sandbox/policy.test.ts @@ -11,6 +11,7 @@ describe("sandbox.policy", () => { 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)") @@ -22,6 +23,7 @@ describe("sandbox.policy", () => { 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("adds network and unix socket rules only when requested", () => { @@ -39,4 +41,19 @@ describe("sandbox.policy", () => { expect(out.profile).toContain("network-bind") expect(out.profile).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")') + }) }) diff --git a/packages/opencode/test/sandbox/spawn.test.ts b/packages/opencode/test/sandbox/spawn.test.ts index 3483b1e08143..27f3b785fbb1 100644 --- a/packages/opencode/test/sandbox/spawn.test.ts +++ b/packages/opencode/test/sandbox/spawn.test.ts @@ -53,6 +53,41 @@ describe("sandbox.spawn", () => { 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({ @@ -68,6 +103,98 @@ describe("sandbox.spawn", () => { ).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.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("respects the env override at runtime", async () => { await using home = await tmpdir() await using tmp = await tmpdir({ diff --git a/packages/opencode/test/session/prompt-sandbox.test.ts b/packages/opencode/test/session/prompt-sandbox.test.ts index cf571435a993..be8525aaefa7 100644 --- a/packages/opencode/test/session/prompt-sandbox.test.ts +++ b/packages/opencode/test/session/prompt-sandbox.test.ts @@ -126,4 +126,88 @@ describe("session.prompt sandbox", () => { }, }) }) + + test("blocks excluded commands before spawning", async () => { + await using tmp = await tmpdir({ + 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 Session.create({}) + const out = await SessionPrompt.shell({ + 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 Session.remove(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({ + 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 Session.create({}) + const out = await SessionPrompt.shell({ + 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") + await Session.remove(session.id) + }, + }) + }) }) diff --git a/packages/opencode/test/tool/bash-sandbox.test.ts b/packages/opencode/test/tool/bash-sandbox.test.ts index 057341a0ec8f..e3ef1f6bbb1a 100644 --- a/packages/opencode/test/tool/bash-sandbox.test.ts +++ b/packages/opencode/test/tool/bash-sandbox.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { BashTool } from "../../src/tool/bash" +import { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { SessionID, MessageID } from "../../src/session/schema" import { tmpdir } from "../fixture/fixture" @@ -23,6 +24,11 @@ const ctx = { ask: async () => {}, } +const makeCtx = (ask: Tool.Context["ask"] = async () => {}) => ({ + ...ctx, + ask, +}) + afterEach(() => { if (env.HOME === undefined) delete process.env.HOME else process.env.HOME = env.HOME @@ -94,14 +100,206 @@ describe("tool.bash sandbox", () => { 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") + }, + }) + }) + + 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") }, From 155aef6b6962141bd7bde7d0c1276b36708b538b Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Fri, 27 Mar 2026 21:25:06 +0100 Subject: [PATCH 04/23] docs: update SECURITY.md for macOS sandbox phase 3 --- SECURITY.md | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index c87ec44d29f2..c18a15332109 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,9 +23,28 @@ OpenCode can optionally sandbox certain command execution paths on macOS using ` | **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 | +| **LSP server launches** | Yes | No | 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. +LSP servers are always launched with `read-only` mode and network denied, regardless of the active preset. This is hardcoded in `lsp/launch.ts` and cannot be overridden by configuration. + +#### 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. @@ -35,18 +54,19 @@ PTY sessions apply the sandbox profile to the initial process spawn and check `e All options live under `experimental.sandbox` in `opencode.json`: -- **`excluded_commands`** — a pre-spawn deny list of command prefixes. Matched commands are blocked before execution on all three covered surfaces. +- **`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`. +- **`excluded_commands`** — a pre-spawn deny list of command prefixes. Matched commands are blocked before execution on all covered surfaces except LSP launches. - **`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. +- **`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 and LSP launches do **not** support unsandboxed retry. #### Not covered The following are explicitly **not** sandboxed: -- MCP server processes -- LSP server processes -- Internal and shared spawn utilities not routed through the three surfaces above +- MCP server processes (local stdio and SSE servers) +- Internal spawn utilities (`util/process.ts`, `cross-spawn-spawner.ts`) not routed through the four surfaces above - Domain/proxy-mediated network controls - All non-macOS platforms (Linux, Windows, etc.) @@ -60,13 +80,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 (uncovered paths)** | MCP, LSP, 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 | +| 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 | --- From e909b8abb1380ca22cfc15487c27bcc444f8db41 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Fri, 27 Mar 2026 21:26:20 +0100 Subject: [PATCH 05/23] docs: document missing sandbox config knobs and no danger-full-access mode --- SECURITY.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index c18a15332109..d0a60afbda1d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -50,12 +50,19 @@ Inside writable workspace roots, `.git` and `.opencode` are always write-protect - **`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 directory basenames (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 except LSP launches. - **`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.). From 290fe86d68d7bd8ac3cfd3df8245cc4275a82ec2 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Fri, 27 Mar 2026 21:27:03 +0100 Subject: [PATCH 06/23] docs: fix protected_roots and MCP exclusion wording in SECURITY.md --- SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index d0a60afbda1d..beb45a210d63 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -60,7 +60,7 @@ All options live under `experimental.sandbox` in `opencode.json`: - **`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 directory basenames (defaults to `.git` and `.opencode`). +- **`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 except LSP launches. @@ -72,7 +72,7 @@ All options live under `experimental.sandbox` in `opencode.json`: The following are explicitly **not** sandboxed: -- MCP server processes (local stdio and SSE servers) +- MCP server processes (local stdio servers and SSE connections) - Internal spawn utilities (`util/process.ts`, `cross-spawn-spawner.ts`) not routed through the four surfaces above - Domain/proxy-mediated network controls - All non-macOS platforms (Linux, Windows, etc.) From b666bbaf5d31d96d4dd9d7ab4095d156cb4f0179 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Fri, 27 Mar 2026 23:17:25 +0100 Subject: [PATCH 07/23] phase 3 --- index.html | 150 ++++++++++++++++++ packages/app/src/i18n/ar.ts | 4 + packages/app/src/i18n/br.ts | 4 + packages/app/src/i18n/bs.ts | 4 + packages/app/src/i18n/da.ts | 4 + packages/app/src/i18n/de.ts | 4 + packages/app/src/i18n/en.ts | 4 + packages/app/src/i18n/es.ts | 4 + packages/app/src/i18n/fr.ts | 4 + packages/app/src/i18n/ja.ts | 4 + packages/app/src/i18n/ko.ts | 4 + packages/app/src/i18n/no.ts | 4 + packages/app/src/i18n/pl.ts | 4 + packages/app/src/i18n/ru.ts | 4 + packages/app/src/i18n/th.ts | 4 + packages/app/src/i18n/tr.ts | 4 + packages/app/src/i18n/zh.ts | 4 + packages/app/src/i18n/zht.ts | 4 + .../composer/session-permission-dock.tsx | 9 +- packages/opencode/2nexit | 6 + packages/opencode/src/agent/agent.ts | 13 +- .../cli/cmd/tui/routes/session/permission.tsx | 22 +++ packages/opencode/src/config/config.ts | 138 ++++++++++++---- packages/opencode/src/file/protected.ts | 30 ++++ packages/opencode/src/lsp/launch.ts | 37 ++++- packages/opencode/src/lsp/lsp.ts | 2 +- packages/opencode/src/lsp/server.ts | 103 ++++++------ packages/opencode/src/sandbox/policy.ts | 7 + packages/opencode/src/sandbox/preset.ts | 108 +++++++++++++ packages/opencode/src/sandbox/runtime.ts | 61 +++++++ packages/opencode/src/sandbox/spawn.ts | 106 ++++++++++--- packages/opencode/test/config/config.test.ts | 20 +++ packages/opencode/test/lsp/launch.test.ts | 2 +- packages/opencode/test/sandbox/policy.test.ts | 42 +++++ .../test/sandbox/preset-permission.test.ts | 117 ++++++++++++++ packages/opencode/test/sandbox/preset.test.ts | 122 ++++++++++++++ .../opencode/test/sandbox/runtime.test.ts | 53 +++++++ packages/opencode/test/sandbox/spawn.test.ts | 46 ++++++ 38 files changed, 1160 insertions(+), 102 deletions(-) create mode 100644 index.html create mode 100644 packages/opencode/2nexit create mode 100644 packages/opencode/src/sandbox/preset.ts create mode 100644 packages/opencode/src/sandbox/runtime.ts create mode 100644 packages/opencode/test/sandbox/preset-permission.test.ts create mode 100644 packages/opencode/test/sandbox/preset.test.ts create mode 100644 packages/opencode/test/sandbox/runtime.test.ts diff --git a/index.html b/index.html new file mode 100644 index 000000000000..ecc684c2f8c8 --- /dev/null +++ b/index.html @@ -0,0 +1,150 @@ +Google



 

Recherche avance

© 2026 - Confidentialit - Conditions

Applications Google
\ No newline at end of file diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 9e9a88c2d052..cc39cba81c86 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -707,6 +707,10 @@ export const dict = { "settings.permissions.tool.list.description": "سرد الملفات داخل دليل", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "تشغيل أوامر shell", + "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.task.title": "مهمة", "settings.permissions.tool.task.description": "تشغيل الوكلاء الفرعيين", "settings.permissions.tool.skill.title": "مهارة", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 5fd1aee76321..2e28acce2afa 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -717,6 +717,10 @@ export const dict = { "settings.permissions.tool.list.description": "Listar arquivos dentro de um diretório", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Executar comandos shell", + "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.task.title": "Tarefa", "settings.permissions.tool.task.description": "Lançar sub-agentes", "settings.permissions.tool.skill.title": "Habilidade", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index f872db1f00f9..e51722667218 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -791,6 +791,10 @@ export const dict = { "settings.permissions.tool.list.description": "Listanje datoteka unutar direktorija", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Pokretanje shell komandi", + "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.task.title": "Zadatak", "settings.permissions.tool.task.description": "Pokretanje pod-agenta", "settings.permissions.tool.skill.title": "Vještina", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 82f4fe3f6389..14560371e677 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -785,6 +785,10 @@ export const dict = { "settings.permissions.tool.list.description": "List filer i en mappe", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Kør shell-kommandoer", + "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.task.title": "Opgave", "settings.permissions.tool.task.description": "Start underagenter", "settings.permissions.tool.skill.title": "Færdighed", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index d5b95459ac79..c8f02619f529 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -728,6 +728,10 @@ export const dict = { "settings.permissions.tool.list.description": "Dateien in einem Verzeichnis auflisten", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Shell-Befehle ausführen", + "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.task.title": "Aufgabe", "settings.permissions.tool.task.description": "Unteragenten starten", "settings.permissions.tool.skill.title": "Fähigkeit", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 8a2fbf87f06f..37a27b294e60 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -900,6 +900,10 @@ 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.task.title": "Task", "settings.permissions.tool.task.description": "Launch sub-agents", "settings.permissions.tool.skill.title": "Skill", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 12bc45cf38bd..7dcc1d62a08e 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -798,6 +798,10 @@ export const dict = { "settings.permissions.tool.list.description": "Listar archivos dentro de un directorio", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Ejecutar comandos de shell", + "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.task.title": "Tarea", "settings.permissions.tool.task.description": "Lanzar sub-agentes", "settings.permissions.tool.skill.title": "Habilidad", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 6c98b9ca1e50..559083e0403e 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -726,6 +726,10 @@ export const dict = { "settings.permissions.tool.list.description": "Lister les fichiers dans un répertoire", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Exécuter des commandes shell", + "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.task.title": "Tâche", "settings.permissions.tool.task.description": "Lancer des sous-agents", "settings.permissions.tool.skill.title": "Compétence", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 76783341270b..b37aa7ea4b27 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -712,6 +712,10 @@ export const dict = { "settings.permissions.tool.list.description": "ディレクトリ内のファイル一覧表示", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "シェルコマンドの実行", + "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.task.title": "タスク", "settings.permissions.tool.task.description": "サブエージェントの起動", "settings.permissions.tool.skill.title": "スキル", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 76bf33df6fba..6f96cf88935c 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -707,6 +707,10 @@ export const dict = { "settings.permissions.tool.list.description": "디렉터리 내 파일 나열", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "셸 명령어 실행", + "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.task.title": "작업", "settings.permissions.tool.task.description": "하위 에이전트 실행", "settings.permissions.tool.skill.title": "기술", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 75e557b16b30..7386f4fa335c 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -792,6 +792,10 @@ export const dict = { "settings.permissions.tool.list.description": "List filer i en mappe", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Kjør shell-kommandoer", + "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.task.title": "Oppgave", "settings.permissions.tool.task.description": "Start underagenter", "settings.permissions.tool.skill.title": "Ferdighet", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 0ab4a6906cd9..c8acd904faa9 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -714,6 +714,10 @@ export const dict = { "settings.permissions.tool.list.description": "Wyświetlanie listy plików w katalogu", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Uruchamianie poleceń powłoki", + "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.task.title": "Zadanie", "settings.permissions.tool.task.description": "Uruchamianie pod-agentów", "settings.permissions.tool.skill.title": "Umiejętność", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 135c8e66c48c..02a260f70f75 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -793,6 +793,10 @@ export const dict = { "settings.permissions.tool.list.description": "Список файлов в директории", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Запуск команд оболочки", + "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.task.title": "Task", "settings.permissions.tool.task.description": "Запуск подагентов", "settings.permissions.tool.skill.title": "Skill", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 81674df32dda..ff5d265c55e7 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -781,6 +781,10 @@ export const dict = { "settings.permissions.tool.list.description": "แสดงรายการไฟล์ภายในไดเรกทอรี", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "เรียกใช้คำสั่งเชลล์", + "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.task.title": "งาน", "settings.permissions.tool.task.description": "เปิดเอเจนต์ย่อย", "settings.permissions.tool.skill.title": "ทักษะ", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index f3cb3ab464b7..c857e05babed 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -800,6 +800,10 @@ export const dict = { "settings.permissions.tool.list.description": "Bir dizindeki dosyaları listele", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Kabuk komutları çalıştır", + "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.task.title": "Görev", "settings.permissions.tool.task.description": "Alt ajanlar başlat", "settings.permissions.tool.skill.title": "Beceri", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index d95bfd19ba59..ce6033c17eec 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -779,6 +779,10 @@ export const dict = { "settings.permissions.tool.list.description": "列出目录中的文件", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "运行 shell 命令", + "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.task.title": "任务", "settings.permissions.tool.task.description": "启动子智能体", "settings.permissions.tool.skill.title": "技能", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 4a88ca4fc833..b4a27c55f309 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -775,6 +775,10 @@ export const dict = { "settings.permissions.tool.list.description": "列出目錄中的檔案", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "執行 shell 命令", + "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.task.title": "Task", "settings.permissions.tool.task.description": "啟動子代理程式", "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..47f14f6cb692 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,14 @@ 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") { + permission = + props.request.metadata?.reason === "possible_network_sandbox_denial" + ? "bash_unsandboxed_network" + : "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/2nexit b/packages/opencode/2nexit new file mode 100644 index 000000000000..482b49547cd4 --- /dev/null +++ b/packages/opencode/2nexit @@ -0,0 +1,6 @@ +1 | import fs from "fs/promises"; import path from "path"; import { tmpdir } from "./test/fixture/fixture"; import { BashTool } from "./src/tool/bash"; import { Instance } from "./src/project/instance"; import { SessionID, MessageID } from "./src/session/schema"; const ctx = { sessionID: SessionID.make("ses_dbg"), messageID: MessageID.make(""), callID: "", agent: "build", abort: AbortSignal.any([]), messages: [], metadata: () => {}, ask: async (req) => { console.log(JSON.stringify(req)); if (req.permission === "bash:unsandboxed") throw new Error("reject") } }; await using home = await tmpdir(); await using tmp = await tmpdir({ init: async (dir) => { const file = path.join(dir, "curl"); await Bun.write(file, #!/bin/shnprintf + ^ +error: Syntax Error + at /Users/flavien.darche/Documents/opencode/packages/opencode/[eval]:1:714 + +Bun v1.3.11 (macOS arm64) 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/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 54cc86a40d0a..38d54739b08e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -300,6 +300,28 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } + if (permission === "bash:unsandboxed") { + const command = typeof data.command === "string" ? data.command : "" + const reason = props.request.metadata?.reason + const isNetwork = reason === "possible_network_sandbox_denial" + return { + icon: "#", + title: "Retry shell command without sandbox", + body: ( + + + {isNetwork + ? "Sandbox networking is disabled, so the previous attempt may have failed because of the sandbox." + : "The previous sandboxed attempt was denied."} + + + {"$ " + 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/config/config.ts b/packages/opencode/src/config/config.ts index 225401e3c12e..5c48a6b9e888 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -42,6 +42,7 @@ import { ConfigServer } from "./server" import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" import { Npm } from "@/npm" +import { SandboxPreset } from "@/sandbox/preset" const log = Log.create({ service: "config" }) @@ -80,6 +81,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, PTY initial spawns, and LSP launches", + }), + 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, PTY initial spawns, and LSP launches"), + 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,34 +329,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( - Schema.Struct({ - enabled: Schema.optional(Schema.Boolean).annotate({ - description: "Enable macOS sandboxing for bash, session shell commands, and PTY initial spawns", - }), - mode: Schema.optional(Schema.Literals(["workspace-write", "read-only"])).annotate({ - description: "Sandbox mode for command execution (default: workspace-write)", - }), - 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", - }), - }), - ), + sandbox: Schema.optional(SandboxConfig), openTelemetry: Schema.optional(Schema.Boolean).annotate({ description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)", }), 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/lsp/launch.ts b/packages/opencode/src/lsp/launch.ts index fb84666b015f..9fea16875562 100644 --- a/packages/opencode/src/lsp/launch.ts +++ b/packages/opencode/src/lsp/launch.ts @@ -1,15 +1,44 @@ import type { ChildProcessWithoutNullStreams } from "child_process" +import { Instance } from "../project/instance" +import { SandboxRuntime } from "../sandbox/runtime" import { Process } from "../util" type Child = Process.Child & ChildProcessWithoutNullStreams -export function spawn(cmd: string, args: string[], opts?: Process.Options): Child -export function spawn(cmd: string, opts?: Process.Options): Child -export function spawn(cmd: string, argsOrOpts?: string[] | Process.Options, opts?: Process.Options) { +function roots(cwd: string) { + try { + const dir = Instance.directory + return { + project_root: dir, + worktree_root: Instance.worktree === "/" ? dir : Instance.worktree, + } + } catch { + return { + project_root: cwd, + worktree_root: cwd, + } + } +} + +export function spawn(cmd: string, args: string[], opts?: Process.Options): Promise +export function spawn(cmd: string, opts?: Process.Options): Promise +export async function spawn(cmd: string, argsOrOpts?: string[] | Process.Options, opts?: Process.Options) { const args = Array.isArray(argsOrOpts) ? [...argsOrOpts] : [] const cfg = Array.isArray(argsOrOpts) ? opts : argsOrOpts - const proc = Process.spawn([cmd, ...args], { + const cwd = cfg?.cwd ?? process.cwd() + const root = roots(cwd) + const plan = await SandboxRuntime.plan({ + file: cmd, + args, + cwd, + project_root: root.project_root, + worktree_root: root.worktree_root, + mode: "read-only", + allow_network: false, + }) + const proc = Process.spawn([plan.file, ...plan.args], { ...cfg, + cwd, stdin: "pipe", stdout: "pipe", stderr: "pipe", 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/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 918236806356..6a1f8a3d539f 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -84,7 +84,7 @@ export const Deno: Info = { return } return { - process: spawn(deno, ["lsp"], { + process: await spawn(deno, ["lsp"], { cwd: root, }), } @@ -104,7 +104,16 @@ export const Typescript: Info = { if (!tsserver) return const bin = await Npm.which("typescript-language-server") if (!bin) return - const proc = spawn(bin, ["--stdio"], { + const args = ["--stdio", "--tsserver-log-verbosity", "off", "--tsserver-path", tsserver] + + if ( + !(await pathExists(path.join(root, "tsconfig.json"))) && + !(await pathExists(path.join(root, "jsconfig.json"))) + ) { + args.push("--ignore-node-modules") + } + + const proc = await spawn(bin, args, { cwd: root, env: { ...process.env, @@ -135,7 +144,7 @@ export const Vue: Info = { binary = resolved } args.push("--stdio") - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -194,7 +203,7 @@ export const ESLint: Info = { log.info("installed VS Code ESLint server", { serverPath }) } - const proc = spawn("node", [serverPath, "--stdio"], { + const proc = await spawn("node", [serverPath, "--stdio"], { cwd: root, env: { ...process.env, @@ -248,13 +257,13 @@ export const Oxlint: Info = { } if (lintBin) { - const proc = spawn(lintBin, ["--help"]) + const proc = await spawn(lintBin, ["--help"]) await proc.exited if (proc.stdout) { const help = await text(proc.stdout) if (help.includes("--lsp")) { return { - process: spawn(lintBin, ["--lsp"], { + process: await spawn(lintBin, ["--lsp"], { cwd: root, }), } @@ -269,7 +278,7 @@ export const Oxlint: Info = { } if (serverBin) { return { - process: spawn(serverBin, [], { + process: await spawn(serverBin, [], { cwd: root, }), } @@ -329,7 +338,7 @@ export const Biome: Info = { args = ["lsp-proxy", "--stdio"] } - const proc = spawn(bin, args, { + const proc = await spawn(bin, args, { cwd: root, env: { ...process.env, @@ -374,7 +383,7 @@ export const Gopls: Info = { }) } return { - process: spawn(bin!, { + process: await spawn(bin!, { cwd: root, }), } @@ -412,7 +421,7 @@ export const Rubocop: Info = { }) } return { - process: spawn(bin!, ["--lsp"], { + process: await spawn(bin!, ["--lsp"], { cwd: root, }), } @@ -470,7 +479,7 @@ export const Ty: Info = { return } - const proc = spawn(binary, ["server"], { + const proc = await spawn(binary, ["server"], { cwd: root, }) @@ -512,7 +521,7 @@ export const Pyright: Info = { } } - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -581,7 +590,7 @@ export const ElixirLS: Info = { } return { - process: spawn(binary, { + process: await spawn(binary, { cwd: root, }), } @@ -693,7 +702,7 @@ export const Zls: Info = { } return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -730,7 +739,7 @@ export const CSharp: Info = { } return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -767,7 +776,7 @@ export const FSharp: Info = { } return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -784,7 +793,7 @@ export const SourceKit: Info = { const sourcekit = which("sourcekit-lsp") if (sourcekit) { return { - process: spawn(sourcekit, { + process: await spawn(sourcekit, { cwd: root, }), } @@ -801,7 +810,7 @@ export const SourceKit: Info = { const bin = lspLoc.text.trim() return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -847,7 +856,7 @@ export const RustAnalyzer: Info = { return } return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -863,7 +872,7 @@ export const Clangd: Info = { const fromPath = which("clangd") if (fromPath) { return { - process: spawn(fromPath, args, { + process: await spawn(fromPath, args, { cwd: root, }), } @@ -873,7 +882,7 @@ export const Clangd: Info = { const direct = path.join(Global.Path.bin, "clangd" + ext) if (await Filesystem.exists(direct)) { return { - process: spawn(direct, args, { + process: await spawn(direct, args, { cwd: root, }), } @@ -886,7 +895,7 @@ export const Clangd: Info = { const candidate = path.join(Global.Path.bin, entry.name, "bin", "clangd" + ext) if (await Filesystem.exists(candidate)) { return { - process: spawn(candidate, args, { + process: await spawn(candidate, args, { cwd: root, }), } @@ -993,7 +1002,7 @@ export const Clangd: Info = { log.info(`installed clangd`, { bin }) return { - process: spawn(bin, args, { + process: await spawn(bin, args, { cwd: root, }), } @@ -1014,7 +1023,7 @@ export const Svelte: Info = { binary = resolved } args.push("--stdio") - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1048,7 +1057,7 @@ export const Astro: Info = { binary = resolved } args.push("--stdio") - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1161,7 +1170,7 @@ export const JDTLS: Info = { ) const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-jdtls-data")) return { - process: spawn( + process: await spawn( java, [ "-jar", @@ -1278,7 +1287,7 @@ export const KotlinLS: Info = { return } return { - process: spawn(launcherScript, ["--stdio"], { + process: await spawn(launcherScript, ["--stdio"], { cwd: root, }), } @@ -1299,7 +1308,7 @@ export const YamlLS: Info = { binary = resolved } args.push("--stdio") - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1445,7 +1454,7 @@ export const LuaLS: Info = { } return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -1466,7 +1475,7 @@ export const PHPIntelephense: Info = { binary = resolved } args.push("--stdio") - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1494,7 +1503,7 @@ export const Prisma: Info = { return } return { - process: spawn(prisma, ["language-server"], { + process: await spawn(prisma, ["language-server"], { cwd: root, }), } @@ -1512,7 +1521,7 @@ export const Dart: Info = { return } return { - process: spawn(dart, ["language-server", "--lsp"], { + process: await spawn(dart, ["language-server", "--lsp"], { cwd: root, }), } @@ -1530,7 +1539,7 @@ export const Ocaml: Info = { return } return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -1550,7 +1559,7 @@ export const BashLS: Info = { binary = resolved } args.push("start") - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1630,7 +1639,7 @@ export const TerraformLS: Info = { } return { - process: spawn(bin, ["serve"], { + process: await spawn(bin, ["serve"], { cwd: root, }), initialization: { @@ -1724,7 +1733,7 @@ export const TexLab: Info = { } return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -1745,7 +1754,7 @@ export const DockerfileLS: Info = { binary = resolved } args.push("--stdio") - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1768,7 +1777,7 @@ export const Gleam: Info = { return } return { - process: spawn(gleam, ["lsp"], { + process: await spawn(gleam, ["lsp"], { cwd: root, }), } @@ -1789,7 +1798,7 @@ export const Clojure: Info = { return } return { - process: spawn(bin, ["listen"], { + process: await spawn(bin, ["listen"], { cwd: root, }), } @@ -1817,7 +1826,7 @@ export const Nixd: Info = { return } return { - process: spawn(nixd, [], { + process: await spawn(nixd, [], { cwd: root, env: { ...process.env, @@ -1914,7 +1923,7 @@ export const Tinymist: Info = { } return { - process: spawn(bin, { cwd: root }), + process: await spawn(bin, { cwd: root }), } }, } @@ -1930,7 +1939,7 @@ export const HLS: Info = { return } return { - process: spawn(bin, ["--lsp"], { + process: await spawn(bin, ["--lsp"], { cwd: root, }), } @@ -1948,9 +1957,13 @@ export const JuliaLS: Info = { return } return { - process: spawn(julia, ["--startup-file=no", "--history-file=no", "-e", "using LanguageServer; runserver()"], { - cwd: root, - }), + process: await spawn( + julia, + ["--startup-file=no", "--history-file=no", "-e", "using LanguageServer; runserver()"], + { + cwd: root, + }, + ), } }, } diff --git a/packages/opencode/src/sandbox/policy.ts b/packages/opencode/src/sandbox/policy.ts index e2f2752cf492..2ecbcac3813a 100644 --- a/packages/opencode/src/sandbox/policy.ts +++ b/packages/opencode/src/sandbox/policy.ts @@ -9,6 +9,7 @@ export namespace SandboxPolicy { worktree_root: string home: string mode?: Mode + protected_roots?: string[] extra_read_roots?: string[] extra_write_roots?: string[] extra_deny_paths?: string[] @@ -48,12 +49,17 @@ export namespace SandboxPolicy { ]) } + 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, @@ -77,6 +83,7 @@ export namespace SandboxPolicy { ...allow("file-read*", readRoots), ...allow("file-write*", writeRoots), ...deny(denyRoots), + ...denyWrite(protectedRoots), ...(input.allow_network ? ["(allow network*)"] : []), ...(input.allow_unix_sockets ? [ 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/runtime.ts b/packages/opencode/src/sandbox/runtime.ts new file mode 100644 index 000000000000..a504bba9ecb6 --- /dev/null +++ b/packages/opencode/src/sandbox/runtime.ts @@ -0,0 +1,61 @@ +import { SandboxSpawn } from "./spawn" + +export namespace SandboxRuntime { + export interface SpawnPlan { + active: boolean + file: string + args: string[] + env?: Record + diag: SandboxSpawn.Diag + } + + export interface Input { + file: string + args: string[] + cwd: string + project_root: string + worktree_root: string + preset?: string + mode?: SandboxSpawn.Mode + allow_network?: boolean + allow_unix_sockets?: boolean + cfg?: SandboxSpawn.Settings + } + + export async function plan(input: Input): Promise { + const sandbox = await SandboxSpawn.resolve( + { + cwd: input.cwd, + project_root: input.project_root, + worktree_root: input.worktree_root, + preset: input.preset, + mode: input.mode, + allow_network: input.allow_network, + allow_unix_sockets: input.allow_unix_sockets, + }, + input.cfg, + ) + + if (!sandbox.active || !sandbox.profile) { + return { + active: false, + file: input.file, + args: input.args, + diag: sandbox.diag, + } + } + + const cmd = SandboxSpawn.wrap({ + profile: sandbox.profile, + file: input.file, + args: input.args, + }) + + return { + active: true, + file: cmd.file, + args: cmd.args, + diag: sandbox.diag, + } + } +} diff --git a/packages/opencode/src/sandbox/spawn.ts b/packages/opencode/src/sandbox/spawn.ts index 829693314245..550b9371fab9 100644 --- a/packages/opencode/src/sandbox/spawn.ts +++ b/packages/opencode/src/sandbox/spawn.ts @@ -1,4 +1,5 @@ import { Config } from "@/config/config" +import { Protected } from "@/file/protected" import { Flag } from "@/flag/flag" import { Global } from "@/global" import { BashArity } from "@/permission/arity" @@ -7,12 +8,14 @@ import { Filesystem } from "@/util/filesystem" 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 interface Diag { requested: boolean @@ -30,9 +33,13 @@ export namespace SandboxSpawn { export interface Settings { requested: boolean - mode: Mode - extra_read_roots: string[] - extra_write_roots: string[] + 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 @@ -43,6 +50,8 @@ export namespace SandboxSpawn { cwd: string project_root: string worktree_root: string + preset?: string + mode?: Mode allow_network?: boolean allow_unix_sockets?: boolean } @@ -54,6 +63,7 @@ export namespace SandboxSpawn { home: string mode?: Mode fail_if_unavailable?: boolean + protected_roots?: string[] opencode_roots?: string[] extra_read_roots?: string[] extra_write_roots?: string[] @@ -205,9 +215,13 @@ export namespace SandboxSpawn { const raw = cfg.experimental?.sandbox return { requested: env === undefined ? raw?.enabled === true : Flag.OPENCODE_EXPERIMENTAL_SANDBOX, - mode: raw?.mode ?? "workspace-write", - extra_read_roots: raw?.extra_read_roots ?? [], - extra_write_roots: raw?.extra_write_roots ?? [], + 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, @@ -237,14 +251,45 @@ export namespace SandboxSpawn { } } - export function shouldRetry(input: { active: boolean; code: number; stderr: string }) { - if (!input.active || input.code === 0) return false - if (input.stderr.includes("sandbox-exec: sandbox_apply: Operation not permitted")) return true - if (input.stderr.includes("sandbox-exec: execvp()")) return true - if (input.stderr.includes("forbidden-sandbox-reinit")) return true - if (input.stderr.includes("Sandbox:") && input.stderr.includes("deny(1)")) return true - if (input.stderr.includes("Operation not permitted")) return true - return false + 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[] }) { @@ -302,6 +347,7 @@ export namespace SandboxSpawn { 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, @@ -338,9 +384,29 @@ export namespace SandboxSpawn { 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, @@ -349,18 +415,16 @@ export namespace SandboxSpawn { project_root: Filesystem.resolve(input.project_root), worktree_root: Filesystem.resolve(input.worktree_root), home, - mode: raw.mode, + 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: [...raw.extra_read_roots, ...temp].map(Filesystem.resolve), - extra_write_roots: - raw.mode === "read-only" - ? raw.extra_write_roots.map(Filesystem.resolve) - : [...raw.extra_write_roots, ...temp].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: input.allow_network, + allow_network: allowNetwork, allow_unix_sockets: input.allow_unix_sockets, }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index b94c372b8d34..5d79fbc6817a 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -148,13 +148,28 @@ test("loads experimental sandbox config", async () => { 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", + }, + }, + }, }, }, }, @@ -164,13 +179,18 @@ test("loads experimental sandbox config", async () => { 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" }) }, }) }) diff --git a/packages/opencode/test/lsp/launch.test.ts b/packages/opencode/test/lsp/launch.test.ts index 258e92524d86..0514ff72175c 100644 --- a/packages/opencode/test/lsp/launch.test.ts +++ b/packages/opencode/test/lsp/launch.test.ts @@ -15,7 +15,7 @@ describe("lsp.launch", () => { await fs.mkdir(dir, { recursive: true }) await Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n") - const proc = spawn(file, ["--stdio"]) + const proc = await spawn(file, ["--stdio"]) expect(await proc.exited).toBe(0) }) diff --git a/packages/opencode/test/sandbox/policy.test.ts b/packages/opencode/test/sandbox/policy.test.ts index 0cd6e072f69f..f675a5813b06 100644 --- a/packages/opencode/test/sandbox/policy.test.ts +++ b/packages/opencode/test/sandbox/policy.test.ts @@ -1,6 +1,9 @@ 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", () => { @@ -56,4 +59,43 @@ describe("sandbox.policy", () => { 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..ff1f6aa37443 --- /dev/null +++ b/packages/opencode/test/sandbox/preset-permission.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, test } from "bun:test" +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() +}) + +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 Agent.get("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 Agent.get("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 Agent.get("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 Agent.get("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 Agent.get("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..a4de1bc197f3 --- /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/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/runtime.test.ts b/packages/opencode/test/sandbox/runtime.test.ts new file mode 100644 index 000000000000..c12184981302 --- /dev/null +++ b/packages/opencode/test/sandbox/runtime.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { SandboxRuntime } from "../../src/sandbox/runtime" + +describe("sandbox.runtime", () => { + test("wraps commands with preset defaults", async () => { + const out = await SandboxRuntime.plan({ + file: "/bin/zsh", + args: ["-f", "-c", "pwd"], + cwd: "/tmp/project/app", + project_root: "/tmp/project/app", + worktree_root: "/tmp/project", + cfg: { + requested: true, + preset: "strict", + presets: {}, + extra_deny_paths: [], + excluded_commands: [], + allow_unsandboxed_retry: false, + fail_if_unavailable: false, + }, + }) + + expect(out.active).toBe(true) + expect(out.file).toBe("/usr/bin/sandbox-exec") + expect(out.args[2]).toBe("/bin/zsh") + expect(out.diag.mode).toBe("read-only") + expect(out.diag.allow_network).toBe(false) + }) + + test("lets explicit overrides win over preset defaults", async () => { + const out = await SandboxRuntime.plan({ + file: "/bin/zsh", + args: ["-f", "-c", "pwd"], + cwd: "/tmp/project/app", + project_root: "/tmp/project/app", + worktree_root: "/tmp/project", + mode: "read-only", + allow_network: false, + cfg: { + requested: true, + preset: "network", + presets: {}, + extra_deny_paths: [], + excluded_commands: [], + allow_unsandboxed_retry: false, + fail_if_unavailable: false, + }, + }) + + expect(out.diag.mode).toBe("read-only") + expect(out.diag.allow_network).toBe(false) + }) +}) diff --git a/packages/opencode/test/sandbox/spawn.test.ts b/packages/opencode/test/sandbox/spawn.test.ts index 27f3b785fbb1..ee865dbd4266 100644 --- a/packages/opencode/test/sandbox/spawn.test.ts +++ b/packages/opencode/test/sandbox/spawn.test.ts @@ -158,6 +158,13 @@ describe("sandbox.spawn", () => { }) 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, @@ -195,6 +202,45 @@ describe("sandbox.spawn", () => { ).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("respects the env override at runtime", async () => { await using home = await tmpdir() await using tmp = await tmpdir({ From 30168520cf545d7cef24fc03612c93932bacee5c Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Sat, 28 Mar 2026 00:22:40 +0100 Subject: [PATCH 08/23] some ui/ux fix - let the model know about rejection --- packages/app/src/i18n/ar.ts | 2 + packages/app/src/i18n/br.ts | 2 + packages/app/src/i18n/bs.ts | 2 + packages/app/src/i18n/da.ts | 2 + packages/app/src/i18n/de.ts | 2 + packages/app/src/i18n/en.ts | 2 + packages/app/src/i18n/es.ts | 2 + packages/app/src/i18n/fr.ts | 2 + packages/app/src/i18n/ja.ts | 2 + packages/app/src/i18n/ko.ts | 2 + packages/app/src/i18n/no.ts | 2 + packages/app/src/i18n/pl.ts | 2 + packages/app/src/i18n/ru.ts | 2 + packages/app/src/i18n/th.ts | 2 + packages/app/src/i18n/tr.ts | 2 + packages/app/src/i18n/zh.ts | 2 + packages/app/src/i18n/zht.ts | 2 + .../composer/session-permission-dock.tsx | 12 +- packages/opencode/src/cli/cmd/run.ts | 8 +- .../src/cli/cmd/tui/routes/session/index.tsx | 18 +- .../cli/cmd/tui/routes/session/permission.tsx | 22 +- .../src/cli/cmd/tui/util/transcript.ts | 9 +- packages/opencode/src/sandbox/spawn.ts | 19 ++ packages/opencode/src/session/prompt.ts | 117 ++++++++--- packages/opencode/src/tool/bash.ts | 90 +++++++-- packages/opencode/src/tool/bash.txt | 2 + packages/opencode/test/sandbox/spawn.test.ts | 14 ++ .../test/session/prompt-sandbox.test.ts | 190 +++++++++++++++++- .../opencode/test/tool/bash-sandbox.test.ts | 146 +++++++++++++- packages/ui/src/components/message-part.tsx | 7 +- 30 files changed, 630 insertions(+), 58 deletions(-) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index cc39cba81c86..91980acd1fbf 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -711,6 +711,8 @@ export const dict = { "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": "مهمة", "settings.permissions.tool.task.description": "تشغيل الوكلاء الفرعيين", "settings.permissions.tool.skill.title": "مهارة", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 2e28acce2afa..0dd3303b6cef 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -721,6 +721,8 @@ export const dict = { "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": "Tarefa", "settings.permissions.tool.task.description": "Lançar sub-agentes", "settings.permissions.tool.skill.title": "Habilidade", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index e51722667218..47f1850b746a 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -795,6 +795,8 @@ export const dict = { "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": "Zadatak", "settings.permissions.tool.task.description": "Pokretanje pod-agenta", "settings.permissions.tool.skill.title": "Vještina", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 14560371e677..8a666c8fe4c4 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -789,6 +789,8 @@ export const dict = { "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": "Opgave", "settings.permissions.tool.task.description": "Start underagenter", "settings.permissions.tool.skill.title": "Færdighed", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index c8f02619f529..71f9a8aa828a 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -732,6 +732,8 @@ export const dict = { "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": "Aufgabe", "settings.permissions.tool.task.description": "Unteragenten starten", "settings.permissions.tool.skill.title": "Fähigkeit", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 37a27b294e60..058d3d2f2468 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -904,6 +904,8 @@ export const dict = { "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/i18n/es.ts b/packages/app/src/i18n/es.ts index 7dcc1d62a08e..929eee8bf0b7 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -802,6 +802,8 @@ export const dict = { "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": "Tarea", "settings.permissions.tool.task.description": "Lanzar sub-agentes", "settings.permissions.tool.skill.title": "Habilidad", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 559083e0403e..784ec4b7435f 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -730,6 +730,8 @@ export const dict = { "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": "Tâche", "settings.permissions.tool.task.description": "Lancer des sous-agents", "settings.permissions.tool.skill.title": "Compétence", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index b37aa7ea4b27..61e807961553 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -716,6 +716,8 @@ export const dict = { "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": "タスク", "settings.permissions.tool.task.description": "サブエージェントの起動", "settings.permissions.tool.skill.title": "スキル", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 6f96cf88935c..2ad40ff765bd 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -711,6 +711,8 @@ export const dict = { "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": "작업", "settings.permissions.tool.task.description": "하위 에이전트 실행", "settings.permissions.tool.skill.title": "기술", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 7386f4fa335c..4b00087aa4c4 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -796,6 +796,8 @@ export const dict = { "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": "Oppgave", "settings.permissions.tool.task.description": "Start underagenter", "settings.permissions.tool.skill.title": "Ferdighet", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index c8acd904faa9..b4e8de10c0b6 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -718,6 +718,8 @@ export const dict = { "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": "Zadanie", "settings.permissions.tool.task.description": "Uruchamianie pod-agentów", "settings.permissions.tool.skill.title": "Umiejętność", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 02a260f70f75..68522036f285 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -797,6 +797,8 @@ export const dict = { "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": "Запуск подагентов", "settings.permissions.tool.skill.title": "Skill", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index ff5d265c55e7..96aa72c7c96a 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -785,6 +785,8 @@ export const dict = { "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": "งาน", "settings.permissions.tool.task.description": "เปิดเอเจนต์ย่อย", "settings.permissions.tool.skill.title": "ทักษะ", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index c857e05babed..d0458b13a030 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -804,6 +804,8 @@ export const dict = { "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": "Görev", "settings.permissions.tool.task.description": "Alt ajanlar başlat", "settings.permissions.tool.skill.title": "Beceri", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index ce6033c17eec..f298c4d6d109 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -783,6 +783,8 @@ export const dict = { "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": "任务", "settings.permissions.tool.task.description": "启动子智能体", "settings.permissions.tool.skill.title": "技能", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index b4a27c55f309..e351c5a9a1d0 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -779,6 +779,8 @@ export const dict = { "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": "啟動子代理程式", "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 47f14f6cb692..f5f6c737ca06 100644 --- a/packages/app/src/pages/session/composer/session-permission-dock.tsx +++ b/packages/app/src/pages/session/composer/session-permission-dock.tsx @@ -15,10 +15,14 @@ export function SessionPermissionDock(props: { const toolDescription = () => { let permission = props.request.permission if (permission === "bash:unsandboxed") { - permission = - props.request.metadata?.reason === "possible_network_sandbox_denial" - ? "bash_unsandboxed_network" - : "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]) 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 38d54739b08e..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, @@ -301,19 +303,27 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } if (permission === "bash:unsandboxed") { - const command = typeof data.command === "string" ? data.command : "" + 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: "Retry shell command without sandbox", + title: isExplicit ? "Run shell command without sandbox" : "Retry shell command without sandbox", body: ( - {isNetwork - ? "Sandbox networking is disabled, so the previous attempt may have failed because of the sandbox." - : "The previous sandboxed attempt was denied."} + {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} 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/sandbox/spawn.ts b/packages/opencode/src/sandbox/spawn.ts index 550b9371fab9..fd5fe32ed926 100644 --- a/packages/opencode/src/sandbox/spawn.ts +++ b/packages/opencode/src/sandbox/spawn.ts @@ -16,6 +16,12 @@ 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 @@ -152,6 +158,19 @@ export namespace SandboxSpawn { 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() diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7e42fb7ca1c4..a71800dd0329 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -817,9 +817,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the 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", input.command] }, - fish: { args: ["-c", input.command] }, + nu: { args: ["-c", command] }, + fish: { args: ["-c", command] }, zsh: { args: [ "-l", @@ -829,7 +831,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the [[ -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)} + eval ${JSON.stringify(command)} `, ], }, @@ -842,24 +844,24 @@ NOTE: At any point in time through this workflow you should feel free to ask the shopt -s expand_aliases [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true cd "$__oc_cwd" - eval ${JSON.stringify(input.command)} + eval ${JSON.stringify(command)} `, ], }, - cmd: { args: ["/c", input.command] }, - powershell: { args: ["-NoProfile", "-Command", input.command] }, - pwsh: { args: ["-NoProfile", "-Command", input.command] }, - "": { args: ["-c", input.command] }, + cmd: { args: ["/c", command] }, + powershell: { args: ["-NoProfile", "-Command", command] }, + pwsh: { args: ["-NoProfile", "-Command", command] }, + "": { args: ["-c", command] }, } const clean: Record = { - nu: { args: ["-c", input.command] }, - fish: { args: ["-c", input.command] }, - zsh: { args: ["-f", "-c", input.command] }, - bash: { args: ["--noprofile", "--norc", "-c", input.command] }, - cmd: { args: ["/c", input.command] }, - powershell: { args: ["-NoProfile", "-Command", input.command] }, - pwsh: { args: ["-NoProfile", "-Command", input.command] }, - "": { args: ["-c", input.command] }, + 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 @@ -937,21 +939,69 @@ NOTE: At any point in time through this workflow you should feel free to ask the return { code: exit.value, stderr } }) + let proactive = false + let rejected = false + let asked = false + if (command !== input.command && cfg.allow_unsandboxed_retry && sandbox.active) { + asked = true + try { + yield* permission.ask({ + permission: "bash:unsandboxed", + patterns: [command], + always: [command], + metadata: { + reason: "explicit_request" satisfies SandboxSpawn.UnsandboxedReason, + detail: request.detail, + }, + sessionID: input.sessionID, + tool: { + messageID: msg.id, + callID: part.callID, + }, + ruleset: Permission.merge(agent.permission, session.permission ?? []), + }) + proactive = true + } catch (error) { + rejected = true + log.info("proactive unsandboxed request rejected", { error, sessionID: input.sessionID }) + } + } + let retried = false - let result = yield* exec(call) + let reason: SandboxSpawn.RetryReason | undefined + let result + try { + result = yield* exec(proactive ? raw : call) + } catch (error) { + 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, + ) + } + throw error + } - if ( - cfg.allow_unsandboxed_retry && - !aborted && - SandboxSpawn.shouldRetry({ active: sandbox.active, code: result.code, stderr: result.stderr }) - ) { + 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 try { yield* permission.ask({ permission: "bash:unsandboxed", - patterns: [input.command], - always: [input.command], + patterns: [command], + always: [command], metadata: { - reason: "sandbox_denial", + reason, }, sessionID: input.sessionID, tool: { @@ -972,9 +1022,24 @@ NOTE: At any point in time through this workflow you should feel free to ask the } } + if (rejected) { + output += + "\n\n" + + ["", "Explicit unsandboxed request was rejected; command ran in sandbox", ""].join( + "\n", + ) + } + if (retried) { output += - "\n\n" + ["", "Retried command without sandbox after sandbox denial", ""].join("\n") + "\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 diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index cd7aabf9e9aa..cc448fa3e275 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -397,6 +397,7 @@ export const BashTool = Tool.define( const fs = yield* AppFileSystem.Service const trunc = yield* Truncate.Service const plugin = yield* Plugin.Service + const sandbox = yield* Effect.promise(() => SandboxSpawn.settings()) const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) { const lines = yield* spawner @@ -485,6 +486,8 @@ export const BashTool = Tool.define( shell: string name: string command: string + source: string + detail?: string cwd: string env: NodeJS.ProcessEnv timeout: number @@ -629,21 +632,63 @@ export const BashTool = Tool.define( }) let retried = false - let result = yield* exec(launch.proc) - - if ( - input.cfg.allow_unsandboxed_retry && - !result.timedOut && - !result.aborted && - SandboxSpawn.shouldRetry({ active: launch.sandbox.active, code: result.code ?? 1, stderr: result.stderr }) - ) { + let proactive = false + let rejected = false + let asked = false + + if (input.command !== input.source && input.cfg.allow_unsandboxed_retry && launch.sandbox.active) { + asked = true try { yield* ctx.ask({ permission: "bash:unsandboxed", patterns: [input.command], always: [input.command], metadata: { - reason: "sandbox_denial", + reason: "explicit_request" satisfies SandboxSpawn.UnsandboxedReason, + detail: input.detail, + }, + }) + proactive = true + } catch (error) { + rejected = true + log.info("proactive unsandboxed request rejected", { error }) + } + } + + let reason: SandboxSpawn.RetryReason | undefined + let result + try { + result = yield* exec(proactive ? launch.plain : launch.proc) + } catch (error) { + 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, + ) + } + throw error + } + + 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, + }) + } + + if (input.cfg.allow_unsandboxed_retry && !asked && !result.timedOut && !result.aborted && reason) { + asked = true + try { + yield* ctx.ask({ + permission: "bash:unsandboxed", + patterns: [input.command], + always: [input.command], + metadata: { + reason, }, }) retried = true @@ -661,7 +706,16 @@ export const BashTool = Tool.define( } const meta: string[] = [] - if (retried) meta.push("Retried command without sandbox after sandbox denial") + 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.`, @@ -715,10 +769,18 @@ export const BashTool = Tool.define( .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}", + sandbox.allow_unsandboxed_retry + ? "\n\nIf 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 @@ -728,7 +790,7 @@ export const BashTool = Tool.define( const timeout = params.timeout ?? DEFAULT_TIMEOUT const cfg = yield* Effect.promise(() => SandboxSpawn.settings()) const ps = PS.has(name) - const root = yield* parse(params.command, ps) + 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) @@ -737,7 +799,9 @@ export const BashTool = Tool.define( { shell, name, - command: params.command, + command, + source: params.command, + detail: request.detail, cwd, env: yield* shellEnv(ctx, cwd), timeout, 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/sandbox/spawn.test.ts b/packages/opencode/test/sandbox/spawn.test.ts index ee865dbd4266..44e7866c3655 100644 --- a/packages/opencode/test/sandbox/spawn.test.ts +++ b/packages/opencode/test/sandbox/spawn.test.ts @@ -241,6 +241,20 @@ describe("sandbox.spawn", () => { ).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({ diff --git a/packages/opencode/test/session/prompt-sandbox.test.ts b/packages/opencode/test/session/prompt-sandbox.test.ts index be8525aaefa7..56587429ab70 100644 --- a/packages/opencode/test/session/prompt-sandbox.test.ts +++ b/packages/opencode/test/session/prompt-sandbox.test.ts @@ -1,7 +1,9 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" +import { Permission } from "../../src/permission" import { Instance } from "../../src/project/instance" +import { SandboxRuntime } from "../../src/sandbox/runtime" import { Session } from "../../src/session" import { SessionPrompt } from "../../src/session/prompt" import { tmpdir } from "../fixture/fixture" @@ -12,6 +14,15 @@ const env = { SHELL: process.env.SHELL, } +async function waitForPending(count: number) { + for (let i = 0; i < 20; i++) { + const list = await Permission.list() + if (list.length === count) return list + await Bun.sleep(0) + } + return Permission.list() +} + afterEach(() => { if (env.HOME === undefined) delete process.env.HOME else process.env.HOME = env.HOME @@ -210,4 +221,181 @@ describe("session.prompt sandbox", () => { }, }) }) + + 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 using tmp = await tmpdir({ + 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 Session.create({}) + const out = await SessionPrompt.shell({ + 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") + await Session.remove(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({ + 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 Session.create({}) + const run = SessionPrompt.shell({ + 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 Permission.reply({ + 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 Session.remove(session.id) + }, + }) + }) + + test("signals when explicit rejection is followed by sandboxed launch failure", async () => { + if (process.platform !== "darwin") return + const plan = spyOn(SandboxRuntime, "plan").mockResolvedValue({ + active: true, + file: "/definitely/missing-sandbox-exec", + args: [], + diag: { + requested: true, + active: true, + reason: "enabled", + wrapper: "/usr/bin/sandbox-exec", + cwd: "/tmp/project", + mode: "workspace-write", + read_roots: [], + write_roots: [], + unsafe_roots: [], + allow_network: false, + allow_unix_sockets: false, + }, + }) + try { + await using tmp = await tmpdir({ + 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 Session.create({}) + const run = SessionPrompt.shell({ + sessionID: session.id, + agent: "build", + command: "# opencode:unsandboxed needs network\nwget google.com", + }) + const pending = await waitForPending(1) + expect(pending).toHaveLength(1) + await Permission.reply({ + 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 Session.remove(session.id) + }, + }) + } finally { + plan.mockRestore() + } + }) }) diff --git a/packages/opencode/test/tool/bash-sandbox.test.ts b/packages/opencode/test/tool/bash-sandbox.test.ts index e3ef1f6bbb1a..cdde3b541d30 100644 --- a/packages/opencode/test/tool/bash-sandbox.test.ts +++ b/packages/opencode/test/tool/bash-sandbox.test.ts @@ -1,7 +1,8 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" import { BashTool } from "../../src/tool/bash" +import { SandboxRuntime } from "../../src/sandbox/runtime" import { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { SessionID, MessageID } from "../../src/session/schema" @@ -262,6 +263,49 @@ describe("tool.bash sandbox", () => { }) }) + 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 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") + }, + }) + }) + test("keeps the original denial when unsandboxed retry is rejected", async () => { if (process.platform !== "darwin") return await using home = await tmpdir({ @@ -306,6 +350,106 @@ describe("tool.bash sandbox", () => { }) }) + 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 plan = spyOn(SandboxRuntime, "plan").mockResolvedValue({ + active: true, + file: "/definitely/missing-sandbox-exec", + args: [], + diag: { + requested: true, + active: true, + reason: "enabled", + wrapper: "/usr/bin/sandbox-exec", + cwd: "/tmp/project", + mode: "workspace-write", + read_roots: [], + write_roots: [], + unsafe_roots: [], + allow_network: false, + allow_unix_sockets: false, + }, + }) + 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 { + plan.mockRestore() + } + }) + test("preserves timeout and abort through the sandbox wrapper", async () => { if (process.platform !== "darwin") return await using home = await tmpdir() 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) From 0299c66743f468ab2e3be5f5eb6996da8bb1d8e5 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Sat, 28 Mar 2026 00:56:36 +0100 Subject: [PATCH 09/23] fix dev deps precommit --- bun.lock | 20 +++++++++++--------- package.json | 2 ++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index 0ba00b23f8b2..3483f9da05cc 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,8 @@ }, "devDependencies": { "@actions/artifact": "5.0.1", + "@opentui/core": "0.1.90", + "@opentui/solid": "0.1.90", "@tsconfig/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", @@ -3234,7 +3236,7 @@ "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], "finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="], @@ -3728,7 +3730,7 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], @@ -4132,7 +4134,7 @@ "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], - "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], @@ -5888,6 +5890,8 @@ "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + "log-symbols/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -5968,7 +5972,7 @@ "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], - "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], @@ -6712,7 +6716,7 @@ "parse-bmfont-xml/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "readable-stream/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -7048,9 +7052,7 @@ "ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], - - "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -7140,7 +7142,7 @@ "opencontrol/@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/package.json b/package.json index 06bf9c91aef0..1d745bc8074c 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,8 @@ }, "devDependencies": { "@actions/artifact": "5.0.1", + "@opentui/core": "0.1.90", + "@opentui/solid": "0.1.90", "@tsconfig/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", From a3f30b83b768d10cb908b704527fc038ad0678e8 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Sat, 28 Mar 2026 14:32:51 +0100 Subject: [PATCH 10/23] cleanup --- .../plans/macos-command-sandboxing-handoff.md | 631 ------------ ...macos-command-sandboxing-phase-2-parity.md | 710 ------------- ...-sandboxing-phase-3-presets-and-runtime.md | 953 ------------------ index.html | 150 --- packages/opencode/2nexit | 6 - 5 files changed, 2450 deletions(-) delete mode 100644 .sisyphus/plans/macos-command-sandboxing-handoff.md delete mode 100644 .sisyphus/plans/macos-command-sandboxing-phase-2-parity.md delete mode 100644 .sisyphus/plans/macos-command-sandboxing-phase-3-presets-and-runtime.md delete mode 100644 index.html delete mode 100644 packages/opencode/2nexit diff --git a/.sisyphus/plans/macos-command-sandboxing-handoff.md b/.sisyphus/plans/macos-command-sandboxing-handoff.md deleted file mode 100644 index adb96f8b15df..000000000000 --- a/.sisyphus/plans/macos-command-sandboxing-handoff.md +++ /dev/null @@ -1,631 +0,0 @@ -# macOS Command Sandboxing Handoff Plan - -## Status - -This is a handoff plan only. -No implementation work is included. -The goal is to let another agent pick this up later without repeating discovery. - -## Objective - -Add real macOS command sandboxing to opencode for agent-issued shell commands. -The first shippable phase should cover only the two arbitrary shell-command paths: -`packages/opencode/src/tool/bash.ts` and `packages/opencode/src/session/prompt.ts`. -The enforcement mechanism should be macOS Seatbelt via `/usr/bin/sandbox-exec -p `. -The sandbox should sit below the existing permission prompts. - -## Why this is needed - -`SECURITY.md` explicitly says opencode does **not** sandbox the agent. -Today, the permission system is a UX gate, not a security boundary. -Once permission is granted, the command runs with the full privileges of the current user. - -## Confirmed repo facts - -### Existing arbitrary shell-command paths - -- `packages/opencode/src/tool/bash.ts` - - Parses the command for permission prompts. - - Calls `child_process.spawn` directly. - - Uses `Shell.acceptable()` from `packages/opencode/src/shell/shell.ts`. -- `packages/opencode/src/session/prompt.ts` - - Builds shell-specific invocation arguments. - - Calls `child_process.spawn` directly. - - Currently sources shell rc files for some shells before `eval`. - -### Shared spawn layers that exist, but should not be claimed as phase-1 coverage - -- `packages/opencode/src/util/process.ts` -- `packages/opencode/src/effect/cross-spawn-spawner.ts` -- `packages/opencode/src/lsp/launch.ts` -- `packages/opencode/src/lsp/server.ts` - -These are part of the larger spawn surface. -They matter for later expansion, but wiring them in phase 1 would silently broaden scope to LSP and internal tooling. - -### Explicitly out of scope for phase 1 - -- `packages/opencode/src/pty/index.ts` - - PTY / interactive shell path. - - High compatibility risk. -- `packages/opencode/src/mcp/index.ts` - - Local MCP server process launch. - - Separate risk surface. -- Linux. -- Windows. - -### Permission and config paths - -- `packages/opencode/src/config/config.ts` - - Current permission config surface. -- `packages/opencode/src/permission/index.ts` - - Current runtime permission enforcement. -- `packages/opencode/src/flag/flag.ts` - - Current env-flag surface. - -## Confirmed external evidence - -- Codex uses macOS Seatbelt via `/usr/bin/sandbox-exec -p `. -- Claude Code documents macOS Seatbelt and uses a localhost proxy for controlled networking. -- Anthropic's open-source sandbox runtime generates SBPL dynamically, - uses default-deny, - scopes file access to explicit roots, - and treats Unix sockets as dangerous. -- Oracle recommendation: - add one sandbox launcher under local command execution, - ship non-interactive flows first, - and do not pretend PTY has parity in v1. -- Red-team review: - do not overclaim broad spawn coverage, - do not allow broad `$HOME` reads, - do not leave Unix sockets open while claiming network deny, - and do not assume Bun-compiled darwin binaries work correctly with `sandbox-exec` until proven. - -## Phase-1 product statement - -If phase 1 ships successfully, -the correct security statement is: - -> On macOS, -> opencode can optionally sandbox agent-issued non-interactive shell commands from the bash tool and session command path. -> PTY sessions, -> MCP servers, -> LSP servers, -> and non-macOS platforms are not covered by this phase. - -Anything broader than that is inaccurate. - -## Phase-1 design - -### Design principles - -1. Make scope narrow and explicit. -2. Use a reusable sandbox module, - but only wire it into the two in-scope shell-command paths first. -3. Keep permission prompts as the UX layer. -4. Treat the sandbox as the OS enforcement layer. -5. Prefer deny-by-default over permissive compatibility shortcuts. -6. Avoid broad `$HOME` read access. -7. Deny Unix sockets in phase 1. -8. Prove Bun-compiled darwin compatibility before changing core execution paths. - -### New modules to add - -#### `packages/opencode/src/sandbox/policy.ts` - -Responsible for generating the Seatbelt policy string. -Inputs should be plain data. -Avoid hiding filesystem lookups inside the policy builder. - -Suggested inputs: - -- `cwd` -- `project_root` -- `worktree_root` -- `extra_read_roots` -- `extra_write_roots` -- `allow_network` -- `allow_unix_sockets` - -Suggested output: - -- one SBPL string for use with `/usr/bin/sandbox-exec -p ` - -#### `packages/opencode/src/sandbox/spawn.ts` - -Responsible for the macOS-specific wrapper logic. -This module should not decide product policy. -It should only translate validated inputs into a wrapped command. - -Suggested responsibilities: - -- detect whether sandboxing is enabled -- guard on `process.platform === "darwin"` -- guard on `/usr/bin/sandbox-exec` existing -- build the wrapped argv array -- provide structured diagnostics when the wrapper cannot run - -Suggested shape: - -- a pure helper that returns wrapped argv -- a small runtime helper that executes or delegates to existing spawn logic - -### Files to change in phase 1 - -#### `packages/opencode/src/tool/bash.ts` - -Current state: - -- computes permission patterns -- asks for `external_directory` and `bash` -- then spawns the command directly - -Planned change: - -- after permission is granted, - route execution through the macOS sandbox wrapper when sandboxing is enabled -- do not rely on Node's `shell: true` path in sandbox mode -- instead, - build an explicit shell command argv using `Shell.acceptable()` - and pass that argv through the wrapper - -Why: - -- sandboxing an explicit argv is easier to reason about than stacking `shell: true` under the wrapper -- it makes the executed shell binary visible and testable - -#### `packages/opencode/src/session/prompt.ts` - -Current state: - -- builds shell-specific args -- directly spawns the shell -- currently sources shell rc files in some code paths - -Planned change: - -- route the final shell argv through the same sandbox wrapper in sandbox mode -- do **not** expand the policy to broad `$HOME` reads just to preserve shell personalization -- prefer deterministic shell startup in sandbox mode, - even if that means reducing or disabling rc-file sourcing there - -Sharp edge: - -- this path currently reads `~/.zshenv`, - `~/.zshrc`, - and `~/.bashrc` in some cases -- broad `$HOME` reads are too risky -- phase 1 should favor a minimal shell environment plus the existing `shell.env` plugin hook, - not full shell personalization - -#### `packages/opencode/src/config/config.ts` - -Add an opt-in config surface. -Keep it under `experimental` for phase 1. - -Suggested shape: - -```ts -experimental: { - sandbox: { - enabled?: boolean - extra_read_roots?: string[] - extra_write_roots?: string[] - allow_unsandboxed_retry?: boolean - } -} -``` - -Notes: - -- keep the default `enabled` value `false` for the first rollout -- use explicit read and write roots, - not a vague `allowPaths` -- `allow_unsandboxed_retry` must default to `false` - -#### `packages/opencode/src/flag/flag.ts` - -Add an env override for local testing. - -Suggested flag: - -- `OPENCODE_EXPERIMENTAL_SANDBOX` - -This should be an override, -not the only control surface. - -#### `SECURITY.md` - -Update only after validation passes. -The update must name both coverage and exclusions. -Do not describe the feature as general command sandboxing across the product. - -## Phase-1 policy - -### High-level posture - -- default deny -- explicit read roots -- explicit write roots -- no outbound network -- no Unix sockets -- no broad `$HOME` reads - -### Allow rules the implementation will likely need - -These are policy categories, -not exact final SBPL syntax. - -#### Read-only roots - -- project root -- current worktree root -- `/bin` -- `/usr` -- `/System` -- `/Library` -- `/tmp` -- `/private/tmp` -- `/dev` -- `/opt/homebrew` on Apple Silicon machines, - if read-only access is needed -- `/usr/local` on Intel/Homebrew machines, - if read-only access is needed -- `/nix/store` only if the operator explicitly adds it - -#### Writable roots - -- project root -- current worktree root -- temp dir only if required by actual command behavior -- explicitly configured extra write roots - -Keep this list small. -Do not open general cache directories until a concrete failing workflow requires it. - -### Explicit deny intent - -Phase 1 should explicitly deny access to: - -- outbound network -- loopback network unless a later phase introduces controlled mediation -- Unix sockets -- credential-heavy home paths, - including at minimum: - - `~/.ssh` - - `~/.gnupg` - - `~/.aws` - - `~/.azure` - - `~/.config/gcloud` - - `~/.netrc` - - `~/.npmrc` - - opencode config and state directories - -Do not loosen this just to reduce breakage. -Breakage is preferable to fake security. - -## Interaction with existing permissions - -The existing permission system stays in place. - -Correct mental model: - -- permission prompt decides whether the agent is allowed to attempt the tool call -- sandbox decides what the spawned command can actually do on the host - -This means: - -- permission allow + sandbox deny = command still denied by sandbox -- permission deny = command denied before sandbox execution - -If the product wants a rerun outside the sandbox, -that must be explicit. - -Recommended product rule: - -- add a separate unsandboxed retry path only if needed -- gate it behind a distinct permission -- never widen the sandbox automatically after a denial - -## Rollout plan - -### Phase 0 — inventory and proof work - -Goal: -prove the wrapper mechanism before integrating it. - -Steps: - -1. Audit the full spawn surface once, - and write down the classification: - - shell-command - - internal-tool - - mcp - - pty - - cli-only -2. Build a small local proof that a Bun-compiled darwin binary can launch a child via - `/usr/bin/sandbox-exec -p `. -3. Prove that inline `-p` policy strings work correctly, - so the implementation does not need temp profile files. - -Exit criteria: - -- confirmed compatibility on a macOS host -- concrete list of uncovered spawn families for later phases - -QA for this phase: - -- from `packages/opencode`, run `bun run build -- --single` -- on Apple Silicon, run `./dist/opencode-darwin-arm64/bin/opencode --version` -- on Intel, run `./dist/opencode-darwin-x64/bin/opencode --version` -- then run the same compiled binary through Seatbelt with a proof-only profile: - - Apple Silicon: - `/usr/bin/sandbox-exec -p '(version 1) (allow default)' ./dist/opencode-darwin-arm64/bin/opencode --version` - - Intel: - `/usr/bin/sandbox-exec -p '(version 1) (allow default)' ./dist/opencode-darwin-x64/bin/opencode --version` -- expected result: - - both commands exit `0` - - both print a version string - - the wrapped binary does not crash or fail due to Bun compile/runtime issues -- optional extra proof from the same macOS shell: - `/usr/bin/sandbox-exec -p '(version 1) (allow default) (deny network*)' /bin/bash -lc 'curl -I https://example.com'` -- expected result: - - the curl command fails with a network denial - - this proves the host accepts inline `-p` policies before opencode integration starts - -### Phase 1a — reusable sandbox modules - -Goal: -land `sandbox/policy.ts` and `sandbox/spawn.ts` with no behavior change by default. - -Steps: - -1. Add config parsing and env override. -2. Add the policy builder. -3. Add the wrapper helper. -4. Add unit tests for profile generation and wrapper argv generation. - -Exit criteria: - -- code compiles -- tests pass -- feature remains off by default - -QA for this phase: - -- add dedicated tests at: - - `packages/opencode/test/sandbox/policy.test.ts` - - `packages/opencode/test/sandbox/spawn.test.ts` -- from `packages/opencode`, run `bun run typecheck` -- from `packages/opencode`, run `bun test --timeout 30000 test/sandbox/policy.test.ts test/sandbox/spawn.test.ts` -- expected result: - - typecheck exits `0` - - both sandbox unit test files pass - - with sandbox disabled by default, existing behavior is unchanged - -### Phase 1b — integrate `bash.ts` - -Goal: -sandbox the bash tool on macOS when enabled. - -Steps: - -1. keep the existing permission flow intact -2. switch sandboxed execution to explicit shell argv -3. add integration tests for allowed and denied behavior -4. verify abort and timeout behavior still work through the wrapper - -Exit criteria: - -- bash tool still works for in-project read and write operations -- denied operations fail predictably -- timeout and abort still terminate the child correctly - -QA for this phase: - -- add focused coverage in either: - - `packages/opencode/test/tool/bash.test.ts`, or - - a new `packages/opencode/test/tool/bash-sandbox.test.ts` -- from `packages/opencode`, run: - - `bun test --timeout 30000 test/tool/bash.test.ts` - if the existing file is extended, or - - `bun test --timeout 30000 test/tool/bash-sandbox.test.ts` - if a dedicated file is added -- expected result: - - a command that reads or writes inside the fixture project succeeds - - a command that writes outside the project root fails - - a command that attempts a sensitive home-path read fails - - timeout still terminates the wrapped child - - abort still terminates the wrapped child - -### Phase 1c — integrate `session/prompt.ts` - -Goal: -sandbox the session-command execution path on macOS when enabled. - -Steps: - -1. route the final shell argv through the wrapper -2. simplify shell startup in sandbox mode if needed -3. do not grant broad home-directory reads to preserve rc loading -4. test command output streaming and abort behavior - -Exit criteria: - -- session command execution works in sandbox mode for allowed operations -- denied operations fail cleanly -- no broad `$HOME` policy expansion was added to preserve shell rc loading - -QA for this phase: - -- add focused coverage in either: - - `packages/opencode/test/session/prompt.test.ts`, or - - a new `packages/opencode/test/session/prompt-sandbox.test.ts` -- from `packages/opencode`, run: - - `bun test --timeout 30000 test/session/prompt.test.ts` - if the existing file is extended, or - - `bun test --timeout 30000 test/session/prompt-sandbox.test.ts` - if a dedicated file is added -- expected result: - - an allowed command still streams output correctly - - a denied filesystem action fails cleanly - - rc-file behavior in sandbox mode is deterministic and documented - - no test requires broad `$HOME` reads to pass - -### Phase 1d — docs and release guardrails - -Goal: -ship accurate operator-facing documentation. - -Steps: - -1. update `SECURITY.md` -2. document opt-in enablement -3. document exclusions: - PTY, - MCP, - LSP, - non-macOS -4. document how sandbox denials should be interpreted - -Exit criteria: - -- docs match actual coverage -- no overclaiming remains - -QA for this phase: - -- from the repo root, run: - `grep -n "non-interactive shell commands\|PTY\|MCP\|LSP\|non-macOS" SECURITY.md` -- expected result: - - `SECURITY.md` explicitly states macOS-only, - opt-in, - non-interactive shell-command coverage - - `SECURITY.md` explicitly names PTY, - MCP, - LSP, - and non-macOS as exclusions - - no section implies that all local process execution is sandboxed - -## Validation plan - -### Unit tests - -- profile generation for default-deny policy -- wrapper argv generation on darwin -- no-op wrapper behavior on non-darwin -- config parsing for `experimental.sandbox` - -### macOS integration tests - -These must run on a macOS host. - -#### Core command behavior - -1. allowed read inside project root succeeds -2. allowed write inside project root succeeds -3. write outside project root fails -4. read of `~/.ssh` fails -5. outbound HTTP request fails -6. Unix socket access fails - -#### Control flow behavior - -7. timeout still kills the wrapped process -8. abort still kills the wrapped process -9. stderr and stdout still stream correctly - -#### Product behavior - -10. sandbox disabled => current behavior preserved -11. non-darwin => current behavior preserved -12. permission allow does not override sandbox deny - -### Manual verification - -Before updating `SECURITY.md`: - -1. build the darwin binary with the existing Bun compile pipeline -2. run a smoke test using the compiled binary, - not just source mode -3. verify that the wrapper still works there -4. inspect failure output for usability - -## Risks and sharp edges - -### `sandbox-exec` deprecation - -This is a real risk. -The current evidence still points to Seatbelt via `sandbox-exec` as the practical macOS path, -and Codex plus Claude Code both rely on Seatbelt today. -Still, -the implementation should document this as a phase-1 compromise, -not a forever-stable API choice. - -### Shell startup behavior drift - -`session/prompt.ts` currently sources user shell rc files. -Sandbox mode likely cannot preserve that safely. -Expect behavioral differences there. -Prefer deterministic execution over expansive read permissions. - -### Hidden path dependencies - -Toolchains may need read access to paths not obvious from the first pass, -especially Homebrew and Xcode-related locations. -This is exactly why the feature should ship opt-in first. - -### Timeout and kill semantics through the wrapper - -The wrapper adds another process layer. -Abort and timeout behavior must be revalidated, -not assumed. - -### False security if scope is described too broadly - -If documentation or release notes imply that all local process execution is sandboxed, -that will be incorrect. -Phase 1 is intentionally narrower. - -## Open questions - -1. Should the session-command path in sandbox mode skip rc-file sourcing entirely, - or allow a small explicit set of startup files? -2. Which read-only system paths are actually required on Apple Silicon versus Intel Macs? -3. Does any in-scope workflow require loopback access in phase 1, - or can loopback stay denied? -4. Is an explicit unsandboxed retry path needed in phase 1, - or can it wait until phase 2? -5. Should MCP sandboxing be the next follow-up after phase 1, - given its risk profile? - -## Pickup checklist for the next agent - -1. Read this file fully before touching code. -2. Re-read these repo files before planning edits: - - `packages/opencode/src/tool/bash.ts` - - `packages/opencode/src/session/prompt.ts` - - `packages/opencode/src/config/config.ts` - - `packages/opencode/src/permission/index.ts` - - `packages/opencode/src/shell/shell.ts` -3. Start with Phase 0 proof work. -4. Do **not** start by wiring `util/process.ts` or `cross-spawn-spawner.ts` broadly. -5. Keep phase-1 implementation limited to `bash.ts` and `session/prompt.ts` unless the proof work changes scope explicitly. -6. Do **not** allow broad `$HOME` reads. -7. Do **not** leave Unix sockets open while claiming network deny. -8. Do **not** update `SECURITY.md` until the macOS validation matrix passes. - -## Suggested first implementation sequence - -1. prove `sandbox-exec -p` works correctly with the Bun-compiled darwin binary -2. add sandbox config and env-flag parsing -3. add `sandbox/policy.ts` -4. add `sandbox/spawn.ts` -5. wire `bash.ts` -6. validate -7. wire `session/prompt.ts` -8. validate again -9. update docs - -That sequence minimizes the chance of broad regressions, -and it keeps the first shipped claim narrow and accurate. diff --git a/.sisyphus/plans/macos-command-sandboxing-phase-2-parity.md b/.sisyphus/plans/macos-command-sandboxing-phase-2-parity.md deleted file mode 100644 index f6ce24aa92e2..000000000000 --- a/.sisyphus/plans/macos-command-sandboxing-phase-2-parity.md +++ /dev/null @@ -1,710 +0,0 @@ -# macOS Command Sandboxing Phase 2: Parity Plan - -## Status - -This is a handoff plan for phase 2 of macOS command sandboxing. -No implementation work is included. -Another engineer MUST be able to implement phase 2 from this plan without redoing discovery. - -## Objective - -Extend macOS Seatbelt sandboxing to cover all agent-visible local command surfaces, -implement sandbox-first execution with explicit host-level retry, -and close the most impactful gaps relative to Codex and Claude Code sandbox capabilities. - -Phase 2 does NOT aim for full parity with either product. -It aims to get materially closer while keeping claims truthful. - -## Phase-1 baseline - -Phase 1 shipped sandbox coverage for exactly two non-interactive shell-command paths: - -- `packages/opencode/src/tool/bash.ts` (the bash tool) -- `packages/opencode/src/session/prompt.ts` (`SessionPrompt.shell()`) - -Phase 1 also delivered: - -- `packages/opencode/src/sandbox/policy.ts` — SBPL profile builder -- `packages/opencode/src/sandbox/spawn.ts` — macOS wrapper, plan/resolve/wrap helpers -- Config surface under `experimental.sandbox` with `enabled`, `extra_read_roots`, `extra_write_roots`, `allow_unsandboxed_retry` -- `OPENCODE_EXPERIMENTAL_SANDBOX` env override via `flag/flag.ts` -- Default-deny posture with explicit read/write roots, credential-path deny rules, no network, no Unix sockets - -Phase 1 explicitly does NOT cover: - -- `packages/opencode/src/pty/index.ts` (PTY / interactive shell) -- MCP server process launch (`mcp/index.ts`) -- LSP server launch (`lsp/launch.ts`, `lsp/server.ts`) -- Shared spawn layers (`util/process.ts`, `effect/cross-spawn-spawner.ts`) -- Linux or Windows -- `allow_unsandboxed_retry` runtime behavior (config key exists, runtime does not implement it) - ---- - -## Parity targets - -### What Codex documents - -Codex's sandbox model exposes three documented knobs: - -- `sandbox_mode`: `read-only`, `workspace-write`, `danger-full-access` -- `approval_policy`: separate layer from sandbox -- `sandbox_workspace_write.writable_roots`: explicit writable path set - -Sandbox and approval are independent layers. -The agent can run with a restrictive sandbox and a permissive approval policy, or vice versa. - -### What Claude Code documents - -Claude Code's sandbox model documents: - -- Allow/deny filesystem controls scoped to explicit paths -- Domain-based and proxy-mediated network policy -- Excluded commands (command-level deny list) -- Intentional unsandboxed escape hatch -- `sandbox.failIfUnavailable` to hard-fail when the sandbox cannot activate - -### What phase 2 targets - -Phase 2 SHOULD close these gaps: - -| Capability | Codex | Claude | Phase 2 target | -| ---------------------------------------------------------- | --------------------------------- | ------------------------------- | ----------------- | -| PTY / interactive shell sandboxing | Supported (manual verification) | Supported (manual verification) | Yes | -| Sandbox-first with explicit host retry | Implicit via `danger-full-access` | Explicit escape hatch | Yes | -| Minimal sandbox modes | Three modes | N/A | Two modes minimum | -| Richer filesystem policy (excluded paths, credential deny) | Writable roots | Allow/deny lists | Yes | -| Excluded commands | Not documented | Documented | Yes | -| Fail-if-unavailable | Implicit (always sandboxed) | `sandbox.failIfUnavailable` | Yes | -| Domain/proxy network mediation | Not documented | Documented | No (later phase) | -| MCP sandboxing | Not documented | Not documented | No (later phase) | -| LSP sandboxing | N/A | N/A | No (later phase) | - -PTY interactive shell support by Codex and Claude Code was confirmed through manual verification, -not through public documentation stronger than what those docs support. -Phase 2 treats this as established practice, not a guaranteed stable API contract. - ---- - -## Non-goals for phase 2 - -Phase 2 MUST NOT: - -1. Sandbox MCP server launches (`mcp/index.ts`). - MCP processes run operator-configured commands with their own trust model. - Sandboxing them requires separate design work around MCP-specific policy. - -2. Sandbox LSP server launches (`lsp/launch.ts`, `lsp/server.ts`). - LSP servers are internal infrastructure, not agent-visible command surfaces. - -3. Wire `util/process.ts` or `effect/cross-spawn-spawner.ts` through the sandbox. - These are shared spawn utilities used by internal tooling, formatters, and infrastructure. - Broadly wiring them would silently sandbox non-agent processes. - -4. Implement domain-based or proxy-mediated network controls. - Claude Code's localhost proxy model is a substantial piece of infrastructure. - Phase 2 SHOULD keep the current posture: network denied by default, optionally allowed via policy. - -5. Support Linux or Windows sandboxing. - macOS Seatbelt only. - -6. Move sandbox config out of `experimental`. - Phase 2 expands capabilities but the feature remains opt-in and experimental. - ---- - -## Concrete files and modules - -### Files to change - -#### `packages/opencode/src/pty/index.ts` - -Current state: - -- `Pty.create()` calls `bun-pty` `spawn()` directly at line 201 -- The spawn call passes `command`, `args`, `cwd`, and `env` to the native PTY -- No sandbox integration exists -- This is a major uncovered surface: interactive shells have full user privileges - -Planned change: - -- Before spawning, resolve sandbox plan via `SandboxSpawn.resolve()` -- When sandbox is active, wrap the PTY spawn command through `sandbox-exec` -- Pass the wrapped command/args to `bun-pty` `spawn()` instead of the raw shell -- The PTY process itself runs inside the sandbox; I/O streaming is unaffected because `sandbox-exec` preserves stdin/stdout/stderr -- Sandbox mode for PTY SHOULD use the same filesystem policy as `bash.ts` -- Shell rc-file sourcing in PTY sandbox mode SHOULD be addressed the same way as `session/prompt.ts`: prefer deterministic startup, do not grant broad `$HOME` reads - -Sharp edges: - -- `bun-pty` spawns a native PTY via `forkpty()`. - The sandbox wraps the _command_ that the PTY executes, not the PTY allocation itself. - This means the call becomes `bun-pty.spawn("sandbox-exec", ["-p", profile, shell, ...args], ...)`. - This MUST be validated: the PTY allocator needs to handle `sandbox-exec` as the executable. -- Interactive programs (vim, htop) inside the sandbox will be subject to filesystem policy. - This is expected behavior, not a bug. -- Terminal resize, `onData`, and `onExit` callbacks SHOULD be unaffected because they operate on the PTY fd, not the child process identity. - -#### `packages/opencode/src/sandbox/spawn.ts` - -Current state: - -- `resolve()` reads config, detects platform, builds plan -- `plan()` validates roots, builds policy, returns active/inactive state -- `wrap()` produces `{ file: "sandbox-exec", args: ["-p", profile, ...] }` - -Planned changes: - -- Add `mode` support: accept a sandbox mode parameter and translate it to policy constraints -- Add `excluded_commands` checking: before wrapping, check the command against a deny list and throw a structured error if matched -- Add `fail_if_unavailable` behavior: when config says sandbox MUST be active but `sandbox-exec` is missing, throw a hard error instead of falling back to unsandboxed execution -- Add `unsandboxed_retry` support: export a helper that re-runs a command without the sandbox wrapper, gated behind explicit permission - -#### `packages/opencode/src/sandbox/policy.ts` - -Current state: - -- `SandboxPolicy.build()` generates SBPL from structured input -- Supports read roots, write roots, deny roots, network, Unix sockets - -Planned changes: - -- Accept a `mode` input that maps to predefined policy profiles -- Add `excluded_commands` to the input; these are not SBPL rules but pre-spawn checks -- Optionally accept `extra_deny_paths` for operator-configured deny rules beyond the default credential set - -#### `packages/opencode/src/config/config.ts` - -Current state (lines 1020-1036): - -```ts -sandbox: z.object({ - enabled: z.boolean().optional(), - extra_read_roots: z.array(z.string()).optional(), - extra_write_roots: z.array(z.string()).optional(), - allow_unsandboxed_retry: z.boolean().optional(), -}).optional() -``` - -Planned changes — evolve the schema to: - -```ts -sandbox: z.object({ - enabled: z.boolean().optional(), - mode: z.enum(["workspace-write", "read-only"]).optional(), - extra_read_roots: z.array(z.string()).optional(), - extra_write_roots: z.array(z.string()).optional(), - extra_deny_paths: z.array(z.string()).optional(), - excluded_commands: z.array(z.string()).optional(), - allow_unsandboxed_retry: z.boolean().optional(), - fail_if_unavailable: z.boolean().optional(), -}).optional() -``` - -Field semantics: - -- `mode`: defaults to `"workspace-write"`. - `"workspace-write"` allows writes to project root and configured extra write roots. - `"read-only"` denies all writes except `/tmp` and explicitly configured paths. -- `extra_deny_paths`: operator-specified paths to deny beyond the default credential set. -- `excluded_commands`: command prefixes that MUST NOT execute even inside the sandbox. - If the agent attempts an excluded command, execution MUST fail with a structured error before spawning. -- `fail_if_unavailable`: when `true`, sandbox activation failure (missing `sandbox-exec`, unsupported platform) is a hard error. - Default `false` for backward compatibility. - -#### `packages/opencode/src/tool/bash.ts` - -Current state: - -- Already wired through `SandboxSpawn.resolve()` and `SandboxSpawn.wrap()` -- No excluded-command checking -- No unsandboxed retry - -Planned changes: - -- Before sandbox resolution, check the parsed command against `excluded_commands` and fail early if matched -- After a sandbox denial, if `allow_unsandboxed_retry` is `true`, present a distinct permission prompt for unsandboxed re-execution -- Pass the selected `mode` to `SandboxSpawn.resolve()` - -#### `packages/opencode/src/session/prompt.ts` - -Current state: - -- `SessionPrompt.shell()` (line 1542) already wired through `SandboxSpawn.resolve()` -- No excluded-command checking -- No unsandboxed retry - -Planned changes: - -- Mirror the same excluded-command and unsandboxed-retry changes as `bash.ts` -- Pass the selected `mode` to `SandboxSpawn.resolve()` - -#### `packages/opencode/src/permission/index.ts` - -Current state: - -- `Permission.ask()` evaluates rulesets and prompts the user -- No awareness of sandbox retry - -Planned change: - -- No structural change required. - The unsandboxed retry prompt SHOULD use the existing `Permission.ask()` with a distinct permission key (e.g., `"bash:unsandboxed"`) so that operators can pre-allow or pre-deny it via config. - -### Files explicitly NOT changed in phase 2 - -| File | Reason | -| ----------------------------------------------------- | ------------------------------------------ | -| `packages/opencode/src/util/process.ts` | Internal spawn utility, not agent-visible | -| `packages/opencode/src/effect/cross-spawn-spawner.ts` | Effect-layer spawn, used by infrastructure | -| `packages/opencode/src/mcp/index.ts` | MCP server launch, separate trust model | -| `packages/opencode/src/lsp/launch.ts` | LSP launch, internal tooling | -| `packages/opencode/src/lsp/server.ts` | LSP server management, internal tooling | - ---- - -## Sequencing - -### Phase 2a — PTY sandbox integration - -Goal: sandbox interactive shell sessions on macOS when enabled. - -Steps: - -1. Validate that `bun-pty` `spawn()` correctly executes `sandbox-exec -p ` as the PTY command. - This is the critical proof step. - If `bun-pty` cannot run a wrapped command, the approach needs revision. -2. Wire `SandboxSpawn.resolve()` into `Pty.create()` before the `spawn()` call. -3. When sandbox is active, replace the raw `command` + `args` with the wrapped version. -4. Validate that terminal I/O (`onData`, `onExit`, resize) still works through the wrapper. -5. Validate that interactive programs (shell builtins, vim, git interactive rebase) work inside the sandbox. - -Exit criteria: - -- PTY sessions respect sandbox policy when enabled -- Terminal I/O is not broken -- Interactive programs that stay within allowed paths work -- Programs that attempt denied operations fail with Seatbelt denials visible in stderr - -QA: - -- From `packages/opencode`, run `bun run typecheck` -- Add test coverage in `packages/opencode/test/pty/` or extend existing PTY tests -- Manual verification: create a PTY session with sandbox enabled, run `ls`, `cat`, `echo`, confirm output streams correctly -- Manual verification: attempt `cat ~/.ssh/id_rsa` inside sandboxed PTY, confirm denial - -### Phase 2b — sandbox modes - -Goal: implement `workspace-write` and `read-only` modes. - -Steps: - -1. Add `mode` to `SandboxPolicy.Input` -2. In `SandboxPolicy.build()`, when mode is `"read-only"`, move project root and worktree root from write roots to read-only roots. - Only `/tmp`, `/private/tmp`, and explicitly configured extra write roots remain writable. -3. In `SandboxSpawn.resolve()`, read `mode` from config and pass it through. -4. Update `bash.ts`, `session/prompt.ts`, and `pty/index.ts` to forward the mode. - -Exit criteria: - -- `"workspace-write"` behaves identically to current phase-1 behavior -- `"read-only"` denies writes to the project root -- Default is `"workspace-write"` for backward compatibility - -QA: - -- Unit tests in `packages/opencode/test/sandbox/policy.test.ts` for both modes -- Integration test: with `mode: "read-only"`, `touch newfile.txt` in project root MUST fail -- Integration test: with `mode: "workspace-write"`, `touch newfile.txt` in project root MUST succeed - -### Phase 2c — excluded commands - -Goal: block specific commands before they reach the sandbox. - -Steps: - -1. Add `excluded_commands` to config schema. -2. In `SandboxSpawn` (or a new helper), add a check function that takes the parsed command and the exclusion list, - and returns a structured error if the command matches. -3. Wire the check into `bash.ts` (after tree-sitter parse, before spawn) and `session/prompt.ts` (before spawn). -4. Wire the check into `pty/index.ts` for the initial PTY command (not subsequent interactive input — that is out of scope for phase 2). - -Design note: - -- Excluded commands are a pre-spawn deny list, not an SBPL rule. - SBPL cannot reason about command names; it operates on file paths and syscalls. -- Matching SHOULD use the same command prefix logic as `BashArity.prefix()` in `packages/opencode/src/permission/arity.ts`. -- For PTY, the check applies only to the initial spawn command. - Once the interactive shell is running, the user (or agent) can type anything. - This is a known limitation and SHOULD be documented. - -Exit criteria: - -- A command matching the exclusion list fails with a clear error message before spawning -- Non-excluded commands are unaffected - -QA: - -- Add or extend tests at `packages/opencode/test/sandbox/spawn.test.ts` covering the exclusion checker. -- From `packages/opencode`, run: - `bun run typecheck` -- From `packages/opencode`, run: - `bun test --timeout 30000 test/sandbox/spawn.test.ts` -- Expected result for unit tests: - - A command whose prefix matches an entry in `excluded_commands` returns a structured error before any spawn call - - A command that does not match any exclusion proceeds normally -- Add an integration test in `packages/opencode/test/tool/bash.test.ts` (or a dedicated `bash-sandbox.test.ts`): - - Configure `excluded_commands: ["rm"]` - - Invoke the bash tool with `rm -rf /tmp/test` - - Expected result: the tool rejects the command with an error containing the excluded command name; no child process is spawned -- Add an integration test for `session/prompt.ts`: - - Configure `excluded_commands: ["curl"]` - - Call `SessionPrompt.shell()` with `curl https://example.com` - - Expected result: shell execution fails with a structured error before spawning -- Manual verification for PTY: - - Set `excluded_commands: ["python"]` in `opencode.json` under `experimental.sandbox` - - Create a PTY session with `command: "python"` - - Expected result: PTY creation fails with a clear error; no interactive session starts - - Create a PTY session with the default shell (not in the exclusion list) - - Expected result: PTY session starts normally - -### Phase 2d — fail-if-unavailable - -Goal: hard-fail when sandbox is required but cannot activate. - -Steps: - -1. Add `fail_if_unavailable` to config schema. -2. In `SandboxSpawn.resolve()`, when `fail_if_unavailable` is `true` and the plan returns `active: false` with reason `unsupported_platform` or `sandbox_exec_missing`, throw `SandboxSpawn.Error` instead of returning an inactive plan. -3. Currently, `SandboxSpawn.plan()` already throws for `sandbox_exec_missing` when `requested` is `true`. - Extend this to also throw for `unsupported_platform` when `fail_if_unavailable` is `true`. - -Exit criteria: - -- On a non-macOS platform with `fail_if_unavailable: true`, command execution fails with a structured error -- On macOS without `sandbox-exec` (unlikely but testable), same behavior -- With `fail_if_unavailable: false` (default), current fallback behavior is preserved - -QA: - -- Add or extend tests at `packages/opencode/test/sandbox/spawn.test.ts`. -- From `packages/opencode`, run: - `bun run typecheck` -- From `packages/opencode`, run: - `bun test --timeout 30000 test/sandbox/spawn.test.ts` -- Test case 1 — hard failure on unsupported platform: - - Call `SandboxSpawn.plan()` with `requested: true`, `platform: "linux"`, and `fail_if_unavailable: true` - - Expected result: throws `SandboxSpawn.Error` with reason `unsupported_platform` -- Test case 2 — hard failure when `sandbox-exec` is missing: - - Call `SandboxSpawn.plan()` with `requested: true`, `platform: "darwin"`, `available: false`, and `fail_if_unavailable: true` - - Expected result: throws `SandboxSpawn.Error` with reason `sandbox_exec_missing` -- Test case 3 — graceful fallback when `fail_if_unavailable` is `false`: - - Call `SandboxSpawn.plan()` with `requested: true`, `platform: "linux"`, and `fail_if_unavailable: false` - - Expected result: returns `{ active: false }` with reason `unsupported_platform`; no error thrown -- Test case 4 — backward compatibility: - - Call `SandboxSpawn.plan()` with `requested: true`, `platform: "linux"`, and no `fail_if_unavailable` field - - Expected result: same as test case 3; defaults to `false` -- Manual verification: - - On a macOS host, set `fail_if_unavailable: true` and `enabled: true` in `opencode.json` - - Run a bash tool command - - Expected result: command executes inside the sandbox normally (sandbox-exec is present) - - On a Linux host (or by temporarily renaming `/usr/bin/sandbox-exec`), repeat - - Expected result: command fails immediately with a structured error mentioning sandbox unavailability - -### Phase 2e — unsandboxed retry - -Goal: implement the `allow_unsandboxed_retry` runtime path. - -Steps: - -1. Define a new permission key `"bash:unsandboxed"` (or similar) that operators can pre-configure. -2. In `bash.ts`, after a sandbox denial (child exits non-zero with Seatbelt-related stderr), detect the denial. -3. If `allow_unsandboxed_retry` is `true`, prompt the user via `Permission.ask()` with the `"bash:unsandboxed"` key and the original command as the pattern. -4. If approved, re-run the command without the sandbox wrapper. -5. Mirror the same logic in `session/prompt.ts`. -6. For PTY, unsandboxed retry is NOT implemented in phase 2. - Restarting a PTY session outside the sandbox mid-stream is complex and error-prone. - Document this as a known limitation. - -Sharp edges: - -- Detecting a Seatbelt denial from the child's exit code and stderr is heuristic. - `sandbox-exec` does not set a distinct exit code for policy violations. - The implementation SHOULD look for `deny` or `Sandbox:` in stderr as a signal, but MUST NOT treat this as authoritative. -- The retry path MUST re-run the entire command, not attempt to resume partial output. -- The retry permission MUST be distinct from the original bash permission so operators can deny retries globally. - -Exit criteria: - -- When `allow_unsandboxed_retry` is `false` (default), no retry is offered -- When `true` and a sandbox denial occurs, the user is prompted -- If the user approves, the command runs unsandboxed and produces output normally -- If the user rejects, the original sandbox denial stands - -QA: - -- Add or extend tests at `packages/opencode/test/tool/bash.test.ts` (or a dedicated `bash-sandbox.test.ts`). -- From `packages/opencode`, run: - `bun run typecheck` -- From `packages/opencode`, run: - `bun test --timeout 30000 test/tool/bash.test.ts` - (or `bun test --timeout 30000 test/tool/bash-sandbox.test.ts` if a dedicated file is added) -- Test case 1 — retry disabled (default): - - Configure `allow_unsandboxed_retry: false` (or omit the field) - - Run a bash tool command that triggers a sandbox denial (e.g., write outside project root) - - Expected result: command fails with sandbox denial output; no retry prompt is issued; `Permission.ask()` is NOT called with `"bash:unsandboxed"` -- Test case 2 — retry enabled, user approves: - - Configure `allow_unsandboxed_retry: true` - - Run a bash tool command that triggers a sandbox denial - - Stub or pre-allow the `"bash:unsandboxed"` permission - - Expected result: after the initial sandbox denial, the command re-runs without the sandbox wrapper and produces normal output -- Test case 3 — retry enabled, user rejects: - - Configure `allow_unsandboxed_retry: true` - - Run a bash tool command that triggers a sandbox denial - - Stub or pre-deny the `"bash:unsandboxed"` permission - - Expected result: the original sandbox denial stands; no unsandboxed execution occurs -- Add a parallel test in `packages/opencode/test/session/prompt.test.ts` (or `prompt-sandbox.test.ts`): - - Configure `allow_unsandboxed_retry: true` - - Call `SessionPrompt.shell()` with a command that triggers a sandbox denial - - Pre-allow the `"bash:unsandboxed"` permission - - Expected result: command re-runs unsandboxed; output streams correctly -- Manual verification: - - Set `allow_unsandboxed_retry: true` and `enabled: true` in `opencode.json` - - In the TUI, run a bash command that writes to a path outside the project root (e.g., `touch /tmp/outside-project/test.txt` where the parent dir is not in extra write roots) - - Expected result: a permission prompt appears asking to retry without the sandbox - - Accept the prompt - - Expected result: the command runs unsandboxed and succeeds - - Repeat, but reject the prompt - - Expected result: the command remains failed with the original sandbox denial - -### Phase 2f — config evolution and docs - -Goal: ship accurate documentation for phase 2 capabilities. - -Steps: - -1. Update `SECURITY.md` to reflect expanded coverage: bash tool, session command, and PTY sessions. -2. Document the new config fields: `mode`, `excluded_commands`, `fail_if_unavailable`, `extra_deny_paths`. -3. Document exclusions: MCP, LSP, internal spawn layers, non-macOS, domain-mediated network. -4. Document the unsandboxed retry flow and its limitations. -5. Document that PTY excluded-command checking applies only to the initial spawn, not interactive input. - -Exit criteria: - -- Docs match actual coverage -- No overclaiming - -QA: - -- From the repo root, run: - `grep -n "PTY\|interactive\|MCP\|LSP\|non-macOS\|excluded.command\|unsandboxed.retry\|fail.if.unavailable" SECURITY.md` -- Expected result: - - `SECURITY.md` explicitly states macOS-only, opt-in, and experimental - - `SECURITY.md` lists bash tool, session command, and PTY interactive sessions as covered surfaces - - `SECURITY.md` explicitly names MCP, LSP, internal process utilities, domain-mediated network, and non-macOS as exclusions - - `SECURITY.md` documents that PTY excluded-command checking applies only to the initial spawn, not interactive input - - No section implies all local process execution is sandboxed -- Verify config documentation: - - From the repo root, run: - `grep -n "mode\|excluded_commands\|fail_if_unavailable\|extra_deny_paths" SECURITY.md` - - Expected result: each new config field is mentioned with a brief description of its behavior and default value -- Verify unsandboxed retry documentation: - - `SECURITY.md` or a linked doc describes the retry flow: sandbox denial, permission prompt with `"bash:unsandboxed"` key, re-execution without wrapper - - The doc notes that PTY sessions do not support unsandboxed retry in phase 2 -- Manual review: - - Read `SECURITY.md` end to end after edits - - Confirm no sentence claims coverage beyond what the validation matrix proved - - Confirm the phase-2 product statement in this plan matches the claims in `SECURITY.md` - ---- - -## Validation matrix - -### Unit tests - -| Test | Location | -| -------------------------------------------- | ----------------------------- | -| Policy generation for `workspace-write` mode | `test/sandbox/policy.test.ts` | -| Policy generation for `read-only` mode | `test/sandbox/policy.test.ts` | -| Excluded command matching | `test/sandbox/spawn.test.ts` | -| `fail_if_unavailable` throws on non-darwin | `test/sandbox/spawn.test.ts` | -| Unsandboxed retry permission key | `test/sandbox/spawn.test.ts` | -| Config parsing for new fields | `test/config/` | - -### macOS integration tests - -| # | Test | Expected | -| --- | -------------------------------------------------------------------- | ---------------------------------------- | -| 1 | PTY session with sandbox enabled, `ls` in project root | Succeeds, output streams | -| 2 | PTY session with sandbox enabled, `cat ~/.ssh/id_rsa` | Fails with Seatbelt denial | -| 3 | PTY session with sandbox enabled, terminal resize | Resize works | -| 4 | PTY session with sandbox enabled, interactive program (e.g., `less`) | Runs if file is in allowed path | -| 5 | `read-only` mode, `touch newfile.txt` in project root | Fails | -| 6 | `read-only` mode, `cat` a project file | Succeeds | -| 7 | `workspace-write` mode, `touch newfile.txt` in project root | Succeeds | -| 8 | Excluded command in bash tool | Fails before spawn with structured error | -| 9 | Excluded command in PTY initial spawn | Fails before spawn | -| 10 | `fail_if_unavailable: true` on non-darwin | Hard error | -| 11 | `allow_unsandboxed_retry: true`, sandbox denial, user approves | Command re-runs unsandboxed | -| 12 | `allow_unsandboxed_retry: true`, sandbox denial, user rejects | Original denial stands | -| 13 | `allow_unsandboxed_retry: false`, sandbox denial | No retry offered | - -### Manual verification - -Before updating docs: - -1. Build the darwin binary with the Bun compile pipeline. -2. Open a PTY session with sandbox enabled and run common interactive workflows. -3. Verify that `bun-pty` correctly spawns `sandbox-exec` as the PTY command. -4. Verify that sandbox denials are visible in PTY stderr output. -5. Test excluded-command blocking in both bash tool and PTY. - ---- - -## Risks and sharp edges - -### `bun-pty` and `sandbox-exec` interaction - -This is the highest-risk item in phase 2. -`bun-pty` uses `forkpty()` to allocate a pseudo-terminal and exec the command. -The command becomes `sandbox-exec -p /bin/zsh -l`. -If `forkpty()` + `execvp("sandbox-exec", ...)` does not work correctly, -the entire PTY sandboxing approach needs revision. - -Mitigation: validate this in phase 2a before any other work. - -### PTY excluded-command limitation - -Excluded-command checking for PTY applies only to the initial spawn command. -Once an interactive shell is running, the user or agent can type any command. -This is an inherent limitation of pre-spawn deny lists. - -Mitigation: document this clearly. Do not claim that excluded commands are enforced inside interactive sessions. - -### Seatbelt denial detection heuristic - -There is no reliable programmatic way to distinguish a Seatbelt denial from other non-zero exits. -The retry path relies on stderr heuristics. - -Mitigation: make the heuristic conservative. Better to miss a retry opportunity than to offer retry on a non-sandbox failure. - -### `sandbox-exec` deprecation - -Apple has not removed `sandbox-exec` but has deprecated the Seatbelt API. -Both Codex and Claude Code still rely on it. -This risk is inherited from phase 1 and unchanged. - -### Mode default and backward compatibility - -Phase 1 has no `mode` concept. -Phase 2 MUST default to `"workspace-write"` so that existing phase-1 users see no behavior change. - -### Shell rc-file sourcing in PTY sandbox mode - -PTY sessions typically source `~/.zshrc`, `~/.bashrc`, etc. -Sandbox mode cannot safely allow broad `$HOME` reads for this. -Users MAY experience different shell behavior in sandboxed PTY sessions. - -Mitigation: document this. Prefer deterministic shell startup in sandbox mode. - ---- - -## Open questions - -1. Does `bun-pty` `spawn()` correctly execute `sandbox-exec` as the PTY command? - This MUST be validated before committing to the PTY approach. - -2. Should `read-only` mode allow writes to `/tmp` and `/private/tmp`, - or should it deny all writes except explicitly configured paths? - -3. What is the right UX for the unsandboxed retry prompt? - Should it show the original command, the denial reason, or both? - -4. Should excluded-command matching be exact prefix or glob-based? - Prefix matching via `BashArity.prefix()` is simpler and consistent with existing permission arity. - -5. Should `fail_if_unavailable` be separate from `enabled`, - or should there be a three-state `enabled` field (`true`, `false`, `"required"`)? - -6. What default `excluded_commands` list (if any) should ship with phase 2? - Candidates: `rm -rf /`, `chmod 777`, `curl | sh`, `eval`. - ---- - -## Phase-2 product statement - -If phase 2 ships successfully, -the correct security statement is: - -> On macOS, -> opencode can optionally sandbox agent-issued shell commands from the bash tool, session command path, and PTY interactive sessions. -> The sandbox supports workspace-write and read-only modes, -> excluded-command blocking (pre-spawn only), -> and an explicit unsandboxed retry path gated behind a distinct permission prompt. -> MCP servers, -> LSP servers, -> internal process utilities, -> domain-mediated network controls, -> and non-macOS platforms are not covered by this phase. - -Anything broader than that is inaccurate. - ---- - -## Later phases - -Phase 2 intentionally defers the following. -Each item has its own design considerations. - -| Item | Why deferred | Likely phase | -| ------------------------------------- | ---------------------------------------------------- | ---------------- | -| MCP server sandboxing | Separate trust model; operator-configured commands | Phase 3 | -| LSP server sandboxing | Internal infrastructure, not agent-visible | Phase 3 or later | -| `util/process.ts` wiring | Shared by non-agent code paths | Phase 3 or later | -| `cross-spawn-spawner.ts` wiring | Effect-layer spawn, infrastructure | Phase 3 or later | -| Domain/proxy network mediation | Substantial infrastructure (localhost proxy) | Phase 4+ | -| Linux sandboxing | Different mechanism (e.g., bubblewrap, namespaces) | Phase 4+ | -| Windows sandboxing | Different mechanism entirely | Phase 5+ | -| Interactive-session excluded commands | Requires in-band command interception, not pre-spawn | Research | - ---- - -## Rollout plan - -1. Ship phase 2a (PTY integration) behind the existing `experimental.sandbox.enabled` flag. - No new flag required. -2. Ship phase 2b (modes) with `mode` defaulting to `"workspace-write"`. - No behavior change for existing users. -3. Ship phase 2c (excluded commands) with an empty default list. - Operators opt in by configuring `excluded_commands`. -4. Ship phase 2d (fail-if-unavailable) with `fail_if_unavailable` defaulting to `false`. -5. Ship phase 2e (unsandboxed retry) with `allow_unsandboxed_retry` defaulting to `false` (unchanged from phase 1). -6. Ship phase 2f (docs) only after manual verification of the full validation matrix. - -Each sub-phase MAY ship independently. -They have no hard ordering dependencies except that 2a SHOULD land first because it validates the PTY approach. - ---- - -## Pickup checklist for the next agent - -1. Read this file fully before touching code. -2. Read the phase-1 handoff plan at `.sisyphus/plans/macos-command-sandboxing-handoff.md` for context. -3. Re-read these repo files before planning edits: - - `packages/opencode/src/pty/index.ts` (PTY spawn at line 201) - - `packages/opencode/src/sandbox/policy.ts` (SBPL builder) - - `packages/opencode/src/sandbox/spawn.ts` (wrapper, plan, resolve) - - `packages/opencode/src/tool/bash.ts` (current sandbox wiring) - - `packages/opencode/src/session/prompt.ts` (`shell()` at line 1542, current sandbox wiring at line 1729) - - `packages/opencode/src/config/config.ts` (sandbox config at line 1020) - - `packages/opencode/src/permission/index.ts` (permission ask/reply) - - `packages/opencode/src/permission/evaluate.ts` (rule evaluation) - - `packages/opencode/src/permission/arity.ts` (command prefix matching) -4. Start with phase 2a proof work: validate `bun-pty` + `sandbox-exec`. - Do NOT proceed with other sub-phases until this is confirmed. -5. Do NOT wire `util/process.ts`, `cross-spawn-spawner.ts`, `mcp/index.ts`, or `lsp/*`. -6. Do NOT claim domain-mediated network controls. -7. Do NOT allow broad `$HOME` reads for PTY shell rc-file sourcing. -8. Do NOT update `SECURITY.md` until the validation matrix passes. -9. Default all new config fields to backward-compatible values. -10. Use the existing `Permission.ask()` flow for unsandboxed retry; do not create a parallel permission system. diff --git a/.sisyphus/plans/macos-command-sandboxing-phase-3-presets-and-runtime.md b/.sisyphus/plans/macos-command-sandboxing-phase-3-presets-and-runtime.md deleted file mode 100644 index fdd9e82e26a6..000000000000 --- a/.sisyphus/plans/macos-command-sandboxing-phase-3-presets-and-runtime.md +++ /dev/null @@ -1,953 +0,0 @@ -# macOS Command Sandboxing Phase 3: Presets and Runtime - -## Status - -This is a handoff plan for phase 3 of macOS command sandboxing. -No implementation work is included. -Another engineer MUST be able to implement phase 3 from this plan without redoing discovery. - -## Entry gate - -Phase 3 builds on phase 2 as its baseline. -Phase 2 MUST land before phase 3 work begins. - -Phase 2 delivers: - -- PTY interactive shell sandboxing via `bun-pty` + `sandbox-exec` -- `workspace-write` and `read-only` sandbox modes -- Excluded-command pre-spawn blocking -- `fail_if_unavailable` hard-fail behavior -- `allow_unsandboxed_retry` runtime path with distinct permission key -- Coverage of `bash.ts`, `session/prompt.ts`, and `pty/index.ts` - -Phase 3 MUST NOT overclaim coverage of any spawn surface that phase 2 did not ship. -If phase 2 has not landed when this plan is picked up, -the implementer MUST either wait or explicitly scope down to phase-1 coverage only (bash tool and session command, no PTY). - ---- - -## Objective - -Add Codex-style approval and sandbox presets as a first-class configuration surface, -introduce a small shared spawn-policy helper for sandbox resolution, -extend sandbox coverage to LSP runtime launches, -and harden workspace-root protection with Codex-inspired protected-path behavior. - -Phase 3 is a **preset-and-opt-in-runtime** phase. -It is NOT a blanket shared-spawn-centralization phase. - ---- - -## Non-goals - -Phase 3 MUST NOT: - -1. **Sandbox MCP local server launches** (`mcp/index.ts`). - MCP processes run operator-configured commands with their own trust model. - `connectLocal()` in `mcp/index.ts` (line 380) passes the command directly to `StdioClientTransport`. - Sandboxing that path requires MCP-specific policy design — a separate workstream. - -2. **Wire `util/process.ts` or `cross-spawn-spawner.ts` through the sandbox automatically.** - These are shared spawn utilities used by LSP servers, formatters, `BunProc`, and internal tooling. - Broadly wiring them would silently sandbox non-agent processes. - Phase 3 wraps LSP launch specifically, not the general-purpose spawn layers. - -3. **Implement domain-based or proxy-mediated network controls.** - Claude Code's localhost-proxy model is substantial infrastructure. - Phase 3 keeps the current posture: network denied by default, optionally allowed via policy or per-preset override. - -4. **Support Linux or Windows sandboxing.** - macOS Seatbelt only. - -5. **Claim full Codex parity.** - Phase 3 closes the gap on presets and protected roots. - It does not replicate Codex's `danger-full-access` mode, automatic worktree discovery, or full approval-policy matrix. - -6. **Move sandbox config out of `experimental`.** - The preset surface is new and needs real-world feedback before promotion. - ---- - -## Parity targets - -### What Codex documents - -| Concept | Codex behavior | -| ---------------------------------------- | ------------------------------------------------------------------------------------------- | -| `sandbox_mode` | `read-only`, `workspace-write`, `danger-full-access` | -| `approval_policy` | Separate layer from sandbox; controls when the agent must ask | -| `profiles.` | Named preset profiles composing sandbox + approval | -| `sandbox_workspace_write.writable_roots` | Explicit writable path set | -| `sandbox_workspace_write.network_access` | Per-mode network toggle | -| Protected roots inside writable roots | `.git`, resolved gitdir, `.agents`, `.codex` are protected even when the parent is writable | - -### What phase 3 targets - -| Capability | Codex | Phase 3 target | -| ----------------------------------------------- | --------------------------- | ------------------------------------------------ | -| Named presets composing sandbox + permission | `profiles.` | Yes — `experimental.sandbox.preset` | -| Protected roots inside writable workspace | `.git`, `.agents`, `.codex` | Yes — `.git`, resolved gitdir, `.opencode` | -| Per-preset network toggle | `network_access` per mode | Yes | -| Approval policy as independent layer | `approval_policy` | Partial — maps to existing `permission` rulesets | -| `danger-full-access` mode | Supported | No — too risky for opt-in experimental | -| Automatic worktree discovery for writable roots | Automatic | No — explicit only | -| LSP runtime sandboxing | N/A | Yes — `lsp/launch.ts` | -| Shared spawn-policy helper | N/A | Yes — `SandboxRuntime` / `SpawnPlan` | - ---- - -## Concrete files and modules - -### New modules - -#### `packages/opencode/src/sandbox/preset.ts` - -Responsible for resolving a named preset into concrete sandbox and permission configuration. - -A preset is a named bundle of: - -- sandbox mode (`workspace-write`, `read-only`) -- network access toggle -- protected roots list -- permission ruleset overlay -- optional extra read/write roots - -Suggested shape: - -```ts -export namespace SandboxPreset { - export interface Def { - mode: "workspace-write" | "read-only" - network: boolean - protected_roots: string[] - permission: Permission.Ruleset - extra_read_roots?: string[] - extra_write_roots?: string[] - } - - export function resolve(name: string, overrides?: Partial): Def - export function builtins(): Record -} -``` - -Built-in presets: - -| Name | Mode | Network | Protected roots | Permission overlay | -| --------- | ----------------- | ------- | ------------------------------------ | ------------------------ | -| `default` | `workspace-write` | `false` | `.git`, resolved gitdir, `.opencode` | none | -| `strict` | `read-only` | `false` | `.git`, resolved gitdir, `.opencode` | `bash: ask`, `edit: ask` | -| `network` | `workspace-write` | `true` | `.git`, resolved gitdir, `.opencode` | none | - -Operators MAY define custom presets under `experimental.sandbox.presets` in config. -Built-in presets MUST NOT be overridable by user config — they serve as known-good baselines. -Custom presets extend the set; they do not replace built-ins. - -#### `packages/opencode/src/sandbox/runtime.ts` - -A small shared spawn-policy helper that consolidates sandbox resolution logic. -This replaces the pattern of each call site independently calling `SandboxSpawn.resolve()` + `SandboxSpawn.wrap()`. - -Suggested shape: - -```ts -export namespace SandboxRuntime { - export interface SpawnPlan { - active: boolean - file: string - args: string[] - env?: Record - diag: SandboxSpawn.Diag - } - - export function plan(input: { - file: string - args: string[] - cwd: string - project_root: string - worktree_root: string - preset?: string - }): SpawnPlan -} -``` - -This helper: - -- Reads the active preset (from config or explicit parameter) -- Resolves protected roots and merges them into deny rules -- Calls `SandboxSpawn.plan()` and `SandboxSpawn.wrap()` internally -- Returns a ready-to-use `SpawnPlan` with final `file` + `args` -- Remains a pure data transformer — no side effects, no spawning - -Call sites (`bash.ts`, `session/prompt.ts`, `pty/index.ts`, `lsp/launch.ts`) switch from direct `SandboxSpawn` calls to `SandboxRuntime.plan()`. -This is a refactor of existing wiring, not new coverage. - -### Files to change - -#### `packages/opencode/src/sandbox/policy.ts` - -Current state: - -- `SandboxPolicy.build()` generates SBPL from `Input` with read roots, write roots, deny roots, network, Unix sockets -- `secret` array holds credential-path deny list -- No awareness of protected workspace roots - -Planned changes: - -- Accept `protected_roots` in `Input`. - These are paths inside writable roots that MUST be denied for writes. - They are added to the deny list after write-root expansion. -- The SBPL generation MUST emit deny rules for protected roots _after_ the write-allow rules, - so that Seatbelt's last-match-wins evaluation denies writes to protected paths even inside writable directories. -- Accept `mode` in `Input` to support `read-only` moving project/worktree to read roots (phase 2 may already have this; if so, no change needed). - -#### `packages/opencode/src/sandbox/spawn.ts` - -Current state (after phase 2): - -- `resolve()` reads config, detects platform, builds plan -- `plan()` validates roots, builds policy, returns active/inactive -- `wrap()` produces `{ file: "sandbox-exec", args: ["-p", profile, ...] }` -- Mode, excluded commands, fail-if-unavailable, and unsandboxed retry are wired - -Planned changes: - -- Accept an optional `preset` name in `ResolveInput`. - When provided, resolve the preset via `SandboxPreset.resolve()` and merge its settings before building the plan. -- Preset settings MUST be overridable by explicit `experimental.sandbox.*` fields. - Priority order (highest wins): explicit config fields > preset defaults > hardcoded defaults. -- When a preset specifies `network: true`, set `allow_network: true` in the policy input. -- When a preset specifies `protected_roots`, pass them through to `SandboxPolicy.build()`. - -#### `packages/opencode/src/config/config.ts` - -Current sandbox schema (after phase 2): - -```ts -sandbox: z.object({ - enabled: z.boolean().optional(), - mode: z.enum(["workspace-write", "read-only"]).optional(), - extra_read_roots: z.array(z.string()).optional(), - extra_write_roots: z.array(z.string()).optional(), - extra_deny_paths: z.array(z.string()).optional(), - excluded_commands: z.array(z.string()).optional(), - allow_unsandboxed_retry: z.boolean().optional(), - fail_if_unavailable: z.boolean().optional(), -}).optional() -``` - -Planned evolution: - -```ts -sandbox: z.object({ - enabled: z.boolean().optional(), - preset: z.string().optional(), - 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(), - extra_deny_paths: z.array(z.string()).optional(), - excluded_commands: z.array(z.string()).optional(), - allow_unsandboxed_retry: z.boolean().optional(), - fail_if_unavailable: z.boolean().optional(), - 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: Permission.optional(), - }), - ) - .optional(), -}).optional() -``` - -Field semantics: - -- `preset`: name of the active preset. - When set, the preset's defaults apply. - Explicit sibling fields (`mode`, `network`, etc.) override the preset's defaults. -- `network`: controls whether the sandbox allows outbound network. - Default `false`. - Overrides the preset's `network` setting. -- `protected_roots`: workspace-relative paths that MUST be write-denied even inside writable roots. - Defaults to `[".git", ".opencode"]` when any preset is active. - Operators MAY add custom protected roots (e.g., `.env`, `secrets/`). -- `presets`: operator-defined named presets. - These extend the built-in set. - A custom preset with the same name as a built-in MUST be rejected at config parse time with a clear error. - -#### `packages/opencode/src/lsp/launch.ts` - -Current state: - -- `spawn()` delegates to `Process.spawn()` from `util/process.ts` -- Passes `stdin: "pipe"`, `stdout: "pipe"`, `stderr: "pipe"` -- No sandbox awareness - -Planned change: - -- Before spawning, call `SandboxRuntime.plan()` to resolve a spawn plan. -- When sandbox is active, replace the `cmd` + `args` with the wrapped version from the plan. -- Pass the wrapped command to `Process.spawn()`. -- LSP servers need read access to the project root and standard system paths. - They SHOULD NOT need write access outside `/tmp` and explicitly configured write roots. -- The sandbox mode for LSP launches SHOULD default to `read-only` regardless of the active preset's mode. - LSP servers are infrastructure; they SHOULD NOT write to the workspace. - -Sharp edges: - -- LSP servers often need to read the entire project tree plus globally installed toolchains. - The read-root set MUST include Homebrew paths (`/opt/homebrew`, `/usr/local`) and any Nix store paths configured in `extra_read_roots`. -- Some LSP servers write to cache directories under `$HOME` (e.g., `~/.cache/typescript`). - Phase 3 SHOULD NOT grant broad `$HOME` writes. - If a specific LSP fails, the operator SHOULD add the cache path to `extra_write_roots`. -- `lsp/server.ts` manages LSP server lifecycle and calls `spawn()` from `lsp/launch.ts`. - The sandbox integration is in `launch.ts`; `server.ts` does not need changes. - -#### `packages/opencode/src/lsp/server.ts` - -No changes planned. -`server.ts` calls `spawn()` from `launch.ts`. -The sandbox wrapping happens inside `launch.ts`. - -#### `packages/opencode/src/file/protected.ts` - -Current state: - -- `Protected.names()` returns macOS TCC-protected directory basenames -- `Protected.paths()` returns absolute paths that should never be watched or scanned -- Used for file watcher exclusion, not sandbox policy - -Planned change: - -- Add a `Protected.workspace()` export that returns the default protected roots for sandbox policy: - `[".git", ".opencode"]`. -- Add a `Protected.resolve(project_root: string)` that resolves `.git` to the actual gitdir - (handles worktrees where `.git` is a file pointing elsewhere) - and returns absolute paths. -- `SandboxPreset` and `SandboxRuntime` call `Protected.resolve()` to build the deny list for protected workspace roots. - -#### `packages/opencode/src/tool/bash.ts` - -Current state (after phase 2): - -- Wired through `SandboxSpawn.resolve()` and `SandboxSpawn.wrap()` -- Has excluded-command checking and unsandboxed retry - -Planned change: - -- Switch from direct `SandboxSpawn` calls to `SandboxRuntime.plan()`. -- The preset name flows from config through the runtime helper. -- No behavioral change beyond the refactor. - -#### `packages/opencode/src/session/prompt.ts` - -Same refactor as `bash.ts`: switch to `SandboxRuntime.plan()`. - -#### `packages/opencode/src/pty/index.ts` - -Same refactor as `bash.ts`: switch to `SandboxRuntime.plan()`. - -#### `packages/opencode/src/agent/agent.ts` - -Current state: - -- Builds per-agent permission rulesets via `Permission.merge()` of defaults, agent-specific config, and user config -- No awareness of sandbox presets - -Planned change: - -- When a sandbox preset specifies a `permission` overlay, - merge that overlay into the agent's ruleset at the correct priority. -- Priority order (lowest to highest): - 1. Hardcoded defaults - 2. Preset permission overlay - 3. Agent-specific config (`cfg.agent..permission`) - 4. Top-level user config (`cfg.permission`) -- This preserves the existing merge behavior. - The preset overlay sits between defaults and explicit user config. - -#### `packages/opencode/src/permission/index.ts` - -No structural change required. -`Permission.merge()` and `Permission.fromConfig()` already support arbitrary rulesets. -The preset's permission overlay is just another ruleset passed to `merge()`. - -#### `packages/opencode/src/permission/evaluate.ts` - -No change. -Last-match-wins evaluation is already correct for the preset overlay merge order. - -### Files explicitly NOT changed in phase 3 - -| File | Reason | -| ----------------------------------------------------- | ------------------------------------------ | -| `packages/opencode/src/util/process.ts` | Shared spawn utility, not agent-visible | -| `packages/opencode/src/effect/cross-spawn-spawner.ts` | Effect-layer spawn, used by infrastructure | -| `packages/opencode/src/mcp/index.ts` | MCP server launch, separate trust model | - ---- - -## Preset model - -### Resolution order - -When sandbox is enabled: - -1. Read `experimental.sandbox.preset` from config (e.g., `"default"`, `"strict"`, `"network"`, or a custom name). -2. Look up the preset definition: first in built-ins, then in `experimental.sandbox.presets`. -3. Merge the preset's defaults with explicit config overrides. - Explicit fields always win. -4. The merged result feeds into `SandboxRuntime.plan()`. - -If no preset is specified but sandbox is enabled, -the `"default"` preset applies implicitly. - -### Relationship to existing config - -The `preset` field is sugar. -Every field a preset sets can be set directly. -Operators who prefer granular control MAY ignore presets entirely and set `mode`, `network`, `protected_roots`, etc. directly. - -The raw `permission` and `experimental.sandbox.*` fields remain as explicit overrides. -Presets do not replace them; they provide convenient defaults. - -### Example configs - -Minimal opt-in with default preset: - -```json -{ - "experimental": { - "sandbox": { - "enabled": true - } - } -} -``` - -Strict preset with custom protected root: - -```json -{ - "experimental": { - "sandbox": { - "enabled": true, - "preset": "strict", - "protected_roots": [".git", ".opencode", ".env"] - } - } -} -``` - -Custom preset definition: - -```json -{ - "experimental": { - "sandbox": { - "enabled": true, - "preset": "ci", - "presets": { - "ci": { - "mode": "workspace-write", - "network": true, - "protected_roots": [".git", ".opencode"], - "permission": { - "bash": "allow", - "edit": "allow" - } - } - } - } - } -} -``` - -Preset with explicit override: - -```json -{ - "experimental": { - "sandbox": { - "enabled": true, - "preset": "default", - "network": true - } - } -} -``` - -Here `"default"` preset has `network: false`, -but the explicit `"network": true` overrides it. - ---- - -## Protected-root model - -### Codex behavior - -Codex protects certain paths inside writable roots: - -- `.git` (and resolved gitdir for worktrees) -- `.agents` -- `.codex` - -These paths are write-denied even when the parent directory is writable. -This prevents the agent from modifying version control state or its own configuration. - -### Phase 3 behavior - -Default protected roots when sandbox is active: `.git` and `.opencode`. - -Resolution: - -1. `Protected.resolve(project_root)` resolves `.git` to the actual gitdir. - For standard repos, this is `/.git`. - For worktrees, `.git` is a file whose content points to the real gitdir (e.g., `../../.git/worktrees/foo`). - Both paths MUST be protected. -2. `.opencode` resolves to `/.opencode`. -3. Operator-configured `protected_roots` are resolved relative to the project root. - -SBPL implementation: - -The deny rules for protected roots MUST appear after the write-allow rules in the profile. -Seatbelt evaluates rules in order; later rules take precedence. -This ensures that `(deny file-write* (subpath ""))` overrides `(allow file-write* (subpath ""))`. - -### Workspace boundary - -Phase 3 MUST NOT treat all discovered worktrees as automatically writable. -Only the explicitly configured project root and worktree root are writable. -Additional writable paths require explicit `extra_write_roots` configuration. - ---- - -## LSP runtime coverage - -### Scope - -Phase 3 extends sandbox coverage to LSP server process launches via `lsp/launch.ts`. -This is the only new spawn surface covered in phase 3. - -### Why LSP - -LSP servers are long-running processes spawned by opencode to provide language intelligence. -They read the entire project tree and execute with full user privileges. -A compromised or misconfigured LSP server can read credentials, exfiltrate code, or modify files. - -Sandboxing LSP servers with a read-only policy and restricted network -reduces the blast radius of language server vulnerabilities. - -### Why not MCP - -MCP local servers (`mcp/index.ts` line 380) use `StdioClientTransport` which manages its own process lifecycle. -The transport creates the child process internally; opencode does not call `spawn()` directly. -Sandboxing MCP requires either patching the SDK transport or wrapping the command before passing it to the transport. -This is a separate design problem and SHOULD be a phase 4 workstream. - -### LSP sandbox policy - -- Mode: `read-only` (regardless of the active preset's mode) -- Read roots: project root, worktree root, standard system paths, Homebrew paths, configured `extra_read_roots` -- Write roots: `/tmp`, `/private/tmp` only (LSP servers SHOULD NOT need to write to the workspace) -- Network: denied by default (LSP servers SHOULD NOT need network access) -- Protected roots: not applicable (project root is read-only for LSP) - -If a specific LSP server needs write access (e.g., to a cache directory), -the operator SHOULD add the path to `extra_write_roots`. - ---- - -## Validation matrix - -### Unit tests - -| Test | Location | -| --------------------------------------------------------------------------------- | ------------------------------ | -| Preset resolution for built-in names | `test/sandbox/preset.test.ts` | -| Custom preset definition and lookup | `test/sandbox/preset.test.ts` | -| Preset override by explicit config fields | `test/sandbox/preset.test.ts` | -| Built-in preset name collision rejection | `test/sandbox/preset.test.ts` | -| Protected-root resolution for standard repo | `test/sandbox/policy.test.ts` | -| Protected-root resolution for worktree | `test/sandbox/policy.test.ts` | -| SBPL deny-after-allow ordering for protected roots | `test/sandbox/policy.test.ts` | -| `SandboxRuntime.plan()` returns correct `SpawnPlan` | `test/sandbox/runtime.test.ts` | -| `SandboxRuntime.plan()` applies preset defaults | `test/sandbox/runtime.test.ts` | -| `SandboxRuntime.plan()` respects explicit overrides | `test/sandbox/runtime.test.ts` | -| Config parsing for new fields (`preset`, `network`, `protected_roots`, `presets`) | `test/config/` | -| Preset permission overlay merge order in `agent.ts` | `test/agent/` | - -### macOS integration tests - -| # | Test | Expected | -| --- | ---------------------------------------------------------------------- | ------------------------------ | -| 1 | Default preset active, write to project file | Succeeds | -| 2 | Default preset active, write to `.git/config` | Fails with Seatbelt denial | -| 3 | Default preset active, write to `.opencode/state.json` | Fails with Seatbelt denial | -| 4 | Strict preset active, write to project file | Fails (read-only mode) | -| 5 | Strict preset active, read project file | Succeeds | -| 6 | Network preset active, `curl https://example.com` | Succeeds | -| 7 | Default preset active, `curl https://example.com` | Fails (network denied) | -| 8 | Custom preset with `network: true`, explicit `network: false` override | Network denied (explicit wins) | -| 9 | LSP server launch with sandbox enabled, reads project file | Succeeds | -| 10 | LSP server launch with sandbox enabled, writes to project root | Fails | -| 11 | LSP server launch with sandbox enabled, writes to `/tmp` | Succeeds | -| 12 | LSP server launch with sandbox enabled, outbound HTTP | Fails | -| 13 | `SandboxRuntime.plan()` used from `bash.ts` | Same behavior as pre-refactor | -| 14 | `SandboxRuntime.plan()` used from `pty/index.ts` | Same behavior as pre-refactor | -| 15 | Worktree `.git` file resolution, write to resolved gitdir | Fails | - -### Manual verification - -Before updating docs: - -1. Build the darwin binary with the Bun compile pipeline. -2. Enable sandbox with `"preset": "default"` and run common bash tool commands. -3. Verify `.git/config` write is denied while project-file write succeeds. -4. Start an LSP server (e.g., TypeScript) with sandbox enabled and verify diagnostics work. -5. Verify LSP server cannot write to the project root. -6. Test with a git worktree to confirm `.git` file resolution protects the real gitdir. - ---- - -## Sequencing - -### Phase 3a — preset infrastructure - -Goal: land `sandbox/preset.ts` with built-in presets and config parsing. - -Steps: - -1. Add `preset`, `network`, `protected_roots`, and `presets` to the config schema. -2. Implement `SandboxPreset.resolve()` and `SandboxPreset.builtins()`. -3. Add config-time validation rejecting custom presets that collide with built-in names. -4. Add unit tests for preset resolution, override semantics, and collision rejection. - -Exit criteria: - -- Config parses correctly with new fields -- Preset resolution returns expected defaults -- Explicit fields override preset defaults -- Feature remains behavioral no-op until wired into spawn path - -QA: - -- From `packages/opencode`, run `bun run typecheck` -- From `packages/opencode`, run `bun test --timeout 30000 test/sandbox/preset.test.ts` - -### Phase 3b — protected roots - -Goal: implement protected-root resolution and SBPL deny-after-allow ordering. - -Steps: - -1. Add `Protected.workspace()` and `Protected.resolve()` to `file/protected.ts`. -2. Add `protected_roots` to `SandboxPolicy.Input`. -3. In `SandboxPolicy.build()`, emit deny rules for protected roots after write-allow rules. -4. Add unit tests verifying SBPL output order and worktree `.git` resolution. - -Exit criteria: - -- `.git` and `.opencode` are write-denied inside writable project root -- Worktree gitdir is correctly resolved and protected -- SBPL deny rules appear after write-allow rules - -QA: - -- From `packages/opencode`, run `bun run typecheck` -- From `packages/opencode`, run `bun test --timeout 30000 test/sandbox/policy.test.ts` -- macOS integration: `touch .git/test` inside sandbox MUST fail - -### Phase 3c — `SandboxRuntime` helper - -Goal: consolidate sandbox resolution into `SandboxRuntime.plan()`. - -Steps: - -1. Implement `sandbox/runtime.ts` with `SandboxRuntime.plan()`. -2. Refactor `bash.ts` to use `SandboxRuntime.plan()` instead of direct `SandboxSpawn` calls. -3. Refactor `session/prompt.ts` the same way. -4. Refactor `pty/index.ts` the same way. -5. Verify all existing sandbox behavior is preserved through the refactor. - -Exit criteria: - -- All three existing call sites use `SandboxRuntime.plan()` -- No behavioral change -- Existing tests pass without modification - -QA: - -- From `packages/opencode`, run `bun run typecheck` -- From `packages/opencode`, run `bun test --timeout 30000` for all existing sandbox tests -- Manual smoke test: enable sandbox, run bash commands, verify same behavior as before - -### Phase 3d — preset permission overlay - -Goal: merge preset permission overlays into agent rulesets. - -Steps: - -1. In `agent/agent.ts`, when a sandbox preset is active, read its `permission` overlay. -2. Insert the overlay into the `Permission.merge()` chain between defaults and agent-specific config. -3. Add tests verifying the merge order. - -Exit criteria: - -- Strict preset's `bash: ask` overlay takes effect for the build agent -- Explicit user permission config overrides the preset overlay -- Agents without permission config inherit the preset overlay - -QA: - -- From `packages/opencode`, run `bun run typecheck` -- Add a dedicated test file at `packages/opencode/test/sandbox/preset-permission.test.ts`. -- From `packages/opencode`, run: - `bun test --timeout 30000 test/sandbox/preset-permission.test.ts` -- Test case 1 — preset overlay applies when no agent or user override exists: - - Configure the `strict` preset (which sets `bash: ask`). - - Resolve the `build` agent's permission ruleset. - - Evaluate `Permission.evaluate("bash", "echo hello", ruleset)`. - - Expected result: action is `"ask"`. -- Test case 2 — agent-specific config overrides the preset overlay: - - Configure the `strict` preset (`bash: ask`). - - Configure `agent.build.permission` with `bash: allow`. - - Resolve the `build` agent's permission ruleset. - - Evaluate `Permission.evaluate("bash", "echo hello", ruleset)`. - - Expected result: action is `"allow"` (agent config wins over preset overlay). -- Test case 3 — top-level user config overrides both preset and agent config: - - Configure the `strict` preset (`bash: ask`). - - Configure `agent.build.permission` with `bash: allow`. - - Configure top-level `permission` with `bash: deny`. - - Resolve the `build` agent's permission ruleset. - - Evaluate `Permission.evaluate("bash", "echo hello", ruleset)`. - - Expected result: action is `"deny"` (top-level user config wins over both). -- Test case 4 — agent without explicit permission inherits preset overlay: - - Configure the `strict` preset (`bash: ask`, `edit: ask`). - - Do NOT set any `agent.general.permission`. - - Resolve the `general` agent's permission ruleset. - - Evaluate `Permission.evaluate("bash", "ls", ruleset)`. - - Expected result: action is `"ask"` (preset overlay is inherited). -- Test case 5 — no preset active, existing behavior preserved: - - Do NOT set a `preset` or enable sandbox. - - Resolve the `build` agent's permission ruleset. - - Evaluate `Permission.evaluate("bash", "echo hello", ruleset)`. - - Expected result: action matches the existing phase-2 default (no preset influence). - -### Phase 3e — LSP runtime sandboxing - -Goal: sandbox LSP server launches on macOS when sandbox is enabled. - -Steps: - -1. In `lsp/launch.ts`, call `SandboxRuntime.plan()` before spawning. -2. When active, replace `cmd` + `args` with the wrapped version. -3. Force `read-only` mode for LSP launches regardless of preset. -4. Validate that LSP servers can still read the project tree and provide diagnostics. -5. Validate that LSP servers cannot write to the project root. - -Exit criteria: - -- LSP servers start correctly inside the sandbox -- Language diagnostics work (TypeScript, etc.) -- Write attempts to the project root fail -- Outbound network is denied - -QA: - -- From `packages/opencode`, run `bun run typecheck` -- Manual verification: enable sandbox, open a TypeScript file, verify diagnostics appear -- Manual verification: monitor sandbox denials to ensure no unexpected reads are blocked -- Add integration tests in `packages/opencode/test/lsp/` if testable without a running LSP server - -### Phase 3f — docs - -Goal: ship accurate documentation for phase 3 capabilities. - -Steps: - -1. Update `SECURITY.md` to reflect preset support and LSP coverage. -2. Document preset model: built-ins, custom definitions, override semantics. -3. Document protected-root behavior. -4. Document LSP sandboxing and its limitations. -5. Document exclusions: MCP, internal spawn layers, domain-mediated network, non-macOS. - -Exit criteria: - -- Docs match actual coverage -- No overclaiming - -QA: - -- From the repo root, run: - `grep -n "preset\|protected.root\|LSP\|MCP\|non-macOS" SECURITY.md` -- Expected: each new capability is documented with scope and limitations -- No sentence implies all process execution is sandboxed - ---- - -## Rollout plan - -1. Ship phase 3a (preset infrastructure) behind the existing `experimental.sandbox.enabled` flag. - No new flag required. -2. Ship phase 3b (protected roots) alongside 3a. - Default protected roots activate when sandbox is enabled. -3. Ship phase 3c (runtime helper refactor) as a no-behavioral-change refactor. -4. Ship phase 3d (preset permission overlay) with presets active only when `preset` is explicitly set. -5. Ship phase 3e (LSP sandboxing) behind the existing `enabled` flag. - LSP sandboxing activates when sandbox is enabled; no additional opt-in. -6. Ship phase 3f (docs) only after manual verification of the full validation matrix. - -Each sub-phase MAY ship independently. -Phase 3c SHOULD land before 3d and 3e because they depend on `SandboxRuntime.plan()`. -Phases 3a and 3b have no ordering dependency on each other. - ---- - -## Risks and sharp edges - -### SBPL rule ordering for protected roots - -Seatbelt uses last-match-wins for conflicting rules. -If deny rules for `.git` appear before the write-allow rule for the project root, -the allow rule will override the deny. -The implementation MUST emit deny rules after allow rules. - -Mitigation: add a dedicated unit test that verifies the SBPL output order. - -### LSP server compatibility - -LSP servers may read paths not obvious from the first pass — -global npm modules, toolchain caches, language-specific state directories. -A too-restrictive read policy will break language intelligence silently. - -Mitigation: ship LSP sandboxing behind the existing experimental flag. -Monitor sandbox denials (visible in macOS Console.app) during early testing. -Document the `extra_read_roots` escape hatch prominently. - -### Preset name collisions - -If a custom preset shares a name with a built-in, -the config parser MUST reject it. -Silent override of built-in presets would break safety guarantees. - -Mitigation: validate at config parse time and emit a clear error message. - -### `sandbox-exec` deprecation - -Inherited from phases 1 and 2. -Apple has not removed `sandbox-exec` but has deprecated the Seatbelt API. -Both Codex and Claude Code still rely on it. - -### Worktree gitdir resolution - -Git worktrees store a `.git` file (not directory) that contains the path to the real gitdir. -The resolver must handle both cases and protect both the `.git` entry and the resolved target. - -Mitigation: `Protected.resolve()` reads `.git` as a file, parses the `gitdir:` line, and resolves it. -Add unit tests for both standard repos and worktrees. - -### MCP `StdioClientTransport` ownership - -The MCP SDK's `StdioClientTransport` owns the child process lifecycle. -Wrapping the command before passing it to the transport is possible but fragile. -Phase 3 explicitly defers this. - ---- - -## Open questions - -1. Should the `"default"` preset activate implicitly when `enabled: true` and no `preset` is specified, - or should preset activation require an explicit `"preset": "default"` field? - Implicit activation is more ergonomic; explicit is safer for backward compatibility. - -2. Should LSP sandbox denials be surfaced to the user in the TUI, - or only logged? - Surfacing them helps debugging but may be noisy. - -3. Should custom presets be allowed to set `fail_if_unavailable` and `allow_unsandboxed_retry`, - or should those remain top-level-only fields? - -4. What is the right default for `protected_roots` when no preset is active but sandbox is enabled? - Options: empty (backward compat with phase 2), or `[".git", ".opencode"]` (safer default). - -5. Should phase 3 add a `"permissive"` built-in preset that allows network and has no extra protected roots, - as a stepping stone toward Codex's `danger-full-access`? - This would be useful for CI but weakens the safety story. - -6. Do any built-in LSP servers (TypeScript, Go, Rust Analyzer, etc.) need write access beyond `/tmp`? - This needs empirical testing before finalizing the LSP sandbox policy. - ---- - -## Phase-3 product statement - -If phase 3 ships successfully, -the correct security statement is: - -> On macOS, -> opencode can optionally sandbox agent-issued shell commands from the bash tool, session command path, PTY interactive sessions, and LSP server launches. -> The sandbox supports named presets composing mode, network, and permission settings. -> Workspace-critical paths (`.git`, `.opencode`) are write-protected even inside writable roots. -> Operators can define custom presets and override any preset default with explicit config fields. -> MCP servers, -> internal process utilities, -> domain-mediated network controls, -> and non-macOS platforms are not covered by this phase. - -Anything broader than that is inaccurate. - ---- - -## Later phases - -Phase 3 intentionally defers the following. - -| Item | Why deferred | Likely phase | -| ------------------------------------------ | -------------------------------------------------- | ---------------- | -| MCP local server sandboxing | SDK transport owns process lifecycle | Phase 4 | -| `util/process.ts` auto-sandboxing | Shared by non-agent code paths | Phase 4 or later | -| `cross-spawn-spawner.ts` auto-sandboxing | Effect-layer spawn, infrastructure | Phase 4 or later | -| `danger-full-access` mode | Too risky for experimental | Phase 4+ | -| Domain/proxy network mediation | Substantial infrastructure (localhost proxy) | Phase 5+ | -| Automatic worktree writable-root discovery | Requires safe heuristics for multi-worktree setups | Phase 4 | -| Linux sandboxing | Different mechanism (bubblewrap, namespaces) | Phase 5+ | -| Windows sandboxing | Different mechanism entirely | Phase 6+ | - ---- - -## Pickup checklist for the next agent - -1. Read this file fully before touching code. -2. Read the phase-1 plan at `.sisyphus/plans/macos-command-sandboxing-handoff.md` for historical context. -3. Read the phase-2 plan at `.sisyphus/plans/macos-command-sandboxing-phase-2-parity.md` for the immediate baseline. -4. Verify phase 2 has landed by checking that `pty/index.ts` has sandbox wiring, - `config.ts` has `mode`, `excluded_commands`, and `fail_if_unavailable` fields, - and `sandbox/spawn.ts` has mode and excluded-command support. - If phase 2 has NOT landed, STOP and either wait or scope down to phase-1 coverage. -5. Re-read these repo files before planning edits: - - `packages/opencode/src/sandbox/policy.ts` (SBPL builder) - - `packages/opencode/src/sandbox/spawn.ts` (wrapper, plan, resolve) - - `packages/opencode/src/config/config.ts` (sandbox config, around line 1020) - - `packages/opencode/src/lsp/launch.ts` (LSP spawn, 21 lines) - - `packages/opencode/src/lsp/server.ts` (LSP lifecycle, calls `spawn()` from `launch.ts`) - - `packages/opencode/src/file/protected.ts` (TCC-protected paths) - - `packages/opencode/src/agent/agent.ts` (permission merge in `InstanceState.make`, around line 80) - - `packages/opencode/src/permission/index.ts` (`merge()`, `fromConfig()`, `evaluate()`) - - `packages/opencode/src/permission/evaluate.ts` (last-match-wins rule evaluation) -6. Start with phase 3a and 3b (preset infrastructure + protected roots). - These can land in parallel. -7. Land phase 3c (runtime helper refactor) before 3d and 3e. -8. Do NOT wire `util/process.ts`, `cross-spawn-spawner.ts`, or `mcp/index.ts`. -9. Do NOT implement `danger-full-access` mode. -10. Do NOT claim domain-mediated network controls. -11. Do NOT treat all discovered worktrees as automatically writable. -12. Do NOT allow custom presets to shadow built-in preset names. -13. Do NOT update `SECURITY.md` until the validation matrix passes. -14. Preserve raw `permission` and `experimental.sandbox.*` as explicit overrides over preset defaults. diff --git a/index.html b/index.html deleted file mode 100644 index ecc684c2f8c8..000000000000 --- a/index.html +++ /dev/null @@ -1,150 +0,0 @@ -Google



 

Recherche avance

© 2026 - Confidentialit - Conditions

Applications Google
\ No newline at end of file diff --git a/packages/opencode/2nexit b/packages/opencode/2nexit deleted file mode 100644 index 482b49547cd4..000000000000 --- a/packages/opencode/2nexit +++ /dev/null @@ -1,6 +0,0 @@ -1 | import fs from "fs/promises"; import path from "path"; import { tmpdir } from "./test/fixture/fixture"; import { BashTool } from "./src/tool/bash"; import { Instance } from "./src/project/instance"; import { SessionID, MessageID } from "./src/session/schema"; const ctx = { sessionID: SessionID.make("ses_dbg"), messageID: MessageID.make(""), callID: "", agent: "build", abort: AbortSignal.any([]), messages: [], metadata: () => {}, ask: async (req) => { console.log(JSON.stringify(req)); if (req.permission === "bash:unsandboxed") throw new Error("reject") } }; await using home = await tmpdir(); await using tmp = await tmpdir({ init: async (dir) => { const file = path.join(dir, "curl"); await Bun.write(file, #!/bin/shnprintf - ^ -error: Syntax Error - at /Users/flavien.darche/Documents/opencode/packages/opencode/[eval]:1:714 - -Bun v1.3.11 (macOS arm64) From 73548e7954a963da9025b6c2b48a3212aa14e1c8 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Sat, 28 Mar 2026 14:33:22 +0100 Subject: [PATCH 11/23] remove sandbox lsp --- SECURITY.md | 9 ++---- packages/opencode/src/lsp/launch.ts | 31 ++----------------- packages/opencode/src/sandbox/policy.ts | 13 +++++++- packages/opencode/test/sandbox/policy.test.ts | 12 +++++++ 4 files changed, 29 insertions(+), 36 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index beb45a210d63..b8738e7db7da 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,12 +23,9 @@ OpenCode can optionally sandbox certain command execution paths on macOS using ` | **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 | -| **LSP server launches** | Yes | No | 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. -LSP servers are always launched with `read-only` mode and network denied, regardless of the active preset. This is hardcoded in `lsp/launch.ts` and cannot be overridden by configuration. - #### Presets Built-in presets control mode, network, and permission defaults: @@ -63,17 +60,17 @@ All options live under `experimental.sandbox` in `opencode.json`: - **`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 except LSP launches. +- **`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 and LSP launches do **not** support unsandboxed retry. +- **`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 four surfaces above +- 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.) diff --git a/packages/opencode/src/lsp/launch.ts b/packages/opencode/src/lsp/launch.ts index 9fea16875562..dba61549a92e 100644 --- a/packages/opencode/src/lsp/launch.ts +++ b/packages/opencode/src/lsp/launch.ts @@ -1,43 +1,16 @@ import type { ChildProcessWithoutNullStreams } from "child_process" -import { Instance } from "../project/instance" -import { SandboxRuntime } from "../sandbox/runtime" import { Process } from "../util" type Child = Process.Child & ChildProcessWithoutNullStreams -function roots(cwd: string) { - try { - const dir = Instance.directory - return { - project_root: dir, - worktree_root: Instance.worktree === "/" ? dir : Instance.worktree, - } - } catch { - return { - project_root: cwd, - worktree_root: cwd, - } - } -} - export function spawn(cmd: string, args: string[], opts?: Process.Options): Promise export function spawn(cmd: string, opts?: Process.Options): Promise export async function spawn(cmd: string, argsOrOpts?: string[] | Process.Options, opts?: Process.Options) { const args = Array.isArray(argsOrOpts) ? [...argsOrOpts] : [] const cfg = Array.isArray(argsOrOpts) ? opts : argsOrOpts const cwd = cfg?.cwd ?? process.cwd() - const root = roots(cwd) - const plan = await SandboxRuntime.plan({ - file: cmd, - args, - cwd, - project_root: root.project_root, - worktree_root: root.worktree_root, - mode: "read-only", - allow_network: false, - }) - const proc = Process.spawn([plan.file, ...plan.args], { - ...cfg, + const proc = Process.spawn([cmd, ...args], { + ...(cfg ?? {}), cwd, stdin: "pipe", stdout: "pipe", diff --git a/packages/opencode/src/sandbox/policy.ts b/packages/opencode/src/sandbox/policy.ts index 2ecbcac3813a..058aeb29e6c5 100644 --- a/packages/opencode/src/sandbox/policy.ts +++ b/packages/opencode/src/sandbox/policy.ts @@ -25,7 +25,18 @@ export namespace SandboxPolicy { deny: string[] } - const read = ["/bin", "/sbin", "/usr", "/System", "/Library", "/dev", "/tmp", "/private/tmp", "/private/etc"] + 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"] diff --git a/packages/opencode/test/sandbox/policy.test.ts b/packages/opencode/test/sandbox/policy.test.ts index f675a5813b06..18b0114238bd 100644 --- a/packages/opencode/test/sandbox/policy.test.ts +++ b/packages/opencode/test/sandbox/policy.test.ts @@ -29,6 +29,18 @@ describe("sandbox.policy", () => { 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 and unix socket rules only when requested", () => { const out = SandboxPolicy.build({ cwd: "/tmp/project", From d1254de5cf59c36d7a9d1e26dd3f8b9691e20f11 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Sat, 28 Mar 2026 14:33:41 +0100 Subject: [PATCH 12/23] add docs --- packages/web/src/content/docs/config.mdx | 45 +++++++++++++++++++ packages/web/src/content/docs/permissions.mdx | 9 ++++ packages/web/src/content/docs/tools.mdx | 6 +++ 3 files changed, 60 insertions(+) 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 From 2a171bd7b7e19a8fbf1b5db3781ead46415de73e Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Sun, 29 Mar 2026 19:59:24 +0200 Subject: [PATCH 13/23] doc and translation --- packages/app/src/i18n/ar.ts | 13 +++--- packages/app/src/i18n/br.ts | 15 ++++--- packages/app/src/i18n/bs.ts | 15 ++++--- packages/app/src/i18n/da.ts | 14 +++--- packages/app/src/i18n/de.ts | 15 ++++--- packages/app/src/i18n/es.ts | 15 ++++--- packages/app/src/i18n/fr.ts | 14 +++--- packages/app/src/i18n/ja.ts | 14 +++--- packages/app/src/i18n/ko.ts | 14 +++--- packages/app/src/i18n/no.ts | 14 +++--- packages/app/src/i18n/pl.ts | 15 ++++--- packages/app/src/i18n/ru.ts | 14 +++--- packages/app/src/i18n/th.ts | 15 ++++--- packages/app/src/i18n/tr.ts | 15 ++++--- packages/app/src/i18n/zh.ts | 13 +++--- packages/app/src/i18n/zht.ts | 12 ++--- packages/web/src/content/docs/ar/config.mdx | 45 +++++++++++++++++++ .../web/src/content/docs/ar/permissions.mdx | 9 ++++ packages/web/src/content/docs/ar/tools.mdx | 6 +++ packages/web/src/content/docs/bs/config.mdx | 45 +++++++++++++++++++ .../web/src/content/docs/bs/permissions.mdx | 9 ++++ packages/web/src/content/docs/bs/tools.mdx | 6 +++ packages/web/src/content/docs/da/config.mdx | 45 +++++++++++++++++++ .../web/src/content/docs/da/permissions.mdx | 9 ++++ packages/web/src/content/docs/da/tools.mdx | 6 +++ packages/web/src/content/docs/de/config.mdx | 45 +++++++++++++++++++ .../web/src/content/docs/de/permissions.mdx | 9 ++++ packages/web/src/content/docs/de/tools.mdx | 6 +++ packages/web/src/content/docs/es/config.mdx | 45 +++++++++++++++++++ .../web/src/content/docs/es/permissions.mdx | 9 ++++ packages/web/src/content/docs/es/tools.mdx | 6 +++ packages/web/src/content/docs/fr/config.mdx | 45 +++++++++++++++++++ .../web/src/content/docs/fr/permissions.mdx | 9 ++++ packages/web/src/content/docs/fr/tools.mdx | 6 +++ packages/web/src/content/docs/it/config.mdx | 45 +++++++++++++++++++ .../web/src/content/docs/it/permissions.mdx | 9 ++++ packages/web/src/content/docs/it/tools.mdx | 6 +++ packages/web/src/content/docs/ja/config.mdx | 45 +++++++++++++++++++ .../web/src/content/docs/ja/permissions.mdx | 9 ++++ packages/web/src/content/docs/ja/tools.mdx | 6 +++ packages/web/src/content/docs/ko/config.mdx | 45 +++++++++++++++++++ .../web/src/content/docs/ko/permissions.mdx | 9 ++++ packages/web/src/content/docs/ko/tools.mdx | 6 +++ packages/web/src/content/docs/nb/config.mdx | 45 +++++++++++++++++++ .../web/src/content/docs/nb/permissions.mdx | 9 ++++ packages/web/src/content/docs/nb/tools.mdx | 6 +++ packages/web/src/content/docs/pl/config.mdx | 45 +++++++++++++++++++ .../web/src/content/docs/pl/permissions.mdx | 9 ++++ packages/web/src/content/docs/pl/tools.mdx | 6 +++ .../web/src/content/docs/pt-br/config.mdx | 45 +++++++++++++++++++ .../src/content/docs/pt-br/permissions.mdx | 9 ++++ packages/web/src/content/docs/pt-br/tools.mdx | 6 +++ packages/web/src/content/docs/ru/config.mdx | 45 +++++++++++++++++++ .../web/src/content/docs/ru/permissions.mdx | 9 ++++ packages/web/src/content/docs/ru/tools.mdx | 6 +++ packages/web/src/content/docs/th/config.mdx | 45 +++++++++++++++++++ .../web/src/content/docs/th/permissions.mdx | 9 ++++ packages/web/src/content/docs/th/tools.mdx | 6 +++ packages/web/src/content/docs/tr/config.mdx | 45 +++++++++++++++++++ .../web/src/content/docs/tr/permissions.mdx | 9 ++++ packages/web/src/content/docs/tr/tools.mdx | 6 +++ .../web/src/content/docs/zh-cn/config.mdx | 45 +++++++++++++++++++ .../src/content/docs/zh-cn/permissions.mdx | 9 ++++ packages/web/src/content/docs/zh-cn/tools.mdx | 6 +++ .../web/src/content/docs/zh-tw/config.mdx | 45 +++++++++++++++++++ .../src/content/docs/zh-tw/permissions.mdx | 9 ++++ packages/web/src/content/docs/zh-tw/tools.mdx | 6 +++ 67 files changed, 1151 insertions(+), 96 deletions(-) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 91980acd1fbf..0d911f0f4e81 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -707,12 +707,13 @@ export const dict = { "settings.permissions.tool.list.description": "سرد الملفات داخل دليل", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "تشغيل أوامر shell", - "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.bash_unsandboxed.title": "Bash (بدون صندوق حماية)", + "settings.permissions.tool.bash_unsandboxed.description": "أعد محاولة تشغيل أمر shell بدون قيود صندوق الحماية", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (بدون صندوق حماية)", + "settings.permissions.tool.bash_unsandboxed_network.description": + "تم تعطيل الشبكات في صندوق الحماية، لذلك ربما فشلت المحاولة السابقة بسبب صندوق الحماية.", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (بدون صندوق حماية)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": "طلب الأمر التشغيل بدون قيود صندوق الحماية.", "settings.permissions.tool.task.title": "مهمة", "settings.permissions.tool.task.description": "تشغيل الوكلاء الفرعيين", "settings.permissions.tool.skill.title": "مهارة", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 0dd3303b6cef..5b51f9e92902 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -717,12 +717,15 @@ export const dict = { "settings.permissions.tool.list.description": "Listar arquivos dentro de um diretório", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Executar comandos shell", - "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.bash_unsandboxed.title": "Bash (sem sandbox)", + "settings.permissions.tool.bash_unsandboxed.description": + "Tente novamente um comando de shell sem restrições de sandbox", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (sem sandbox)", + "settings.permissions.tool.bash_unsandboxed_network.description": + "A rede do sandbox está desativada, então a tentativa anterior pode ter falhado por causa do sandbox.", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (sem sandbox)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": + "O comando solicitou a execução sem restrições de sandbox.", "settings.permissions.tool.task.title": "Tarefa", "settings.permissions.tool.task.description": "Lançar sub-agentes", "settings.permissions.tool.skill.title": "Habilidade", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 47f1850b746a..8df4602a265f 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -791,12 +791,15 @@ export const dict = { "settings.permissions.tool.list.description": "Listanje datoteka unutar direktorija", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Pokretanje shell komandi", - "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.bash_unsandboxed.title": "Bash (bez sandboxa)", + "settings.permissions.tool.bash_unsandboxed.description": + "Ponovo pokušaj pokrenuti shell naredbu bez ograničenja sandboxa", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (bez sandboxa)", + "settings.permissions.tool.bash_unsandboxed_network.description": + "Mreža u sandboxu je onemogućena, pa je prethodni pokušaj možda neuspješno završio zbog sandboxa.", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (bez sandboxa)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": + "Naredba je zatražila pokretanje bez ograničenja sandboxa.", "settings.permissions.tool.task.title": "Zadatak", "settings.permissions.tool.task.description": "Pokretanje pod-agenta", "settings.permissions.tool.skill.title": "Vještina", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 8a666c8fe4c4..1455d28e3001 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -785,12 +785,14 @@ export const dict = { "settings.permissions.tool.list.description": "List filer i en mappe", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Kør shell-kommandoer", - "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.bash_unsandboxed.title": "Bash (uden sandbox)", + "settings.permissions.tool.bash_unsandboxed.description": "Prøv en shell-kommando igen uden sandbox-begrænsninger", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (uden sandbox)", + "settings.permissions.tool.bash_unsandboxed_network.description": + "Sandbox-netværk er deaktiveret, så det forrige forsøg kan være mislykket på grund af sandboxen.", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (uden sandbox)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": + "Kommandoen anmodede om at køre uden sandbox-begrænsninger.", "settings.permissions.tool.task.title": "Opgave", "settings.permissions.tool.task.description": "Start underagenter", "settings.permissions.tool.skill.title": "Færdighed", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 71f9a8aa828a..a377e476e9c4 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -728,12 +728,15 @@ export const dict = { "settings.permissions.tool.list.description": "Dateien in einem Verzeichnis auflisten", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Shell-Befehle ausführen", - "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.bash_unsandboxed.title": "Bash (ohne Sandbox)", + "settings.permissions.tool.bash_unsandboxed.description": + "Einen Shell-Befehl ohne Sandbox-Einschränkungen erneut ausführen", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (ohne Sandbox)", + "settings.permissions.tool.bash_unsandboxed_network.description": + "Das Sandbox-Netzwerk ist deaktiviert, daher könnte der vorherige Versuch an der Sandbox gescheitert sein.", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (ohne Sandbox)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": + "Der Befehl hat angefordert, ohne Sandbox-Einschränkungen ausgeführt zu werden.", "settings.permissions.tool.task.title": "Aufgabe", "settings.permissions.tool.task.description": "Unteragenten starten", "settings.permissions.tool.skill.title": "Fähigkeit", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 929eee8bf0b7..852df65e5cfc 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -798,12 +798,15 @@ export const dict = { "settings.permissions.tool.list.description": "Listar archivos dentro de un directorio", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Ejecutar comandos de shell", - "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.bash_unsandboxed.title": "Bash (sin sandbox)", + "settings.permissions.tool.bash_unsandboxed.description": + "Reintentar un comando de shell sin restricciones de sandbox", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (sin sandbox)", + "settings.permissions.tool.bash_unsandboxed_network.description": + "La red del sandbox está deshabilitada, por lo que el intento anterior pudo haber fallado a causa del sandbox.", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (sin sandbox)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": + "El comando solicitó ejecutarse sin restricciones de sandbox.", "settings.permissions.tool.task.title": "Tarea", "settings.permissions.tool.task.description": "Lanzar sub-agentes", "settings.permissions.tool.skill.title": "Habilidad", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 784ec4b7435f..f4fe70bc7161 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -726,12 +726,14 @@ export const dict = { "settings.permissions.tool.list.description": "Lister les fichiers dans un répertoire", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Exécuter des commandes shell", - "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.bash_unsandboxed.title": "Bash (sans sandbox)", + "settings.permissions.tool.bash_unsandboxed.description": "Réessayer une commande shell sans restrictions de sandbox", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (sans sandbox)", + "settings.permissions.tool.bash_unsandboxed_network.description": + "Le réseau du sandbox est désactivé, donc la tentative précédente a peut-être échoué à cause du sandbox.", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (sans sandbox)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": + "La commande a demandé à s'exécuter sans restrictions de sandbox.", "settings.permissions.tool.task.title": "Tâche", "settings.permissions.tool.task.description": "Lancer des sous-agents", "settings.permissions.tool.skill.title": "Compétence", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 61e807961553..b488cd7b2a67 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -712,12 +712,14 @@ export const dict = { "settings.permissions.tool.list.description": "ディレクトリ内のファイル一覧表示", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "シェルコマンドの実行", - "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.bash_unsandboxed.title": "Bash(サンドボックスなし)", + "settings.permissions.tool.bash_unsandboxed.description": "サンドボックスの制限なしでシェルコマンドを再試行します", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash(サンドボックスなし)", + "settings.permissions.tool.bash_unsandboxed_network.description": + "サンドボックスのネットワークが無効になっているため、前回の試行はサンドボックスが原因で失敗した可能性があります。", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash(サンドボックスなし)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": + "このコマンドはサンドボックスの制限なしで実行するよう要求しました。", "settings.permissions.tool.task.title": "タスク", "settings.permissions.tool.task.description": "サブエージェントの起動", "settings.permissions.tool.skill.title": "スキル", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 2ad40ff765bd..9d60ff229f18 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -707,12 +707,14 @@ export const dict = { "settings.permissions.tool.list.description": "디렉터리 내 파일 나열", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "셸 명령어 실행", - "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.bash_unsandboxed.title": "Bash (샌드박스 없음)", + "settings.permissions.tool.bash_unsandboxed.description": "샌드박스 제한 없이 셸 명령을 다시 시도합니다", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (샌드박스 없음)", + "settings.permissions.tool.bash_unsandboxed_network.description": + "샌드박스 네트워킹이 비활성화되어 있으므로 이전 시도는 샌드박스 때문에 실패했을 수 있습니다.", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (샌드박스 없음)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": + "명령이 샌드박스 제한 없이 실행되도록 요청했습니다.", "settings.permissions.tool.task.title": "작업", "settings.permissions.tool.task.description": "하위 에이전트 실행", "settings.permissions.tool.skill.title": "기술", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 4b00087aa4c4..a8c86b25363d 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -792,12 +792,14 @@ export const dict = { "settings.permissions.tool.list.description": "List filer i en mappe", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Kjør shell-kommandoer", - "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.bash_unsandboxed.title": "Bash (uten sandbox)", + "settings.permissions.tool.bash_unsandboxed.description": "Prøv en skalkommando på nytt uten sandbox-begrensninger", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (uten sandbox)", + "settings.permissions.tool.bash_unsandboxed_network.description": + "Sandbox-nettverk er deaktivert, så det forrige forsøket kan ha mislyktes på grunn av sandboxen.", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (uten sandbox)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": + "Kommandoen ba om å kjøre uten sandbox-begrensninger.", "settings.permissions.tool.task.title": "Oppgave", "settings.permissions.tool.task.description": "Start underagenter", "settings.permissions.tool.skill.title": "Ferdighet", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index b4e8de10c0b6..bc9c94bffb31 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -714,12 +714,15 @@ export const dict = { "settings.permissions.tool.list.description": "Wyświetlanie listy plików w katalogu", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Uruchamianie poleceń powłoki", - "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.bash_unsandboxed.title": "Bash (bez sandboxa)", + "settings.permissions.tool.bash_unsandboxed.description": + "Ponów próbę uruchomienia polecenia powłoki bez ograniczeń sandboxa", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (bez sandboxa)", + "settings.permissions.tool.bash_unsandboxed_network.description": + "Sieć sandboxa jest wyłączona, więc poprzednia próba mogła nie powieść się z powodu sandboxa.", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (bez sandboxa)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": + "Polecenie zażądało uruchomienia bez ograniczeń sandboxa.", "settings.permissions.tool.task.title": "Zadanie", "settings.permissions.tool.task.description": "Uruchamianie pod-agentów", "settings.permissions.tool.skill.title": "Umiejętność", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 68522036f285..7c6230072637 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -793,12 +793,14 @@ export const dict = { "settings.permissions.tool.list.description": "Список файлов в директории", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Запуск команд оболочки", - "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.bash_unsandboxed.title": "Bash (без песочницы)", + "settings.permissions.tool.bash_unsandboxed.description": "Повторить запуск shell-команды без ограничений песочницы", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (без песочницы)", + "settings.permissions.tool.bash_unsandboxed_network.description": + "Сеть в песочнице отключена, поэтому предыдущая попытка могла завершиться неудачей из-за песочницы.", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (без песочницы)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": + "Команда запросила запуск без ограничений песочницы.", "settings.permissions.tool.task.title": "Task", "settings.permissions.tool.task.description": "Запуск подагентов", "settings.permissions.tool.skill.title": "Skill", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 96aa72c7c96a..70bff54f6eb3 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -781,12 +781,15 @@ export const dict = { "settings.permissions.tool.list.description": "แสดงรายการไฟล์ภายในไดเรกทอรี", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "เรียกใช้คำสั่งเชลล์", - "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.bash_unsandboxed.title": "Bash (ไม่มีแซนด์บ็อกซ์)", + "settings.permissions.tool.bash_unsandboxed.description": + "ลองเรียกใช้คำสั่ง shell อีกครั้งโดยไม่มีข้อจำกัดของแซนด์บ็อกซ์", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (ไม่มีแซนด์บ็อกซ์)", + "settings.permissions.tool.bash_unsandboxed_network.description": + "เครือข่ายของแซนด์บ็อกซ์ถูกปิดใช้งานอยู่ ดังนั้นความพยายามก่อนหน้านี้อาจล้มเหลวเพราะแซนด์บ็อกซ์", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (ไม่มีแซนด์บ็อกซ์)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": + "คำสั่งนี้ขอให้ทำงานโดยไม่มีข้อจำกัดของแซนด์บ็อกซ์", "settings.permissions.tool.task.title": "งาน", "settings.permissions.tool.task.description": "เปิดเอเจนต์ย่อย", "settings.permissions.tool.skill.title": "ทักษะ", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index d0458b13a030..cde39d310faf 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -800,12 +800,15 @@ export const dict = { "settings.permissions.tool.list.description": "Bir dizindeki dosyaları listele", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Kabuk komutları çalıştır", - "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.bash_unsandboxed.title": "Bash (sandbox olmadan)", + "settings.permissions.tool.bash_unsandboxed.description": + "Bir kabuk komutunu sandbox kısıtlamaları olmadan yeniden dene", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (sandbox olmadan)", + "settings.permissions.tool.bash_unsandboxed_network.description": + "Sandbox ağı devre dışı, bu yüzden önceki deneme sandbox nedeniyle başarısız olmuş olabilir.", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (sandbox olmadan)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": + "Komut, sandbox kısıtlamaları olmadan çalıştırılmayı istedi.", "settings.permissions.tool.task.title": "Görev", "settings.permissions.tool.task.description": "Alt ajanlar başlat", "settings.permissions.tool.skill.title": "Beceri", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index f298c4d6d109..a9d94b17b29c 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -779,12 +779,13 @@ export const dict = { "settings.permissions.tool.list.description": "列出目录中的文件", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "运行 shell 命令", - "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.bash_unsandboxed.title": "Bash(无沙箱)", + "settings.permissions.tool.bash_unsandboxed.description": "在没有沙箱限制的情况下重试 shell 命令", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash(无沙箱)", + "settings.permissions.tool.bash_unsandboxed_network.description": + "沙箱网络已被禁用,因此上一次尝试可能因沙箱而失败。", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash(无沙箱)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": "该命令请求在没有沙箱限制的情况下运行。", "settings.permissions.tool.task.title": "任务", "settings.permissions.tool.task.description": "启动子智能体", "settings.permissions.tool.skill.title": "技能", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index e351c5a9a1d0..420bfdecee1a 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -775,12 +775,12 @@ export const dict = { "settings.permissions.tool.list.description": "列出目錄中的檔案", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "執行 shell 命令", - "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.bash_unsandboxed.title": "Bash(無沙箱)", + "settings.permissions.tool.bash_unsandboxed.description": "在沒有沙箱限制的情況下重試 shell 指令", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash(無沙箱)", + "settings.permissions.tool.bash_unsandboxed_network.description": "沙箱網路已停用,因此上一次嘗試可能因沙箱而失敗。", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash(無沙箱)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": "此指令要求在沒有沙箱限制的情況下執行。", "settings.permissions.tool.task.title": "Task", "settings.permissions.tool.task.description": "啟動子代理程式", "settings.permissions.tool.skill.title": "Skill", diff --git a/packages/web/src/content/docs/ar/config.mdx b/packages/web/src/content/docs/ar/config.mdx index 5a1c294bf216..784da8015d57 100644 --- a/packages/web/src/content/docs/ar/config.mdx +++ b/packages/web/src/content/docs/ar/config.mdx @@ -624,6 +624,51 @@ opencode run "Hello world" الخيارات التجريبية غير مستقرة. قد تتغير أو تُزال دون إشعار. ::: +### Sandbox + +يمكن لـ OpenCode تشغيل أوامر bash وأوامر shell للجلسة وبدء PTY داخل بيئة معزولة (sandbox) على macOS. +ميزة Sandbox تجريبية واختيارية ومعطّلة افتراضيًا. + +```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"] + } + } +} +``` + +الخيارات المتاحة: + +| الخيار | النوع | الوصف | +| ------------------------- | ---------------------------------- | -------------------------------------------------------------------------------- | +| `enabled` | `boolean` | تفعيل البيئة المعزولة لمسارات التنفيذ المدعومة على macOS. | +| `preset` | `string` | اختيار إعداد مسبق مدمج (`default` أو `strict` أو `network`) أو اسم مخصص. | +| `mode` | `"workspace-write" \| "read-only"` | تجاوز وضع الإعداد المسبق. | +| `network` | `boolean` | تجاوز ما إذا كان الوصول إلى الشبكة مسموحًا. | +| `protected_roots` | `string[]` | مسارات نسبية لمساحة العمل تبقى محمية ضد الكتابة حتى داخل الجذور القابلة للكتابة. | +| `extra_read_roots` | `string[]` | مسارات مطلقة إضافية يمكن للبيئة المعزولة قراءتها. | +| `extra_write_roots` | `string[]` | مسارات مطلقة إضافية يمكن للبيئة المعزولة الكتابة فيها. | +| `extra_deny_paths` | `string[]` | مسارات مطلقة إضافية يجب على البيئة المعزولة رفضها. | +| `excluded_commands` | `string[]` | بادئات أوامر يجب حظرها قبل التنفيذ. | +| `allow_unsandboxed_retry` | `boolean` | السماح بإعادة محاولة منفصلة عبر إذن `bash:unsandboxed` بعد رفض البيئة المعزولة. | +| `fail_if_unavailable` | `boolean` | فشل صريح عندما تكون البيئة المعزولة مفعّلة لكن لا يمكن تنشيطها. | +| `presets` | `Record` | تعريف إعدادات مسبقة مخصصة مع `mode` و`network` والجذور وتجاوزات الأذونات. | + +:::note +يمكن للأوامر المعزولة قراءة جذور النظام المدمجة مثل `/bin` و`/usr` و`/opt/homebrew` و`/System` و`/Library` و`/dev` و`/tmp` و`/private/etc`. +تظل المسارات الحساسة في المنزل مثل `~/.ssh` و`~/.gnupg` وأدلة بيانات الاعتماد السحابية مرفوضة افتراضيًا. +راجع [سياسة الأمان](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) لنموذج التهديد الكامل والأسطح المغطاة والقيود الحالية. +::: + --- ## المتغيرات diff --git a/packages/web/src/content/docs/ar/permissions.mdx b/packages/web/src/content/docs/ar/permissions.mdx index bb21d00b243a..ad9ee26504f5 100644 --- a/packages/web/src/content/docs/ar/permissions.mdx +++ b/packages/web/src/content/docs/ar/permissions.mdx @@ -134,6 +134,7 @@ description: تحكّم في الإجراءات التي تتطلب موافقة - `glob` — مطابقة أسماء الملفات (يطابق نمط الـ glob) - `grep` — البحث في المحتوى (يطابق نمط regex) - `bash` — تشغيل أوامر shell (يطابق الأوامر المُحلَّلة مثل `git status --porcelain`) +- `bash:unsandboxed` — إعادة تشغيل أمر shell خارج البيئة المعزولة بعد الرفض أو بعد طلب صريح لتشغيله بدون عزل - `task` — تشغيل وكلاء فرعيين (يطابق نوع الوكيل الفرعي) - `skill` — تحميل مهارة (يطابق اسم المهارة) - `lsp` — تشغيل استعلامات LSP (حاليًا دون قواعد دقيقة) @@ -233,3 +234,11 @@ Only analyze code and suggest changes. :::tip استخدم مطابقة الأنماط للأوامر التي تحتوي على معاملات. يسمح `"grep *"` بتنفيذ `grep pattern file.txt`، بينما سيحظر `"grep"` وحده ذلك. تعمل أوامر مثل `git status` للسلوك الافتراضي، لكنها تتطلب إذنًا صريحًا (مثل `"git status *"`) عند تمرير معاملات. ::: + +--- + +## التفاعل مع البيئة المعزولة + +عند تفعيل البيئة المعزولة على macOS، يمكن أن يؤدي حظر أمر bash إلى تشغيل طلب إذن منفصل `bash:unsandboxed`. +يحدث ذلك عندما يكتشف OpenCode رفضًا محتملًا من البيئة المعزولة، أو عندما يطلب الأمر صراحةً تخطي البيئة المعزولة عبر `# opencode:unsandboxed ` في أول سطر غير فارغ. +اضبط البيئة المعزولة نفسها في [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/ar/tools.mdx b/packages/web/src/content/docs/ar/tools.mdx index 3f3c9ee06850..ca6fbe0271bf 100644 --- a/packages/web/src/content/docs/ar/tools.mdx +++ b/packages/web/src/content/docs/ar/tools.mdx @@ -60,6 +60,12 @@ description: إدارة الأدوات التي يمكن لـ LLM استخدام تتيح هذه الأداة لـ LLM تشغيل أوامر terminal مثل `npm install` و`git status` أو أي أمر shell آخر. +:::note +عند تفعيل البيئة المعزولة على macOS، يعمل bash مع قيود على نظام الملفات ويمكنه طلب إعادة محاولة منفصلة بدون عزل عند الحاجة. +إذا كنت تعلم أن أمرًا ما يجب أن يبدأ خارج البيئة المعزولة، ضع `# opencode:unsandboxed ` في أول سطر غير فارغ من الأمر. +راجع [إعدادات البيئة المعزولة](/docs/config#sandbox) للسلوك المدعوم والقيود. +::: + --- ### edit diff --git a/packages/web/src/content/docs/bs/config.mdx b/packages/web/src/content/docs/bs/config.mdx index 3183a2f92df9..dcada7760443 100644 --- a/packages/web/src/content/docs/bs/config.mdx +++ b/packages/web/src/content/docs/bs/config.mdx @@ -624,6 +624,51 @@ Ključ `experimental` sadrži opcije koje su u aktivnom razvoju. Eksperimentalne opcije nisu stabilne. Mogu se promijeniti ili ukloniti bez prethodne najave. ::: +### Sandbox + +OpenCode može pokrenuti bash komande, shell komande sesije i PTY pokretanje u sandbox okruženju na macOS-u. +Sandboxing je eksperimentalan, opcionalan i isključen po defaultu. + +```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"] + } + } +} +``` + +Dostupne opcije: + +| Opcija | Tip | Opis | +| ------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------- | +| `enabled` | `boolean` | Omogući sandboxing za podržane macOS putanje izvršavanja. | +| `preset` | `string` | Izaberite ugrađeni preset (`default`, `strict`, `network`) ili prilagođeni naziv. | +| `mode` | `"workspace-write" \| "read-only"` | Zaobiđi preset mod. | +| `network` | `boolean` | Zaobiđi da li je pristup mreži dozvoljen. | +| `protected_roots` | `string[]` | Putanje relativne radnom prostoru koje ostaju zaštićene od pisanja čak i unutar piših korijena. | +| `extra_read_roots` | `string[]` | Dodatne apsolutne putanje koje sandbox može čitati. | +| `extra_write_roots` | `string[]` | Dodatne apsolutne putanje u koje sandbox može pisati. | +| `extra_deny_paths` | `string[]` | Dodatne apsolutne putanje koje sandbox mora odbiti. | +| `excluded_commands` | `string[]` | Prefiksi komandi koji moraju biti blokirani prije izvršavanja. | +| `allow_unsandboxed_retry` | `boolean` | Dozvoli odvojeni pokušaj putem `bash:unsandboxed` dozvole nakon odbijanja sandboxa. | +| `fail_if_unavailable` | `boolean` | Potpuni neuspjeh kada je sandboxing omogućen ali se ne može aktivirati. | +| `presets` | `Record` | Definirajte prilagođene presete sa `mode`, `network`, korijenima i dozvolama. | + +:::note +Sandbox komande mogu čitati ugrađene sistemske korijene kao što su `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` i `/private/etc`. +Osjetljive putanje u kućnom direktoriju kao što su `~/.ssh`, `~/.gnupg` i direktoriji cloud kredencijala ostaju odbijene po defaultu. +Pogledajte [sigurnosnu politiku](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) za potpuni model prijetnji, pokrivene površine i trenutna ograničenja. +::: + --- ## Varijable diff --git a/packages/web/src/content/docs/bs/permissions.mdx b/packages/web/src/content/docs/bs/permissions.mdx index e27fa130b362..dedf5b9db7d0 100644 --- a/packages/web/src/content/docs/bs/permissions.mdx +++ b/packages/web/src/content/docs/bs/permissions.mdx @@ -129,6 +129,7 @@ Dozvole OpenCode su označene imenom alata, plus nekoliko sigurnosnih mjera: - `glob` — globbiranje fajla (odgovara glob uzorku) - `grep` — pretraga sadržaja (podudara se sa regularnim izrazom) - `bash` — izvođenje komandi ljuske (podudara se s raščlanjenim komandama kao što je `git status --porcelain`) +- `bash:unsandboxed` — ponovno pokretanje shell komande izvan sandboxa nakon odbijanja ili nakon eksplicitnog zahtjeva za pokretanje bez sandboxa - `task` — pokretanje subagenta (odgovara tipu podagenta) - `skill` — učitavanje vještine (odgovara nazivu vještine) - `lsp` — pokretanje LSP upita (trenutno negranularno) @@ -226,3 +227,11 @@ Only analyze code and suggest changes. :::tip Koristite podudaranje uzoraka za naredbe s argumentima. `"grep *"` dozvoljava `grep pattern file.txt`, dok bi ga samo `"grep"` blokirao. Naredbe poput `git status` rade za zadano ponašanje, ali zahtijevaju eksplicitnu dozvolu (kao `"git status *"`) kada se prosljeđuju argumenti. ::: + +--- + +## Interakcija sa sandboxom + +Kada je macOS sandboxing omogućen, blokirana bash komanda može pokrenuti odvojeni `bash:unsandboxed` zahtjev za dozvolom. +Ovo se dešava kada OpenCode detektuje vjerovatno odbijanje sandboxa, ili kada komanda eksplicitno traži preskakanje sandboxa sa `# opencode:unsandboxed ` na prvom nepraznom redu. +Konfigurirajte sam sandbox u [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/bs/tools.mdx b/packages/web/src/content/docs/bs/tools.mdx index db04295fd2f1..866b322e7517 100644 --- a/packages/web/src/content/docs/bs/tools.mdx +++ b/packages/web/src/content/docs/bs/tools.mdx @@ -60,6 +60,12 @@ Izvrsava shell komande u okruzenju projekta. Ovaj alat omogucava LLM-u da pokrece terminalske komande kao `npm install`, `git status` i druge shell komande. +:::note +Kada je macOS sandboxing omogućen, bash se pokreće sa ograničenjima fajl sistema i može zatražiti odvojeni pokušaj bez sandboxa kada je potrebno. +Ako znate da komanda mora početi izvan sandboxa, stavite `# opencode:unsandboxed ` na prvi neprazni red komande. +Pogledajte [sandbox konfiguraciju](/docs/config#sandbox) za podržano ponašanje i ograničenja. +::: + --- ### edit diff --git a/packages/web/src/content/docs/da/config.mdx b/packages/web/src/content/docs/da/config.mdx index 18b462580b74..7612d2a5e60a 100644 --- a/packages/web/src/content/docs/da/config.mdx +++ b/packages/web/src/content/docs/da/config.mdx @@ -627,6 +627,51 @@ Nøglen `experimental` indeholder muligheder, der er under aktiv udvikling. Eksperimentelle muligheder er ikke stabile. De kan ændres eller fjernes uden varsel. ::: +### Sandbox + +OpenCode kan køre bash-kommandoer, sessions shell-kommandoer og PTY-opstart i en sandbox på macOS. +Sandboxing er eksperimentelt, opt-in og deaktiveret som standard. + +```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"] + } + } +} +``` + +Tilgængelige muligheder: + +| Mulighed | Type | Beskrivelse | +| ------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------- | +| `enabled` | `boolean` | Aktiver sandboxing for de understøttede macOS-eksekveringsstier. | +| `preset` | `string` | Vælg et indbygget preset (`default`, `strict`, `network`) eller et brugerdefineret navn. | +| `mode` | `"workspace-write" \| "read-only"` | Tilsidesæt preset-tilstanden. | +| `network` | `boolean` | Tilsidesæt om udgående netværksadgang er tilladt. | +| `protected_roots` | `string[]` | Arbejdsområde-relative stier, der forbliver skrivebeskyttede selv inden for skrivbare rødder. | +| `extra_read_roots` | `string[]` | Yderligere absolutte stier, som sandboxen kan læse. | +| `extra_write_roots` | `string[]` | Yderligere absolutte stier, som sandboxen kan skrive til. | +| `extra_deny_paths` | `string[]` | Yderligere absolutte stier, som sandboxen skal nægte. | +| `excluded_commands` | `string[]` | Kommandopræfikser, der skal blokeres før udførelse. | +| `allow_unsandboxed_retry` | `boolean` | Tillad et separat `bash:unsandboxed` tilladelsesbeskyttet genforsøg efter en sandbox-afvisning. | +| `fail_if_unavailable` | `boolean` | Hård fejl, når sandboxing er aktiveret, men ikke kan aktiveres. | +| `presets` | `Record` | Definer brugerdefinerede presets med `mode`, `network`, rødder og tilladelsestilsidesættelser. | + +:::note +Sandboxede kommandoer kan læse indbyggede systemrødder som `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` og `/private/etc`. +Følsomme hjemmestier som `~/.ssh`, `~/.gnupg` og cloud-legitimationsmapper forbliver nægtede som standard. +Se [sikkerhedspolitikken](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) for den fulde trusselsmodel, dækkede overflader og aktuelle begrænsninger. +::: + --- ## Variabler diff --git a/packages/web/src/content/docs/da/permissions.mdx b/packages/web/src/content/docs/da/permissions.mdx index 176dd568e185..39e9589aa04b 100644 --- a/packages/web/src/content/docs/da/permissions.mdx +++ b/packages/web/src/content/docs/da/permissions.mdx @@ -134,6 +134,7 @@ OpenCode tilladelser indtastes efter værktøjsnavn plus et par sikkerhedsafskæ - `glob` — fil-globing (matcher glob-mønsteret) - `grep` — indholdssøgning (matcher regex-mønsteret) - `bash` — kører shell-kommandoer (matcher parsede kommandoer som `git status --porcelain`) +- `bash:unsandboxed` — genkørsel af en shell-kommando uden for sandboxen efter afvisning eller efter en eksplicit anmodning om at køre uden sandbox - `task` — lancering af underagenter (matcher underagenttypen) - `skill` — indlæsning af en færdighed (matcher færdighedsnavnet) - `lsp` — kører LSP forespørgsler (i øjeblikket ikke-granulære) @@ -233,3 +234,11 @@ Only analyze code and suggest changes. :::tip Brug mønstermatchning til kommandoer med argumenter. `"grep *"` tillader `grep pattern file.txt`, mens `"grep"` alene ville blokere det. Kommandoer som `git status` fungerer for standardadfærd, men kræver eksplicit tilladelse (som `"git status *"`), når argumenter sendes. ::: + +--- + +## Sandbox-interaktion + +Når macOS-sandboxing er aktiveret, kan en blokeret bash-kommando udløse en separat `bash:unsandboxed` tilladelsesanmodning. +Dette sker, når OpenCode registrerer en sandsynlig sandbox-afvisning, eller når kommandoen eksplicit beder om at springe sandboxen over med `# opencode:unsandboxed ` på den første ikke-tomme linje. +Konfigurer selve sandboxen i [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/da/tools.mdx b/packages/web/src/content/docs/da/tools.mdx index 6f6f95c9c582..dee2e345b264 100644 --- a/packages/web/src/content/docs/da/tools.mdx +++ b/packages/web/src/content/docs/da/tools.mdx @@ -60,6 +60,12 @@ Utfør shellkommandoer i prosjektmiljøet ditt. Dette verktøyet lar LLM kjøre terminalkommandoer som `npm install`, `git status` eller en hvilken som helst annen shell-kommando. +:::note +Når macOS-sandboxing er aktiveret, kører bash med filsystembegrænsninger og kan anmode om et separat genforsøg uden sandbox, når det er nødvendigt. +Hvis du ved, at en kommando skal starte uden for sandboxen, sæt `# opencode:unsandboxed ` på den første ikke-tomme linje i kommandoen. +Se [sandbox-konfiguration](/docs/config#sandbox) for understøttet adfærd og begrænsninger. +::: + --- ### edit diff --git a/packages/web/src/content/docs/de/config.mdx b/packages/web/src/content/docs/de/config.mdx index 0a2040be7a1f..7495847180b9 100644 --- a/packages/web/src/content/docs/de/config.mdx +++ b/packages/web/src/content/docs/de/config.mdx @@ -623,6 +623,51 @@ Der Schlüssel `experimental` enthält Optionen, die sich in der aktiven Entwick Experimentelle Optionen sind nicht stabil. Sie können ohne vorherige Ankündigung geändert oder entfernt werden. ::: +### Sandbox + +OpenCode kann Bash-Befehle, Session-Shell-Befehle und PTY-Starts unter macOS in einer Sandbox ausführen. +Sandboxing ist experimentell, opt-in und standardmäßig deaktiviert. + +```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"] + } + } +} +``` + +Verfügbare Optionen: + +| Option | Typ | Beschreibung | +| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `enabled` | `boolean` | Sandboxing für die unterstützten macOS-Ausführungspfade aktivieren. | +| `preset` | `string` | Ein eingebautes Preset (`default`, `strict`, `network`) oder einen benutzerdefinierten Namen wählen. | +| `mode` | `"workspace-write" \| "read-only"` | Den Preset-Modus überschreiben. | +| `network` | `boolean` | Überschreiben, ob ausgehender Netzwerkzugriff erlaubt ist. | +| `protected_roots` | `string[]` | Workspace-relative Pfade, die auch innerhalb beschreibbarer Wurzeln schreibgeschützt bleiben. | +| `extra_read_roots` | `string[]` | Zusätzliche absolute Pfade, die die Sandbox lesen kann. | +| `extra_write_roots` | `string[]` | Zusätzliche absolute Pfade, in die die Sandbox schreiben kann. | +| `extra_deny_paths` | `string[]` | Zusätzliche absolute Pfade, die die Sandbox verweigern muss. | +| `excluded_commands` | `string[]` | Befehlspräfixe, die vor der Ausführung blockiert werden müssen. | +| `allow_unsandboxed_retry` | `boolean` | Einen separaten `bash:unsandboxed`-Berechtigungs-Wiederholungsversuch nach einer Sandbox-Ablehnung erlauben. | +| `fail_if_unavailable` | `boolean` | Harter Fehler, wenn Sandboxing aktiviert ist, aber nicht aktiviert werden kann. | +| `presets` | `Record` | Benutzerdefinierte Presets mit `mode`, `network`, Wurzeln und Berechtigungsüberschreibungen definieren. | + +:::note +Sandbox-Befehle können eingebaute Systemwurzeln wie `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` und `/private/etc` lesen. +Sensible Home-Pfade wie `~/.ssh`, `~/.gnupg` und Cloud-Anmeldedatenverzeichnisse bleiben standardmäßig verweigert. +Siehe die [Sicherheitsrichtlinie](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) für das vollständige Bedrohungsmodell, abgedeckte Oberflächen und aktuelle Einschränkungen. +::: + --- ## Variablen diff --git a/packages/web/src/content/docs/de/permissions.mdx b/packages/web/src/content/docs/de/permissions.mdx index 6b647ca3662f..286ddec532f2 100644 --- a/packages/web/src/content/docs/de/permissions.mdx +++ b/packages/web/src/content/docs/de/permissions.mdx @@ -134,6 +134,7 @@ OpenCode-Berechtigungen basieren auf Tool-Namen sowie einigen Sicherheitsvorkehr - `glob` – Datei-Globbing (entspricht dem Glob-Muster) - `grep` – Inhaltssuche (entspricht dem Regex-Muster) - `bash` – Ausführen von Shell-Befehlen (entspricht analysierten Befehlen wie `git status --porcelain`) +- `bash:unsandboxed` – erneute Ausführung eines Shell-Befehls außerhalb der Sandbox nach einer Ablehnung oder nach einer expliziten Anforderung ohne Sandbox - `task` – Subagenten starten (entspricht dem Subagententyp) - `skill` – Laden einer Fertigkeit (entspricht dem Fertigkeitsnamen) - `lsp` – Ausführen von LSP-Abfragen (derzeit nicht granular) @@ -233,3 +234,11 @@ Only analyze code and suggest changes. :::tip Verwenden Sie den Mustervergleich für Befehle mit Argumenten. `"grep *"` erlaubt `grep pattern file.txt`, während `"grep"` allein es blockieren würde. Befehle wie `git status` funktionieren für das Standardverhalten, erfordern jedoch eine explizite Erlaubnis (wie `"git status *"`), wenn Argumente übergeben werden. ::: + +--- + +## Sandbox-Interaktion + +Wenn macOS-Sandboxing aktiviert ist, kann ein blockierter Bash-Befehl eine separate `bash:unsandboxed`-Berechtigungsanfrage auslösen. +Dies geschieht, wenn OpenCode eine wahrscheinliche Sandbox-Ablehnung erkennt, oder wenn der Befehl explizit das Überspringen der Sandbox mit `# opencode:unsandboxed ` in der ersten nicht-leeren Zeile anfordert. +Konfigurieren Sie die Sandbox selbst unter [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/de/tools.mdx b/packages/web/src/content/docs/de/tools.mdx index 6012148c6a88..f136498bcde0 100644 --- a/packages/web/src/content/docs/de/tools.mdx +++ b/packages/web/src/content/docs/de/tools.mdx @@ -64,6 +64,12 @@ Fuehrt Shell-Befehle in deiner Projektumgebung aus. Damit kann das LLM Terminal-Befehle wie `npm install`, `git status` oder andere Shell-Kommandos ausfuehren. +:::note +Wenn macOS-Sandboxing aktiviert ist, laeuft bash mit Dateisystembeschraenkungen und kann bei Bedarf einen separaten Wiederholungsversuch ohne Sandbox anfordern. +Wenn du weisst, dass ein Befehl ausserhalb der Sandbox starten muss, setze `# opencode:unsandboxed ` in die erste nicht-leere Zeile des Befehls. +Siehe [Sandbox-Konfiguration](/docs/config#sandbox) fuer das unterstuetzte Verhalten und Einschraenkungen. +::: + --- ### edit diff --git a/packages/web/src/content/docs/es/config.mdx b/packages/web/src/content/docs/es/config.mdx index c6142e699016..1fca485336fd 100644 --- a/packages/web/src/content/docs/es/config.mdx +++ b/packages/web/src/content/docs/es/config.mdx @@ -624,6 +624,51 @@ La clave `experimental` contiene opciones que se encuentran en desarrollo activo Las opciones experimentales no son estables. Pueden cambiar o eliminarse sin previo aviso. ::: +### Sandbox + +OpenCode puede ejecutar comandos bash, comandos de shell de sesión y el inicio de PTY en un sandbox en macOS. +El sandbox es experimental, requiere activación explícita y está deshabilitado por defecto. + +```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"] + } + } +} +``` + +Opciones disponibles: + +| Opción | Tipo | Descripción | +| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `enabled` | `boolean` | Habilitar sandbox para las rutas de ejecución compatibles en macOS. | +| `preset` | `string` | Seleccionar un preset integrado (`default`, `strict`, `network`) o un nombre personalizado. | +| `mode` | `"workspace-write" \| "read-only"` | Anular el modo del preset. | +| `network` | `boolean` | Anular si se permite el acceso a la red de salida. | +| `protected_roots` | `string[]` | Rutas relativas al workspace que permanecen protegidas contra escritura incluso dentro de raíces escribibles. | +| `extra_read_roots` | `string[]` | Rutas absolutas adicionales que el sandbox puede leer. | +| `extra_write_roots` | `string[]` | Rutas absolutas adicionales que el sandbox puede escribir. | +| `extra_deny_paths` | `string[]` | Rutas absolutas adicionales que el sandbox debe denegar. | +| `excluded_commands` | `string[]` | Prefijos de comandos que deben bloquearse antes de la ejecución. | +| `allow_unsandboxed_retry` | `boolean` | Permitir un reintento separado con permiso `bash:unsandboxed` después de una denegación del sandbox. | +| `fail_if_unavailable` | `boolean` | Fallo grave cuando el sandbox está habilitado pero no puede activarse. | +| `presets` | `Record` | Definir presets personalizados con `mode`, `network`, raíces y anulaciones de permisos. | + +:::note +Los comandos en sandbox pueden leer rutas del sistema integradas como `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` y `/private/etc`. +Las rutas sensibles del directorio personal como `~/.ssh`, `~/.gnupg` y los directorios de credenciales en la nube permanecen denegadas por defecto. +Consulte la [política de seguridad](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) para el modelo de amenazas completo, superficies cubiertas y limitaciones actuales. +::: + --- ## Variables diff --git a/packages/web/src/content/docs/es/permissions.mdx b/packages/web/src/content/docs/es/permissions.mdx index 6923368e402a..cf5541396e8d 100644 --- a/packages/web/src/content/docs/es/permissions.mdx +++ b/packages/web/src/content/docs/es/permissions.mdx @@ -134,6 +134,7 @@ Los permisos OpenCode están codificados por el nombre de la herramienta, ademá - `glob` — globalización de archivos (coincide con el patrón global) - `grep` — búsqueda de contenido (coincide con el patrón de expresiones regulares) - `bash`: ejecuta comandos de shell (coincide con comandos analizados como `git status --porcelain`) +- `bash:unsandboxed` — reejecutar un comando de shell fuera del sandbox después de una denegación o después de una solicitud explícita sin sandbox - `task` — lanzamiento de subagentes (coincide con el tipo de subagente) - `skill` — cargar una habilidad (coincide con el nombre de la habilidad) - `lsp`: ejecución de consultas LSP (actualmente no granulares) @@ -233,3 +234,11 @@ Only analyze code and suggest changes. :::tip Utilice la coincidencia de patrones para comandos con argumentos. `"grep *"` permite `grep pattern file.txt`, mientras que `"grep"` solo lo bloquearía. Los comandos como `git status` funcionan para el comportamiento predeterminado pero requieren permiso explícito (como `"git status *"`) cuando se pasan argumentos. ::: + +--- + +## Interacción con el sandbox + +Cuando el sandbox de macOS está habilitado, un comando bash bloqueado puede activar una solicitud de permiso `bash:unsandboxed` separada. +Esto ocurre cuando OpenCode detecta una denegación probable del sandbox, o cuando el comando solicita explícitamente omitir el sandbox con `# opencode:unsandboxed ` en la primera línea no vacía. +Configure el sandbox en [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/es/tools.mdx b/packages/web/src/content/docs/es/tools.mdx index 83d61f5325fb..557da6a92cc8 100644 --- a/packages/web/src/content/docs/es/tools.mdx +++ b/packages/web/src/content/docs/es/tools.mdx @@ -60,6 +60,12 @@ Ejecute comandos de shell en el entorno de su proyecto. Esta herramienta permite que LLM ejecute comandos de terminal como `npm install`, `git status` o cualquier otro comando de shell. +:::note +Cuando el sandbox de macOS está habilitado, bash se ejecuta con restricciones del sistema de archivos y puede solicitar un reintento separado sin sandbox cuando sea necesario. +Si sabe que un comando debe iniciarse fuera del sandbox, ponga `# opencode:unsandboxed ` en la primera línea no vacía del comando. +Consulte la [configuración del sandbox](/docs/config#sandbox) para el comportamiento y los límites admitidos. +::: + --- ### edit diff --git a/packages/web/src/content/docs/fr/config.mdx b/packages/web/src/content/docs/fr/config.mdx index c576fe2da11b..12b60871bd05 100644 --- a/packages/web/src/content/docs/fr/config.mdx +++ b/packages/web/src/content/docs/fr/config.mdx @@ -625,6 +625,51 @@ La clé `experimental` contient des options en cours de développement actif. Les options expérimentales ne sont pas stables. Elles peuvent changer ou être supprimées sans préavis. ::: +### Sandbox + +OpenCode peut exécuter les commandes bash, les commandes shell de session et le démarrage PTY dans un bac à sable (sandbox) sur macOS. +Le sandboxing est expérimental, optionnel et désactivé par défaut. + +```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"] + } + } +} +``` + +Options disponibles : + +| Option | Type | Description | +| ------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `boolean` | Activer le sandboxing pour les chemins d'exécution pris en charge sur macOS. | +| `preset` | `string` | Sélectionner un preset intégré (`default`, `strict`, `network`) ou un nom personnalisé. | +| `mode` | `"workspace-write" \| "read-only"` | Remplacer le mode du preset. | +| `network` | `boolean` | Remplacer l'autorisation d'accès réseau sortant. | +| `protected_roots` | `string[]` | Chemins relatifs au workspace qui restent protégés en écriture même à l'intérieur de racines accessibles en écriture. | +| `extra_read_roots` | `string[]` | Chemins absolus supplémentaires que le sandbox peut lire. | +| `extra_write_roots` | `string[]` | Chemins absolus supplémentaires que le sandbox peut écrire. | +| `extra_deny_paths` | `string[]` | Chemins absolus supplémentaires que le sandbox doit refuser. | +| `excluded_commands` | `string[]` | Préfixes de commandes à bloquer avant l'exécution. | +| `allow_unsandboxed_retry` | `boolean` | Autoriser une nouvelle tentative séparée avec permission `bash:unsandboxed` après un refus du sandbox. | +| `fail_if_unavailable` | `boolean` | Échec fatal lorsque le sandboxing est activé mais ne peut pas être mis en service. | +| `presets` | `Record` | Définir des presets personnalisés avec `mode`, `network`, racines et remplacements de permissions. | + +:::note +Les commandes sandboxées peuvent lire les racines système intégrées telles que `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` et `/private/etc`. +Les chemins sensibles du répertoire personnel tels que `~/.ssh`, `~/.gnupg` et les répertoires d'identifiants cloud restent refusés par défaut. +Consultez la [politique de sécurité](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) pour le modèle de menaces complet, les surfaces couvertes et les limitations actuelles. +::: + --- ## Variables diff --git a/packages/web/src/content/docs/fr/permissions.mdx b/packages/web/src/content/docs/fr/permissions.mdx index b1c1d6800f5c..f0063e645737 100644 --- a/packages/web/src/content/docs/fr/permissions.mdx +++ b/packages/web/src/content/docs/fr/permissions.mdx @@ -134,6 +134,7 @@ Les autorisations OpenCode sont classées par nom d'outil, plus quelques garde-f - `glob` — globalisation de fichiers (correspond au modèle global) - `grep` — recherche de contenu (correspond au modèle regex) - `bash` - exécution de commandes shell (correspond aux commandes analysées comme `git status --porcelain`) +- `bash:unsandboxed` — réexécuter une commande shell en dehors du sandbox après un refus ou après une demande explicite sans sandbox - `task` — lancement de sous-agents (correspond au type de sous-agent) - `skill` — chargement d'une compétence (correspond au nom de la compétence) - `lsp` — exécution de requêtes LSP (actuellement non granulaires) @@ -233,3 +234,11 @@ Only analyze code and suggest changes. :::tip Utilisez la correspondance de modèles pour les commandes avec des arguments. `"grep *"` autorise `grep pattern file.txt`, tandis que `"grep"` seul le bloquerait. Les commandes comme `git status` fonctionnent pour le comportement par défaut mais nécessitent une autorisation explicite (comme `"git status *"`) lorsque des arguments sont passés. ::: + +--- + +## Interaction avec le sandbox + +Lorsque le sandboxing macOS est activé, une commande bash bloquée peut déclencher une demande de permission `bash:unsandboxed` séparée. +Cela se produit lorsque OpenCode détecte un refus probable du sandbox, ou lorsque la commande demande explicitement de contourner le sandbox avec `# opencode:unsandboxed ` sur la première ligne non vide. +Configurez le sandbox dans [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/fr/tools.mdx b/packages/web/src/content/docs/fr/tools.mdx index 4f3f1804693e..1ba6b183e8da 100644 --- a/packages/web/src/content/docs/fr/tools.mdx +++ b/packages/web/src/content/docs/fr/tools.mdx @@ -60,6 +60,12 @@ Exécutez des commandes shell dans votre environnement de projet. Cet outil permet au LLM d'exécuter des commandes de terminal telles que `npm install`, `git status` ou toute autre commande shell. +:::note +Lorsque le sandboxing macOS est activé, bash s'exécute avec des restrictions de système de fichiers et peut demander une nouvelle tentative séparée sans sandbox si nécessaire. +Si vous savez qu'une commande doit démarrer en dehors du sandbox, mettez `# opencode:unsandboxed ` sur la première ligne non vide de la commande. +Consultez la [configuration du sandbox](/docs/config#sandbox) pour le comportement et les limites pris en charge. +::: + --- ### modifier diff --git a/packages/web/src/content/docs/it/config.mdx b/packages/web/src/content/docs/it/config.mdx index 05741e172ed4..7b29e5a2fdf3 100644 --- a/packages/web/src/content/docs/it/config.mdx +++ b/packages/web/src/content/docs/it/config.mdx @@ -624,6 +624,51 @@ La chiave `experimental` contiene opzioni in sviluppo attivo. Le opzioni sperimentali non sono stabili. Possono cambiare o essere rimosse senza preavviso. ::: +### Sandbox + +OpenCode puo eseguire comandi bash, comandi shell di sessione e l'avvio PTY in un sandbox su macOS. +Il sandboxing e sperimentale, opt-in e disabilitato per impostazione predefinita. + +```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"] + } + } +} +``` + +Opzioni disponibili: + +| Opzione | Tipo | Descrizione | +| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------ | +| `enabled` | `boolean` | Abilita il sandboxing per i percorsi di esecuzione supportati su macOS. | +| `preset` | `string` | Seleziona un preset integrato (`default`, `strict`, `network`) o un nome personalizzato. | +| `mode` | `"workspace-write" \| "read-only"` | Sovrascrive la modalita del preset. | +| `network` | `boolean` | Sovrascrive se l'accesso alla rete in uscita e consentito. | +| `protected_roots` | `string[]` | Percorsi relativi alla workspace che restano protetti in scrittura anche dentro root scrivibili. | +| `extra_read_roots` | `string[]` | Percorsi assoluti aggiuntivi che il sandbox puo leggere. | +| `extra_write_roots` | `string[]` | Percorsi assoluti aggiuntivi che il sandbox puo scrivere. | +| `extra_deny_paths` | `string[]` | Percorsi assoluti aggiuntivi che il sandbox deve negare. | +| `excluded_commands` | `string[]` | Prefissi di comandi che devono essere bloccati prima dell'esecuzione. | +| `allow_unsandboxed_retry` | `boolean` | Consenti un retry separato con permesso `bash:unsandboxed` dopo un rifiuto del sandbox. | +| `fail_if_unavailable` | `boolean` | Errore fatale quando il sandboxing e abilitato ma non puo essere attivato. | +| `presets` | `Record` | Definisci preset personalizzati con `mode`, `network`, root e override dei permessi. | + +:::note +I comandi in sandbox possono leggere le root di sistema integrate come `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` e `/private/etc`. +I percorsi sensibili della home come `~/.ssh`, `~/.gnupg` e le directory delle credenziali cloud restano negati per impostazione predefinita. +Consulta la [policy di sicurezza](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) per il modello di minaccia completo, le superfici coperte e le limitazioni attuali. +::: + --- ## Variabili diff --git a/packages/web/src/content/docs/it/permissions.mdx b/packages/web/src/content/docs/it/permissions.mdx index 49f0e8e4d38e..ccc039cb602f 100644 --- a/packages/web/src/content/docs/it/permissions.mdx +++ b/packages/web/src/content/docs/it/permissions.mdx @@ -134,6 +134,7 @@ I permessi di OpenCode sono indicizzati per nome dello strumento, piu' un paio d - `glob` — ricerca file tramite glob (corrisponde al pattern glob) - `grep` — ricerca nel contenuto (corrisponde al pattern regex) - `bash` — esecuzione comandi di shell (corrisponde a comandi parsati come `git status --porcelain`) +- `bash:unsandboxed` — rieseguire un comando di shell al di fuori del sandbox dopo un rifiuto o dopo una richiesta esplicita senza sandbox - `task` — avvio subagenti (corrisponde al tipo di subagente) - `skill` — caricamento di una skill (corrisponde al nome della skill) - `lsp` — esecuzione query LSP (attualmente non granulare) @@ -233,3 +234,11 @@ Only analyze code and suggest changes. :::tip Usa il pattern matching per comandi con argomenti. `"grep *"` consente `grep pattern file.txt`, mentre `"grep"` da solo lo bloccherebbe. Comandi come `git status` funzionano per il comportamento di default ma richiedono un permesso esplicito (come `"git status *"`) quando vengono passati argomenti. ::: + +--- + +## Interazione con il sandbox + +Quando il sandboxing macOS e abilitato, un comando bash bloccato puo attivare una richiesta di permesso `bash:unsandboxed` separata. +Questo accade quando OpenCode rileva un probabile rifiuto del sandbox, o quando il comando richiede esplicitamente di saltare il sandbox con `# opencode:unsandboxed ` sulla prima riga non vuota. +Configura il sandbox in [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/it/tools.mdx b/packages/web/src/content/docs/it/tools.mdx index c1e69f8beb4f..4864f36d843e 100644 --- a/packages/web/src/content/docs/it/tools.mdx +++ b/packages/web/src/content/docs/it/tools.mdx @@ -60,6 +60,12 @@ Esegui comandi di shell nel tuo ambiente di progetto. Questo strumento permette all'LLM di eseguire comandi da terminale come `npm install`, `git status` o qualunque altro comando di shell. +:::note +Quando il sandboxing macOS e abilitato, bash viene eseguito con restrizioni sul filesystem e puo richiedere un retry separato senza sandbox quando necessario. +Se sai che un comando deve avviarsi al di fuori del sandbox, metti `# opencode:unsandboxed ` sulla prima riga non vuota del comando. +Consulta la [configurazione del sandbox](/docs/config#sandbox) per il comportamento e i limiti supportati. +::: + --- ### edit diff --git a/packages/web/src/content/docs/ja/config.mdx b/packages/web/src/content/docs/ja/config.mdx index 20e29190dae0..14543b6fa1cb 100644 --- a/packages/web/src/content/docs/ja/config.mdx +++ b/packages/web/src/content/docs/ja/config.mdx @@ -623,6 +623,51 @@ OpenCode は起動時に新しいアップデートを自動的にダウンロ 実験的なオプションは安定していません。予告なく変更または削除される場合があります。 ::: +### サンドボックス + +OpenCode は macOS 上で bash コマンド、セッションシェルコマンド、PTY の起動をサンドボックス化できます。 +サンドボックスは実験的な機能であり、オプトインで、デフォルトでは無効になっています。 + +```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"] + } + } +} +``` + +利用可能なオプション: + +| オプション | 型 | 説明 | +| ------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------- | +| `enabled` | `boolean` | macOS でサポートされている実行パスのサンドボックスを有効にします。 | +| `preset` | `string` | 組み込みプリセット (`default`、`strict`、`network`) またはカスタムプリセット名を選択します。 | +| `mode` | `"workspace-write" \| "read-only"` | プリセットのモードをオーバーライドします。 | +| `network` | `boolean` | 送信ネットワークアクセスを許可するかどうかをオーバーライドします。 | +| `protected_roots` | `string[]` | 書き込み可能なルート内でも書き込み保護のままにするワークスペース相対パス。 | +| `extra_read_roots` | `string[]` | サンドボックスが読み取れる追加の絶対パス。 | +| `extra_write_roots` | `string[]` | サンドボックスが書き込める追加の絶対パス。 | +| `extra_deny_paths` | `string[]` | サンドボックスが拒否する追加の絶対パス。 | +| `excluded_commands` | `string[]` | 実行前にブロックする必要があるコマンドプレフィックス。 | +| `allow_unsandboxed_retry` | `boolean` | サンドボックス拒否後に、別の `bash:unsandboxed` 権限付きリトライを許可します。 | +| `fail_if_unavailable` | `boolean` | サンドボックスが有効だが起動できない場合にハードフェイルします。 | +| `presets` | `Record` | `mode`、`network`、ルート、権限オーバーライドを使用してカスタムプリセットを定義します。 | + +:::note +サンドボックス化されたコマンドは、`/bin`、`/usr`、`/opt/homebrew`、`/System`、`/Library`、`/dev`、`/tmp`、`/private/etc` などの組み込みシステムルートを読み取ることができます。 +`~/.ssh`、`~/.gnupg`、クラウド認証ディレクトリなどの機密性の高いホームパスは、デフォルトで拒否されたままです。 +完全な脅威モデル、カバーされる範囲、および現在の制限事項については、[セキュリティポリシー](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) を参照してください。 +::: + --- ## 変数 diff --git a/packages/web/src/content/docs/ja/permissions.mdx b/packages/web/src/content/docs/ja/permissions.mdx index f2b0978259a8..7d657e34146a 100644 --- a/packages/web/src/content/docs/ja/permissions.mdx +++ b/packages/web/src/content/docs/ja/permissions.mdx @@ -134,6 +134,7 @@ OpenCode の権限は、ツール名に加えて、いくつかの安全対策 - `glob` — ファイルのグロビング (グロブパターンと一致) - `grep` — コンテンツ検索 (正規表現パターンと一致) - `bash` — シェルコマンドの実行 (`git status --porcelain` などの解析されたコマンドと一致します) +- `bash:unsandboxed` — サンドボックス拒否後、または明示的なサンドボックス外リクエスト後にサンドボックス外でシェルコマンドを再実行します - `task` — サブエージェントの起動 (サブエージェントのタイプと一致) - `skill` — スキルをロードしています(スキル名と一致します) - `lsp` — LSP クエリの実行 (現在は非細分性) @@ -280,3 +281,11 @@ Only analyze code and suggest changes. :::tip 引数のあるコマンドにはパターン マッチングを使用します。 `"grep *"` は `grep pattern file.txt` を許可しますが、`"grep"` だけではブロックされます。 `git status` のようなコマンドはデフォルトの動作で機能しますが、引数を渡すときに明示的な許可 (`"git status *"` など) が必要です。 ::: + +--- + +## サンドボックスとの連携 + +macOS サンドボックスが有効になっている場合、ブロックされた bash コマンドは個別の `bash:unsandboxed` 権限リクエストをトリガーできます。 +これは、OpenCode がサンドボックス拒否の可能性を検出した場合、またはコマンドが最初の空でない行で `# opencode:unsandboxed <理由>` を使用して明示的にサンドボックスのスキップを要求した場合に発生します。 +サンドボックスの設定は [`experimental.sandbox`](/docs/config#sandbox) で行います。 diff --git a/packages/web/src/content/docs/ja/tools.mdx b/packages/web/src/content/docs/ja/tools.mdx index 394506393687..6e0d6bd7145a 100644 --- a/packages/web/src/content/docs/ja/tools.mdx +++ b/packages/web/src/content/docs/ja/tools.mdx @@ -60,6 +60,12 @@ OpenCode で利用可能なすべての組み込みツールを次に示しま このツールを使用すると、LLM は `npm install`、`git status`、またはその他のシェルコマンドなどのターミナルコマンドを実行できます。 +:::note +macOS サンドボックスが有効な場合、bash はファイルシステムの制限付きで実行され、必要に応じてサンドボックス外での個別のリトライを要求できます。 +コマンドがサンドボックスの外で開始する必要があることがわかっている場合は、コマンドの最初の空でない行に `# opencode:unsandboxed <理由>` を記述してください。 +サポートされる動作と制限については、[サンドボックス設定](/docs/config#sandbox) を参照してください。 +::: + --- ### edit diff --git a/packages/web/src/content/docs/ko/config.mdx b/packages/web/src/content/docs/ko/config.mdx index 2f08824d699c..2a2e98272db7 100644 --- a/packages/web/src/content/docs/ko/config.mdx +++ b/packages/web/src/content/docs/ko/config.mdx @@ -624,6 +624,51 @@ provider를 하나씩 비활성화하는 대신, OpenCode가 특정 provider만 experimental 옵션은 안정적이지 않습니다. 예고 없이 변경되거나 제거될 수 있습니다. ::: +### Sandbox + +OpenCode는 macOS에서 bash 명령, 세션 셸 명령, PTY 시작을 샌드박스할 수 있습니다. +샌드박싱은 실험적이며 opt-in 방식이고 기본적으로 비활성화되어 있습니다. + +```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"] + } + } +} +``` + +사용 가능한 옵션: + +| 옵션 | 타입 | 설명 | +| ------------------------- | ---------------------------------- | --------------------------------------------------------------------------------- | +| `enabled` | `boolean` | 지원되는 macOS 실행 경로에 대해 샌드박싱을 활성화합니다. | +| `preset` | `string` | 내장 프리셋(`default`, `strict`, `network`) 또는 커스텀 프리셋 이름을 선택합니다. | +| `mode` | `"workspace-write" \| "read-only"` | 프리셋 모드를 오버라이드합니다. | +| `network` | `boolean` | 아웃바운드 네트워크 접근 허용 여부를 오버라이드합니다. | +| `protected_roots` | `string[]` | 쓰기 가능한 루트 안에서도 쓰기 보호를 유지할 작업 공간 상대 경로입니다. | +| `extra_read_roots` | `string[]` | 샌드박스가 읽을 수 있는 추가 절대 경로입니다. | +| `extra_write_roots` | `string[]` | 샌드박스가 쓸 수 있는 추가 절대 경로입니다. | +| `extra_deny_paths` | `string[]` | 샌드박스가 거부해야 하는 추가 절대 경로입니다. | +| `excluded_commands` | `string[]` | 실행 전에 차단해야 하는 명령 접두사입니다. | +| `allow_unsandboxed_retry` | `boolean` | 샌드박스 거부 후 별도의 `bash:unsandboxed` 권한 기반 재시도를 허용합니다. | +| `fail_if_unavailable` | `boolean` | 샌드박싱이 활성화되었지만 작동할 수 없을 때 하드 실패합니다. | +| `presets` | `Record` | `mode`, `network`, 루트 및 권한 오버라이드로 커스텀 프리셋을 정의합니다. | + +:::note +샌드박스된 명령은 `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp`, `/private/etc` 같은 내장 시스템 루트를 읽을 수 있습니다. +`~/.ssh`, `~/.gnupg` 및 클라우드 자격 증명 디렉토리 같은 민감한 홈 경로는 기본적으로 거부됩니다. +전체 위협 모델, 적용 범위 및 현재 제한 사항은 [보안 정책](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md)을 참조하세요. +::: + --- ## Variables diff --git a/packages/web/src/content/docs/ko/permissions.mdx b/packages/web/src/content/docs/ko/permissions.mdx index 0742089d6b7b..94a3c7386214 100644 --- a/packages/web/src/content/docs/ko/permissions.mdx +++ b/packages/web/src/content/docs/ko/permissions.mdx @@ -134,6 +134,7 @@ opencode 권한은 도구 이름에 의해 키 입력되며, 두 개의 안전 - `glob` - 파일 globbing (glob 패턴 매칭) - `grep` - 콘텐츠 검색 ( regex 패턴 매칭) - `bash` - shell 명령 실행 (`git status --porcelain`와 같은 팟 명령) +- `bash:unsandboxed` — 샌드박스 거부 후 또는 명시적 비샌드박스 요청 후 셸 명령을 샌드박스 밖에서 다시 실행 - `task` - 에이전트 실행 (작업 에이전트 유형) - `skill` - 기술을 로딩 (기술 이름을 매칭) - `lsp` - LSP 쿼리 실행 (현재 비 과립) @@ -233,3 +234,11 @@ Only analyze code and suggest changes. :::tip 인자와 명령에 대한 패턴 매칭을 사용합니다. `"grep *"`는 `grep pattern file.txt`를 허용하고, `"grep"`는 혼자 그것을 막을 것입니다. `git status`와 같은 명령은 기본 동작을 위해 작동하지만, 인수가 전달될 때 명시된 권한 (`"git status *"`와 같은)이 필요합니다. ::: + +--- + +## 샌드박스 상호작용 + +macOS 샌드박싱이 활성화되면, 차단된 bash 명령은 별도의 `bash:unsandboxed` 권한 요청을 트리거할 수 있습니다. +이는 OpenCode가 샌드박스 거부를 감지하거나, 명령이 첫 번째 비어있지 않은 줄에 `# opencode:unsandboxed `을 넣어 명시적으로 샌드박스를 건너뛰도록 요청할 때 발생합니다. +샌드박스 자체는 [`experimental.sandbox`](/docs/config#sandbox)에서 설정하세요. diff --git a/packages/web/src/content/docs/ko/tools.mdx b/packages/web/src/content/docs/ko/tools.mdx index 49bea93cb2ea..f6823aedb382 100644 --- a/packages/web/src/content/docs/ko/tools.mdx +++ b/packages/web/src/content/docs/ko/tools.mdx @@ -60,6 +60,12 @@ description: LLM이 사용할 수 있는 도구를 관리합니다. 이 도구는 `npm install`, `git status` 또는 다른 shell 명령과 같은 terminal 명령을 실행하는 LLM을 허용합니다. +:::note +macOS 샌드박싱이 활성화되면 bash는 파일 시스템 제한이 적용된 상태로 실행되며, 필요한 경우 별도의 비샌드박스 재시도를 요청할 수 있습니다. +명령이 샌드박스 밖에서 시작되어야 하는 경우, 명령의 첫 번째 비어있지 않은 줄에 `# opencode:unsandboxed `을 넣으세요. +지원되는 동작과 제한 사항은 [sandbox config](/docs/config#sandbox)를 참조하세요. +::: + --- ### edit diff --git a/packages/web/src/content/docs/nb/config.mdx b/packages/web/src/content/docs/nb/config.mdx index e8b32d5a0676..cac360f66d13 100644 --- a/packages/web/src/content/docs/nb/config.mdx +++ b/packages/web/src/content/docs/nb/config.mdx @@ -627,6 +627,51 @@ Hvis en leverandør vises i både `enabled_providers` og `disabled_providers`, h Eksperimentelle alternativer er ikke stabile. De kan endres eller fjernes uten varsel. ::: +### Sandbox + +OpenCode kan sandkasse bash-kommandoer, øktskallkommandoer og PTY-oppstart på macOS. +Sandkassing er eksperimentelt, opt-in og deaktivert som standard. + +```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"] + } + } +} +``` + +Tilgjengelige alternativer: + +| Alternativ | Type | Beskrivelse | +| ------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------- | +| `enabled` | `boolean` | Aktiver sandkassing for de støttede macOS-kjørebanene. | +| `preset` | `string` | Velg et innebygd forhåndsvalg (`default`, `strict`, `network`) eller et egendefinert navn. | +| `mode` | `"workspace-write" \| "read-only"` | Overstyr forhåndsvalgmodusen. | +| `network` | `boolean` | Overstyr om utgående nettverkstilgang er tillatt. | +| `protected_roots` | `string[]` | Arbeidsområde-relative stier som forblir skrivebeskyttet selv inne i skrivbare røtter. | +| `extra_read_roots` | `string[]` | Ekstra absolutte stier sandkassen kan lese. | +| `extra_write_roots` | `string[]` | Ekstra absolutte stier sandkassen kan skrive. | +| `extra_deny_paths` | `string[]` | Ekstra absolutte stier sandkassen må nekte. | +| `excluded_commands` | `string[]` | Kommandoprefikser som må blokkeres før kjøring. | +| `allow_unsandboxed_retry` | `boolean` | Tillat et separat `bash:unsandboxed`-tillatelsesbeskyttet nytt forsøk etter en sandkasseavvisning. | +| `fail_if_unavailable` | `boolean` | Hard-feil når sandkassing er aktivert men ikke kan aktiveres. | +| `presets` | `Record` | Definer egendefinerte forhåndsvalg med `mode`, `network`, røtter og tillatelsesoverstyrelser. | + +:::note +Sandkassede kommandoer kan lese innebygde systemrøtter som `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` og `/private/etc`. +Sensitive hjemmestier som `~/.ssh`, `~/.gnupg` og skylegitimjonskatalogene forblir nektet som standard. +Se [sikkerhetspolicyen](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) for den fullstendige trusselmodellen, dekkede overflater og nåværende begrensninger. +::: + --- ## Variabler diff --git a/packages/web/src/content/docs/nb/permissions.mdx b/packages/web/src/content/docs/nb/permissions.mdx index 5c63b251e35d..76f80e2e95d0 100644 --- a/packages/web/src/content/docs/nb/permissions.mdx +++ b/packages/web/src/content/docs/nb/permissions.mdx @@ -134,6 +134,7 @@ OpenCode-tillatelser tastes inn etter verktøynavn, pluss et par sikkerhetsvakte - `glob` — fil-globing (tilsvarer glob-mønsteret) - `grep` — innholdssøk (samsvarer med regex-mønsteret) - `bash` — kjører skallkommandoer (matcher analyserte kommandoer som `git status --porcelain`) +- `bash:unsandboxed` — kjører en skallkommando på nytt utenfor sandkassen etter avvisning eller etter en eksplisitt forespørsel uten sandkasse - `task` — start av subagenter (tilsvarer subagenttypen) - `skill` — laster en ferdighet (tilsvarer navnet på ferdigheten) - `lsp` — kjører LSP-spørringer (for øyeblikket ikke-granulære) @@ -233,3 +234,11 @@ Only analyze code and suggest changes. :::tip Bruk mønstertilpasning for kommandoer med argumenter. `"grep *"` tillater `grep pattern file.txt`, mens `"grep"` alene ville blokkert den. Kommandoer som `git status` fungerer for standard oppførsel, men krever eksplisitt tillatelse (som `"git status *"`) når argumenter sendes. ::: + +--- + +## Sandkasse-interaksjon + +Når macOS-sandkassing er aktivert, kan en blokkert bash-kommando utløse en separat `bash:unsandboxed`-tillatelsesforespørsel. +Dette skjer når OpenCode oppdager en sannsynlig sandkasseavvisning, eller når kommandoen eksplisitt ber om å hoppe over sandkassen med `# opencode:unsandboxed ` på den første ikke-tomme linjen. +Konfigurer sandkassen selv i [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/nb/tools.mdx b/packages/web/src/content/docs/nb/tools.mdx index 8c871f11c9ad..443a7e36405b 100644 --- a/packages/web/src/content/docs/nb/tools.mdx +++ b/packages/web/src/content/docs/nb/tools.mdx @@ -60,6 +60,12 @@ Utfør skallkommandoer i prosjektmiljøet ditt. Dette verktøyet lar LLM kjøre terminalkommandoer som `npm install`, `git status` eller en hvilken som helst annen skallkommando. +:::note +Når macOS-sandkassing er aktivert, kjører bash med filsystembegrensninger og kan be om et separat nytt forsøk uten sandkasse ved behov. +Hvis du vet at en kommando må starte utenfor sandkassen, legg `# opencode:unsandboxed ` på den første ikke-tomme linjen i kommandoen. +Se [sandbox-konfigurasjon](/docs/config#sandbox) for støttet oppførsel og begrensninger. +::: + --- ### edit diff --git a/packages/web/src/content/docs/pl/config.mdx b/packages/web/src/content/docs/pl/config.mdx index a6a6fb156d74..72dc8bd07913 100644 --- a/packages/web/src/content/docs/pl/config.mdx +++ b/packages/web/src/content/docs/pl/config.mdx @@ -619,6 +619,51 @@ Klucz `experimental` zawiera opcje, które są we wczesnej fazie rozwoju. Opcje eksperymentalne nie są stabilne. Mogą ulec zmianie lub zostać usunięte bez ostrzeżenia. ::: +### Sandbox + +OpenCode może sandboxować polecenia bash, polecenia powłoki sesji i uruchamianie PTY na macOS. +Sandboxowanie jest eksperymentalne, wymaga włączenia i jest domyślnie wyłączone. + +```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"] + } + } +} +``` + +Dostępne opcje: + +| Opcja | Typ | Opis | +| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `enabled` | `boolean` | Włącz sandboxowanie dla obsługiwanych ścieżek wykonawczych macOS. | +| `preset` | `string` | Wybierz wbudowany preset (`default`, `strict`, `network`) lub nazwę niestandardowego. | +| `mode` | `"workspace-write" \| "read-only"` | Nadpisz tryb presetu. | +| `network` | `boolean` | Nadpisz, czy dozwolony jest wychodzący dostęp do sieci. | +| `protected_roots` | `string[]` | Ścieżki względne do workspace, które pozostają chronione przed zapisem nawet wewnątrz zapisywalnych korzeni. | +| `extra_read_roots` | `string[]` | Dodatkowe ścieżki absolutne, które sandbox może odczytywać. | +| `extra_write_roots` | `string[]` | Dodatkowe ścieżki absolutne, do których sandbox może zapisywać. | +| `extra_deny_paths` | `string[]` | Dodatkowe ścieżki absolutne, które sandbox musi odrzucać. | +| `excluded_commands` | `string[]` | Prefiksy poleceń, które muszą być zablokowane przed wykonaniem. | +| `allow_unsandboxed_retry` | `boolean` | Zezwól na osobne ponowienie z uprawnieniem `bash:unsandboxed` po odrzuceniu przez sandbox. | +| `fail_if_unavailable` | `boolean` | Twardy błąd, gdy sandboxowanie jest włączone, ale nie może być aktywowane. | +| `presets` | `Record` | Zdefiniuj niestandardowe presety z `mode`, `network`, korzeniami i nadpisaniami uprawnień. | + +:::note +Polecenia w sandboxie mogą odczytywać wbudowane korzenie systemowe, takie jak `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` i `/private/etc`. +Wrażliwe ścieżki domowe, takie jak `~/.ssh`, `~/.gnupg` i katalogi poświadczeń chmury, pozostają domyślnie odrzucone. +Zobacz [politykę bezpieczeństwa](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md), aby poznać pełny model zagrożeń, pokryte powierzchnie i aktualne ograniczenia. +::: + --- ## Zmienne diff --git a/packages/web/src/content/docs/pl/permissions.mdx b/packages/web/src/content/docs/pl/permissions.mdx index a5c05b6dc634..ddefea99ef41 100644 --- a/packages/web/src/content/docs/pl/permissions.mdx +++ b/packages/web/src/content/docs/pl/permissions.mdx @@ -134,6 +134,7 @@ Uprawnienia opencode są określane na podstawie nazwy narzędzia i kilku zabezp - `glob` — maglowanie plików (pasuje do wzorców globowania) - `grep` — wyszukiwanie treści (pasuje do wzorca regularnego) - `bash` — uruchamianie poleceń shell (pasuje do poleceń przeanalizowanych, takich jak `git status --porcelain`) +- `bash:unsandboxed` — ponowne uruchomienie polecenia shell poza sandboxem po odrzuceniu lub po jawnym żądaniu bez sandboxa - `task` — uruchamianie podagentów (odpowiada typowi podagenta) - `skill` — ładowanie umiejętności (pasuje do nazwy umiejętności) - `lsp` — uruchamianie zapytań LSP (obecnie nieszczegółowych) @@ -233,3 +234,11 @@ Only analyze code and suggest changes. :::tip zastosowanie dopasowywania wzorców dla pierwotnych z argumentami. `"grep *"` pozwala na `grep pattern file.txt`, podczas gdy sam `"grep"` blokuje to. Polecenia takie jak `git status` w przypadku postępowania dyscyplinarnego, ale ostatecznego zastosowania (np. `"git status *"`) podczas stosowania argumentów. ::: + +--- + +## Interakcja z sandboxem + +Gdy sandboxowanie macOS jest włączone, zablokowane polecenie bash może wywołać osobne żądanie uprawnienia `bash:unsandboxed`. +Dzieje się tak, gdy OpenCode wykryje prawdopodobne odrzucenie przez sandbox lub gdy polecenie jawnie prosi o pominięcie sandboxa za pomocą `# opencode:unsandboxed ` w pierwszej niepustej linii. +Skonfiguruj sam sandbox w [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/pl/tools.mdx b/packages/web/src/content/docs/pl/tools.mdx index 180e043cd567..4cac3f9037d9 100644 --- a/packages/web/src/content/docs/pl/tools.mdx +++ b/packages/web/src/content/docs/pl/tools.mdx @@ -60,6 +60,12 @@ Wykonuj polecenia powłoki (shell) w środowisku projektu. To narzędzie umożliwia LLM uruchamianie poleceń terminalowych, takich jak `npm install`, `git status` lub dowolne inne polecenie powłoki. +:::note +Gdy sandboxowanie macOS jest włączone, bash działa z ograniczeniami systemu plików i może żądać osobnego ponowienia bez sandboxa w razie potrzeby. +Jeśli wiesz, że polecenie musi być uruchomione poza sandboxem, umieść `# opencode:unsandboxed ` w pierwszej niepustej linii polecenia. +Zobacz [konfigurację sandboxa](/docs/config#sandbox), aby poznać obsługiwane zachowania i ograniczenia. +::: + --- ### edit diff --git a/packages/web/src/content/docs/pt-br/config.mdx b/packages/web/src/content/docs/pt-br/config.mdx index 4684bb199ecf..9c21fcc98acc 100644 --- a/packages/web/src/content/docs/pt-br/config.mdx +++ b/packages/web/src/content/docs/pt-br/config.mdx @@ -625,6 +625,51 @@ A chave `experimental` contém opções que estão em desenvolvimento ativo. Opções experimentais não são estáveis. Elas podem mudar ou ser removidas sem aviso prévio. ::: +### Sandbox + +O opencode pode sandboxar comandos bash, comandos de shell de sessão e inicialização de PTY no macOS. +O sandboxing é experimental, opt-in e desabilitado por padrão. + +```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"] + } + } +} +``` + +Opções disponíveis: + +| Opção | Tipo | Descrição | +| ------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `enabled` | `boolean` | Habilitar sandboxing para os caminhos de execução suportados no macOS. | +| `preset` | `string` | Selecionar um preset embutido (`default`, `strict`, `network`) ou um nome de preset personalizado. | +| `mode` | `"workspace-write" \| "read-only"` | Substituir o modo do preset. | +| `network` | `boolean` | Substituir se o acesso de rede de saída é permitido. | +| `protected_roots` | `string[]` | Caminhos relativos ao workspace que permanecem protegidos contra escrita mesmo dentro de raízes graváveis. | +| `extra_read_roots` | `string[]` | Caminhos absolutos adicionais que o sandbox pode ler. | +| `extra_write_roots` | `string[]` | Caminhos absolutos adicionais que o sandbox pode escrever. | +| `extra_deny_paths` | `string[]` | Caminhos absolutos adicionais que o sandbox deve negar. | +| `excluded_commands` | `string[]` | Prefixos de comandos que devem ser bloqueados antes da execução. | +| `allow_unsandboxed_retry` | `boolean` | Permitir uma nova tentativa separada com permissão `bash:unsandboxed` após uma negação do sandbox. | +| `fail_if_unavailable` | `boolean` | Falha severa quando o sandboxing está habilitado mas não pode ser ativado. | +| `presets` | `Record` | Definir presets personalizados com `mode`, `network`, raízes e substituições de permissões. | + +:::note +Comandos em sandbox podem ler raízes de sistema embutidas como `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` e `/private/etc`. +Caminhos sensíveis do home como `~/.ssh`, `~/.gnupg` e diretórios de credenciais de nuvem permanecem negados por padrão. +Veja a [política de segurança](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) para o modelo de ameaças completo, superfícies cobertas e limitações atuais. +::: + --- ## Variáveis diff --git a/packages/web/src/content/docs/pt-br/permissions.mdx b/packages/web/src/content/docs/pt-br/permissions.mdx index 4facc9f72b8f..8a210c033ca3 100644 --- a/packages/web/src/content/docs/pt-br/permissions.mdx +++ b/packages/web/src/content/docs/pt-br/permissions.mdx @@ -134,6 +134,7 @@ As permissões do opencode são indexadas pelo nome da ferramenta, além de algu - `glob` — globbing de arquivos (corresponde ao padrão glob) - `grep` — busca de conteúdo (corresponde ao padrão regex) - `bash` — execução de comandos de shell (corresponde a comandos analisados como `git status --porcelain`) +- `bash:unsandboxed` — reexecução de um comando de shell fora do sandbox após negação ou após uma solicitação explícita sem sandbox - `task` — lançamento de subagentes (corresponde ao tipo de subagente) - `skill` — carregamento de uma habilidade (corresponde ao nome da habilidade) - `lsp` — execução de consultas LSP (atualmente não granular) @@ -233,3 +234,11 @@ Only analyze code and suggest changes. :::tip Use correspondência de padrões para comandos com argumentos. `"grep *"` permite `grep pattern file.txt`, enquanto `"grep"` sozinho o bloquearia. Comandos como `git status` funcionam para o comportamento padrão, mas requerem permissão explícita (como `"git status *"`) quando argumentos são passados. ::: + +--- + +## Interação com o Sandbox + +Quando o sandboxing do macOS está habilitado, um comando bash bloqueado pode acionar uma solicitação de permissão `bash:unsandboxed` separada. +Isso acontece quando o opencode detecta uma provável negação do sandbox, ou quando o comando solicita explicitamente pular o sandbox com `# opencode:unsandboxed ` na primeira linha não vazia. +Configure o sandbox em [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/pt-br/tools.mdx b/packages/web/src/content/docs/pt-br/tools.mdx index 4c7b37197179..9abbe4eba9d6 100644 --- a/packages/web/src/content/docs/pt-br/tools.mdx +++ b/packages/web/src/content/docs/pt-br/tools.mdx @@ -60,6 +60,12 @@ Execute comandos de shell no ambiente do seu projeto. Esta ferramenta permite que o LLM execute comandos de terminal como `npm install`, `git status` ou qualquer outro comando de shell. +:::note +Quando o sandboxing do macOS está habilitado, o bash é executado com restrições de sistema de arquivos e pode solicitar uma nova tentativa separada sem sandbox quando necessário. +Se você sabe que um comando precisa iniciar fora do sandbox, coloque `# opencode:unsandboxed ` na primeira linha não vazia do comando. +Veja [configuração do sandbox](/docs/config#sandbox) para o comportamento suportado e limites. +::: + --- ### edit diff --git a/packages/web/src/content/docs/ru/config.mdx b/packages/web/src/content/docs/ru/config.mdx index 5d91dc5e01b6..f8b69b537739 100644 --- a/packages/web/src/content/docs/ru/config.mdx +++ b/packages/web/src/content/docs/ru/config.mdx @@ -624,6 +624,51 @@ opencode автоматически загрузит все новые обно Экспериментальные варианты не стабильны. Они могут быть изменены или удалены без предварительного уведомления. ::: +### Sandbox + +OpenCode может изолировать команды bash, команды оболочки сеанса и запуск PTY в песочнице на macOS. +Песочница экспериментальна, включается явно и по умолчанию отключена. + +```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"] + } + } +} +``` + +Доступные параметры: + +| Параметр | Тип | Описание | +| ------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `enabled` | `boolean` | Включить песочницу для поддерживаемых путей выполнения macOS. | +| `preset` | `string` | Выбрать встроенный пресет (`default`, `strict`, `network`) или пользовательский. | +| `mode` | `"workspace-write" \| "read-only"` | Переопределить режим пресета. | +| `network` | `boolean` | Переопределить разрешение исходящего сетевого доступа. | +| `protected_roots` | `string[]` | Пути относительно рабочей области, защищённые от записи даже внутри записываемых корней. | +| `extra_read_roots` | `string[]` | Дополнительные абсолютные пути для чтения из песочницы. | +| `extra_write_roots` | `string[]` | Дополнительные абсолютные пути для записи из песочницы. | +| `extra_deny_paths` | `string[]` | Дополнительные абсолютные пути, которые песочница должна блокировать. | +| `excluded_commands` | `string[]` | Префиксы команд, которые должны быть заблокированы перед выполнением. | +| `allow_unsandboxed_retry` | `boolean` | Разрешить отдельную попытку `bash:unsandboxed` с запросом разрешения после отказа песочницы. | +| `fail_if_unavailable` | `boolean` | Жёсткий отказ, если песочница включена, но не может быть активирована. | +| `presets` | `Record` | Определить пользовательские пресеты с `mode`, `network`, корневыми путями и переопределениями разрешений. | + +:::note +Команды в песочнице могут читать встроенные системные пути, такие как `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` и `/private/etc`. +Чувствительные домашние пути, такие как `~/.ssh`, `~/.gnupg` и каталоги облачных учётных данных, по умолчанию запрещены. +См. [политику безопасности](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) для полной модели угроз, покрытых поверхностей и текущих ограничений. +::: + --- ## Переменные diff --git a/packages/web/src/content/docs/ru/permissions.mdx b/packages/web/src/content/docs/ru/permissions.mdx index 961a0682435d..6305a2b845be 100644 --- a/packages/web/src/content/docs/ru/permissions.mdx +++ b/packages/web/src/content/docs/ru/permissions.mdx @@ -134,6 +134,7 @@ opencode использует конфигурацию `permission`, чтобы - `glob` — подстановка файла (соответствует шаблону подстановки) - `grep` — поиск по контенту (соответствует шаблону регулярного выражения) - `bash` — запуск shell-команд (соответствует проанализированным командам, например `git status --porcelain`) +- `bash:unsandboxed` — повторный запуск shell-команды вне песочницы после отказа или явного запроса без песочницы - `task` — запуск субагентов (соответствует типу субагента) - `skill` — загрузка навыка (соответствует названию навыка) - `lsp` — выполнение запросов LSP (в настоящее время не детализированных) @@ -233,3 +234,11 @@ Only analyze code and suggest changes. :::tip Используйте сопоставление с образцом для команд с аргументами. `"grep *"` разрешает `grep pattern file.txt`, а сам `"grep"` блокирует его. Такие команды, как `git status`, работают по умолчанию, но требуют явного разрешения (например, `"git status *"`) при передаче аргументов. ::: + +--- + +## Взаимодействие с песочницей + +Когда песочница macOS включена, заблокированная команда bash может вызвать отдельный запрос разрешения `bash:unsandboxed`. +Это происходит, когда OpenCode обнаруживает вероятный отказ песочницы, или когда команда явно запрашивает пропуск песочницы с помощью `# opencode:unsandboxed <причина>` в первой непустой строке. +Настройте саму песочницу в [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/ru/tools.mdx b/packages/web/src/content/docs/ru/tools.mdx index 35958e036c1b..d42625019dee 100644 --- a/packages/web/src/content/docs/ru/tools.mdx +++ b/packages/web/src/content/docs/ru/tools.mdx @@ -60,6 +60,12 @@ description: Управляйте инструментами, которые м Этот инструмент позволяет LLM запускать команды терминала, такие как `npm install`, `git status` или любую другую shell-команду. +:::note +Когда песочница macOS включена, bash запускается с ограничениями файловой системы и может запросить отдельную попытку без песочницы при необходимости. +Если вы знаете, что команда должна запускаться вне песочницы, добавьте `# opencode:unsandboxed <причина>` в первую непустую строку команды. +См. [настройку песочницы](/docs/config#sandbox) для поддерживаемого поведения и ограничений. +::: + --- ### edit diff --git a/packages/web/src/content/docs/th/config.mdx b/packages/web/src/content/docs/th/config.mdx index c58469c77ab0..a912b7e02d04 100644 --- a/packages/web/src/content/docs/th/config.mdx +++ b/packages/web/src/content/docs/th/config.mdx @@ -629,6 +629,51 @@ OpenCode จะดาวน์โหลดการอัปเดตใหม ตัวเลือกการทดลองไม่เสถียร อาจมีการเปลี่ยนแปลงหรือลบออกโดยไม่ต้องแจ้งให้ทราบล่วงหน้า ::: +### Sandbox + +OpenCode สามารถ sandbox คำสั่ง bash, คำสั่ง shell ของเซสชัน และการเริ่มต้น PTY บน macOS +Sandboxing เป็นฟีเจอร์ทดลอง ต้องเปิดใช้งานเอง และถูกปิดใช้งานตามค่าเริ่มต้น + +```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"] + } + } +} +``` + +ตัวเลือกที่มี: + +| ตัวเลือก | ประเภท | คำอธิบาย | +| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------- | +| `enabled` | `boolean` | เปิดใช้งาน sandboxing สำหรับเส้นทางการทำงาน macOS ที่รองรับ | +| `preset` | `string` | เลือกพรีเซ็ตในตัว (`default`, `strict`, `network`) หรือชื่อพรีเซ็ตที่กำหนดเอง | +| `mode` | `"workspace-write" \| "read-only"` | แทนที่โหมดพรีเซ็ต | +| `network` | `boolean` | แทนที่ว่าอนุญาตให้เข้าถึงเครือข่ายขาออกหรือไม่ | +| `protected_roots` | `string[]` | เส้นทางที่สัมพันธ์กับ workspace ที่ยังคงป้องกันการเขียนแม้จะอยู่ภายในรูทที่เขียนได้ | +| `extra_read_roots` | `string[]` | เส้นทางสัมบูรณ์เพิ่มเติมที่ sandbox สามารถอ่านได้ | +| `extra_write_roots` | `string[]` | เส้นทางสัมบูรณ์เพิ่มเติมที่ sandbox สามารถเขียนได้ | +| `extra_deny_paths` | `string[]` | เส้นทางสัมบูรณ์เพิ่มเติมที่ sandbox ต้องปฏิเสธ | +| `excluded_commands` | `string[]` | คำนำหน้าคำสั่งที่ต้องถูกบล็อกก่อนดำเนินการ | +| `allow_unsandboxed_retry` | `boolean` | อนุญาตให้ลองใหม่แบบ `bash:unsandboxed` แยกต่างหากที่มีการควบคุมสิทธิ์หลังจาก sandbox ปฏิเสธ | +| `fail_if_unavailable` | `boolean` | ล้มเหลวทันทีเมื่อเปิดใช้งาน sandboxing แต่ไม่สามารถเปิดใช้งานได้ | +| `presets` | `Record` | กำหนดพรีเซ็ตที่กำหนดเองด้วย `mode`, `network`, รูท และการแทนที่สิทธิ์ | + +:::note +คำสั่งใน sandbox สามารถอ่านรูทระบบในตัว เช่น `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` และ `/private/etc` +เส้นทางบ้านที่ละเอียดอ่อน เช่น `~/.ssh`, `~/.gnupg` และไดเรกทอรีข้อมูลรับรองคลาวด์ จะถูกปฏิเสธตามค่าเริ่มต้น +ดู [นโยบายความปลอดภัย](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) สำหรับรูปแบบภัยคุกคามฉบับเต็ม พื้นผิวที่ครอบคลุม และข้อจำกัดปัจจุบัน +::: + --- ## ตัวแปร diff --git a/packages/web/src/content/docs/th/permissions.mdx b/packages/web/src/content/docs/th/permissions.mdx index 5fed616159a4..262dddd2c137 100644 --- a/packages/web/src/content/docs/th/permissions.mdx +++ b/packages/web/src/content/docs/th/permissions.mdx @@ -134,6 +134,7 @@ OpenCode ใช้การกำหนดค่า `permission` เพื่อ - `glob` — ไฟล์ globbing (ตรงกับรูปแบบ glob) - `grep` — การค้นหาเนื้อหา (ตรงกับรูปแบบ regex) - `bash` — การรันคำสั่ง shell (ตรงกับคำสั่งที่แยกวิเคราะห์เช่น `git status --porcelain`) +- `bash:unsandboxed` — รันคำสั่ง shell ซ้ำนอก sandbox หลังจากถูกปฏิเสธหรือหลังจากมีคำขอ unsandboxed อย่างชัดเจน - `task` — การเปิดตัวตัวแทนย่อย (ตรงกับประเภทตัวแทนย่อย) - `skill` — กำลังโหลดทักษะ (ตรงกับชื่อทักษะ) - `lsp` — กำลังเรียกใช้คำสั่ง LSP (ปัจจุบันยังไม่ละเอียด) @@ -233,3 +234,11 @@ Only analyze code and suggest changes. :::tip ใช้การจับคู่รูปแบบสำหรับคำสั่งที่มีอาร์กิวเมนต์ `"grep *"` อนุญาต `grep pattern file.txt` ในขณะที่ `"grep"` คนเดียวจะบล็อกได้ คำสั่งเช่น `git status` ใช้งานได้กับพฤติกรรมเริ่มต้น แต่ต้องได้รับอนุญาตอย่างชัดเจน (เช่น `"git status *"`) เมื่ออาร์กิวเมนต์ถูกส่งผ่าน ::: + +--- + +## การโต้ตอบกับ Sandbox + +เมื่อเปิดใช้งาน sandbox ของ macOS คำสั่ง bash ที่ถูกบล็อกสามารถเรียกคำขอสิทธิ์ `bash:unsandboxed` แยกต่างหากได้ +สิ่งนี้เกิดขึ้นเมื่อ OpenCode ตรวจพบการปฏิเสธจาก sandbox ที่น่าจะเป็นไปได้ หรือเมื่อคำสั่งร้องขออย่างชัดเจนให้ข้าม sandbox ด้วย `# opencode:unsandboxed <เหตุผล>` ในบรรทัดแรกที่ไม่ว่าง +กำหนดค่า sandbox ใน [`experimental.sandbox`](/docs/config#sandbox) diff --git a/packages/web/src/content/docs/th/tools.mdx b/packages/web/src/content/docs/th/tools.mdx index 0ead638461af..dc07e299264e 100644 --- a/packages/web/src/content/docs/th/tools.mdx +++ b/packages/web/src/content/docs/th/tools.mdx @@ -60,6 +60,12 @@ description: จัดการเครื่องมือที่ LLM ส เครื่องมือนี้อนุญาตให้ LLM รันคำสั่ง terminal เช่น `npm install`, `git status` หรือคำสั่ง shell อื่น ๆ +:::note +เมื่อเปิดใช้งาน sandbox ของ macOS bash จะทำงานโดยมีข้อจำกัดของระบบไฟล์และสามารถร้องขอการลองใหม่แบบ unsandboxed แยกต่างหากเมื่อจำเป็น +หากคุณทราบว่าคำสั่งต้องเริ่มนอก sandbox ให้ใส่ `# opencode:unsandboxed <เหตุผล>` ในบรรทัดแรกที่ไม่ว่างของคำสั่ง +ดู [การกำหนดค่า sandbox](/docs/config#sandbox) สำหรับพฤติกรรมและข้อจำกัดที่รองรับ +::: + --- ### edit diff --git a/packages/web/src/content/docs/tr/config.mdx b/packages/web/src/content/docs/tr/config.mdx index 8a769ba69081..45c97161bdf7 100644 --- a/packages/web/src/content/docs/tr/config.mdx +++ b/packages/web/src/content/docs/tr/config.mdx @@ -626,6 +626,51 @@ Bir sağlayıcı hem `enabled_providers` hem de `disabled_providers`'de görün Deneysel seçenekler kararlı değildir. Bildirim yapılmaksızın değişebilir veya kaldırılabilirler. ::: +### Sandbox + +OpenCode, macOS üzerinde bash komutlarını, oturum kabuk komutlarını ve PTY başlangıcını sandbox'layabilir. +Sandbox deneyseldir, isteğe bağlıdır ve varsayılan olarak devre dışıdır. + +```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"] + } + } +} +``` + +Mevcut seçenekler: + +| Seçenek | Tür | Açıklama | +| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------- | +| `enabled` | `boolean` | Desteklenen macOS yürütme yolları için sandbox'ı etkinleştirin. | +| `preset` | `string` | Yerleşik bir ön ayar (`default`, `strict`, `network`) veya özel ön ayar adı seçin. | +| `mode` | `"workspace-write" \| "read-only"` | Ön ayar modunu geçersiz kılın. | +| `network` | `boolean` | Giden ağ erişimine izin verilip verilmeyeceğini geçersiz kılın. | +| `protected_roots` | `string[]` | Yazılabilir kökler içinde bile yazma korumalı kalan çalışma alanına göreli yollar. | +| `extra_read_roots` | `string[]` | Sandbox'ın okuyabileceği ek mutlak yollar. | +| `extra_write_roots` | `string[]` | Sandbox'ın yazabileceği ek mutlak yollar. | +| `extra_deny_paths` | `string[]` | Sandbox'ın reddetmesi gereken ek mutlak yollar. | +| `excluded_commands` | `string[]` | Yürütme öncesinde engellenmesi gereken komut önekleri. | +| `allow_unsandboxed_retry` | `boolean` | Sandbox reddinden sonra ayrı bir izin denetimli `bash:unsandboxed` yeniden denemesine izin verin. | +| `fail_if_unavailable` | `boolean` | Sandbox etkinleştirildiğinde ancak aktif edilemediğinde kesin hata verin. | +| `presets` | `Record` | `mode`, `network`, kökler ve izin geçersiz kılmalarıyla özel ön ayarlar tanımlayın. | + +:::note +Sandbox'lı komutlar `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` ve `/private/etc` gibi yerleşik sistem köklerini okuyabilir. +`~/.ssh`, `~/.gnupg` gibi hassas ev dizini yolları ve bulut kimlik bilgileri dizinleri varsayılan olarak reddedilir. +Tam tehdit modeli, kapsanan yüzeyler ve mevcut sınırlamalar için [güvenlik politikasına](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) bakın. +::: + --- ## Değişkenler diff --git a/packages/web/src/content/docs/tr/permissions.mdx b/packages/web/src/content/docs/tr/permissions.mdx index 976ee0a7ffb8..e02fb4fc31ab 100644 --- a/packages/web/src/content/docs/tr/permissions.mdx +++ b/packages/web/src/content/docs/tr/permissions.mdx @@ -134,6 +134,7 @@ opencode izinleri araç adına ve birkaç güvenlik önlemine göre anahtarlanı - `glob` — dosya genellemesi (glob düzeniyle eşleşir) - `grep` — içerik arama (regex modeliyle eşleşir) - `bash` — kabuk komutlarını çalıştırma (`git status --porcelain` gibi ayrıştırılmış komutlarla eşleşir) +- `bash:unsandboxed` — sandbox reddinden sonra veya açık bir sandbox dışı istekten sonra bir kabuk komutunu sandbox dışında yeniden çalıştırma - `task` — alt agent'ların başlatılması (alt agent türüyle eşleşir) - `skill` — bir skill yükleniyor (skill adıyla eşleşir) - `lsp` — LSP sorgularını çalıştırıyor (şu anda ayrıntılı değil) @@ -233,3 +234,11 @@ Only analyze code and suggest changes. :::tip Bağımsız değişken içeren komutlar için kalıp eşleştirmeyi kullanın. `"grep *"`, `grep pattern file.txt`'ye izin verir, ancak `"grep"` tek başına onu engeller. `git status` gibi komutlar varsayılan davranış için çalışır ancak argümanlar aktarıldığında açık izin (`"git status *"` gibi) gerektirir. ::: + +--- + +## Sandbox Etkileşimi + +macOS sandbox'ı etkinleştirildiğinde, engellenen bir bash komutu ayrı bir `bash:unsandboxed` izin isteği tetikleyebilir. +Bu, OpenCode muhtemel bir sandbox reddini tespit ettiğinde veya komut ilk boş olmayan satırda `# opencode:unsandboxed ` ile açıkça sandbox'ı atlamayı istediğinde gerçekleşir. +Sandbox'ın kendisini [`experimental.sandbox`](/docs/config#sandbox) içinde yapılandırın. diff --git a/packages/web/src/content/docs/tr/tools.mdx b/packages/web/src/content/docs/tr/tools.mdx index 2beb19009441..997820d0ada8 100644 --- a/packages/web/src/content/docs/tr/tools.mdx +++ b/packages/web/src/content/docs/tr/tools.mdx @@ -60,6 +60,12 @@ Proje ortamınızda kabuk komutları çalıştırır. Bu araç LLM'in `npm install`, `git status` gibi terminal komutlarını veya diğer kabuk komutlarını çalıştırmasını sağlar. +:::note +macOS sandbox'ı etkinleştirildiğinde bash, dosya sistemi kısıtlamalarıyla çalışır ve gerektiğinde ayrı bir sandbox dışı yeniden deneme isteyebilir. +Bir komutun sandbox dışında başlaması gerektiğini biliyorsanız, komutun ilk boş olmayan satırına `# opencode:unsandboxed ` koyun. +Desteklenen davranış ve sınırlamalar için [sandbox yapılandırmasına](/docs/config#sandbox) bakın. +::: + --- ### edit diff --git a/packages/web/src/content/docs/zh-cn/config.mdx b/packages/web/src/content/docs/zh-cn/config.mdx index c401bcf121fa..316154751017 100644 --- a/packages/web/src/content/docs/zh-cn/config.mdx +++ b/packages/web/src/content/docs/zh-cn/config.mdx @@ -622,6 +622,51 @@ OpenCode 启动时会自动下载新版本。您可以使用 `autoupdate` 选项 实验性选项不稳定。它们可能会在不另行通知的情况下被更改或移除。 ::: +### Sandbox + +OpenCode 可以在 macOS 上对 bash 命令、会话 shell 命令和 PTY 启动进行沙箱隔离。 +沙箱功能为实验性功能,需要手动启用,默认处于关闭状态。 + +```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"] + } + } +} +``` + +可用选项: + +| 选项 | 类型 | 描述 | +| ------------------------- | ---------------------------------- | ---------------------------------------------------------------- | +| `enabled` | `boolean` | 为支持的 macOS 执行路径启用沙箱。 | +| `preset` | `string` | 选择内置预设(`default`、`strict`、`network`)或自定义预设名称。 | +| `mode` | `"workspace-write" \| "read-only"` | 覆盖预设模式。 | +| `network` | `boolean` | 覆盖是否允许出站网络访问。 | +| `protected_roots` | `string[]` | 即使在可写根目录内也保持写保护的工作空间相对路径。 | +| `extra_read_roots` | `string[]` | 沙箱可以读取的额外绝对路径。 | +| `extra_write_roots` | `string[]` | 沙箱可以写入的额外绝对路径。 | +| `extra_deny_paths` | `string[]` | 沙箱必须拒绝的额外绝对路径。 | +| `excluded_commands` | `string[]` | 执行前必须阻止的命令前缀。 | +| `allow_unsandboxed_retry` | `boolean` | 允许在沙箱拒绝后进行单独的 `bash:unsandboxed` 权限控制重试。 | +| `fail_if_unavailable` | `boolean` | 当沙箱已启用但无法激活时硬性失败。 | +| `presets` | `Record` | 使用 `mode`、`network`、根路径和权限覆盖定义自定义预设。 | + +:::note +沙箱中的命令可以读取内置系统根目录,如 `/bin`、`/usr`、`/opt/homebrew`、`/System`、`/Library`、`/dev`、`/tmp` 和 `/private/etc`。 +敏感的主目录路径(如 `~/.ssh`、`~/.gnupg` 和云凭据目录)默认被拒绝。 +请参阅[安全策略](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md)了解完整的威胁模型、覆盖范围和当前限制。 +::: + --- ## 变量 diff --git a/packages/web/src/content/docs/zh-cn/permissions.mdx b/packages/web/src/content/docs/zh-cn/permissions.mdx index f928554f2a84..d7123711f6f4 100644 --- a/packages/web/src/content/docs/zh-cn/permissions.mdx +++ b/packages/web/src/content/docs/zh-cn/permissions.mdx @@ -134,6 +134,7 @@ OpenCode 的权限以工具名称为键,外加几个安全防护项: - `glob` — 文件通配(匹配通配模式) - `grep` — 内容搜索(匹配正则表达式模式) - `bash` — 运行 shell 命令(匹配解析后的命令,如 `git status --porcelain`) +- `bash:unsandboxed` — 在沙箱拒绝后或在明确的非沙箱请求后,在沙箱外重新运行 shell 命令 - `task` — 启动子代理(匹配子代理类型) - `skill` — 加载技能(匹配技能名称) - `lsp` — 运行 LSP 查询(当前不支持细粒度配置) @@ -233,3 +234,11 @@ Only analyze code and suggest changes. :::tip 对带参数的命令使用模式匹配。`"grep *"` 允许执行 `grep pattern file.txt`,而单独的 `"grep"` 则会阻止它。像 `git status` 这样的命令适用于默认行为,但在传递参数时需要显式权限(如 `"git status *"`)。 ::: + +--- + +## 沙箱交互 + +当 macOS 沙箱启用时,被阻止的 bash 命令可以触发单独的 `bash:unsandboxed` 权限请求。 +当 OpenCode 检测到可能的沙箱拒绝,或者命令在第一个非空行中使用 `# opencode:unsandboxed <原因>` 明确请求跳过沙箱时,就会发生这种情况。 +在 [`experimental.sandbox`](/docs/config#sandbox) 中配置沙箱本身。 diff --git a/packages/web/src/content/docs/zh-cn/tools.mdx b/packages/web/src/content/docs/zh-cn/tools.mdx index 4c603705901a..149eb2d0c774 100644 --- a/packages/web/src/content/docs/zh-cn/tools.mdx +++ b/packages/web/src/content/docs/zh-cn/tools.mdx @@ -60,6 +60,12 @@ description: 管理 LLM 可以使用的工具。 该工具允许 LLM 运行终端命令,例如 `npm install`、`git status` 或其他任何 shell 命令。 +:::note +当 macOS 沙箱启用时,bash 在文件系统限制下运行,并可在需要时请求单独的非沙箱重试。 +如果您知道某个命令必须在沙箱外启动,请在命令的第一个非空行添加 `# opencode:unsandboxed <原因>`。 +有关支持的行为和限制,请参阅[沙箱配置](/docs/config#sandbox)。 +::: + --- ### edit diff --git a/packages/web/src/content/docs/zh-tw/config.mdx b/packages/web/src/content/docs/zh-tw/config.mdx index a694823a65f9..d614516fedb1 100644 --- a/packages/web/src/content/docs/zh-tw/config.mdx +++ b/packages/web/src/content/docs/zh-tw/config.mdx @@ -626,6 +626,51 @@ OpenCode 啟動時會自動下載新版本。您可以使用 `autoupdate` 選項 實驗性選項不穩定。它們可能會在不另行通知的情況下被變更或移除。 ::: +### Sandbox + +OpenCode 可以在 macOS 上對 bash 指令、工作階段 shell 指令和 PTY 啟動進行沙箱隔離。 +沙箱功能為實驗性功能,需要手動啟用,預設處於關閉狀態。 + +```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"] + } + } +} +``` + +可用選項: + +| 選項 | 類型 | 描述 | +| ------------------------- | ---------------------------------- | -------------------------------------------------------------- | +| `enabled` | `boolean` | 為支援的 macOS 執行路徑啟用沙箱。 | +| `preset` | `string` | 選擇內建預設(`default`、`strict`、`network`)或自訂預設名稱。 | +| `mode` | `"workspace-write" \| "read-only"` | 覆寫預設模式。 | +| `network` | `boolean` | 覆寫是否允許出站網路存取。 | +| `protected_roots` | `string[]` | 即使在可寫根目錄內也保持寫入保護的工作空間相對路徑。 | +| `extra_read_roots` | `string[]` | 沙箱可以讀取的額外絕對路徑。 | +| `extra_write_roots` | `string[]` | 沙箱可以寫入的額外絕對路徑。 | +| `extra_deny_paths` | `string[]` | 沙箱必須拒絕的額外絕對路徑。 | +| `excluded_commands` | `string[]` | 執行前必須阻止的指令前綴。 | +| `allow_unsandboxed_retry` | `boolean` | 允許在沙箱拒絕後進行單獨的 `bash:unsandboxed` 權限控制重試。 | +| `fail_if_unavailable` | `boolean` | 當沙箱已啟用但無法啟動時硬性失敗。 | +| `presets` | `Record` | 使用 `mode`、`network`、根路徑和權限覆寫定義自訂預設。 | + +:::note +沙箱中的指令可以讀取內建系統根目錄,如 `/bin`、`/usr`、`/opt/homebrew`、`/System`、`/Library`、`/dev`、`/tmp` 和 `/private/etc`。 +敏感的主目錄路徑(如 `~/.ssh`、`~/.gnupg` 和雲端憑證目錄)預設被拒絕。 +請參閱[安全政策](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md)了解完整的威脅模型、涵蓋範圍和目前限制。 +::: + --- ## 變數 diff --git a/packages/web/src/content/docs/zh-tw/permissions.mdx b/packages/web/src/content/docs/zh-tw/permissions.mdx index bacd87c1ed56..a5d2cb4deeed 100644 --- a/packages/web/src/content/docs/zh-tw/permissions.mdx +++ b/packages/web/src/content/docs/zh-tw/permissions.mdx @@ -134,6 +134,7 @@ OpenCode 的權限以工具名稱為鍵,外加幾個安全防護項: - `glob` — 檔案萬用字元比對(比對萬用字元模式) - `grep` — 內容搜尋(比對正規表示式模式) - `bash` — 執行 shell 指令(比對解析後的指令,如 `git status --porcelain`) +- `bash:unsandboxed` — 在沙箱拒絕後或在明確的非沙箱請求後,在沙箱外重新執行 shell 指令 - `task` — 啟動子代理(比對子代理類型) - `skill` — 載入技能(比對技能名稱) - `lsp` — 執行 LSP 查詢(目前不支援細粒度設定) @@ -233,3 +234,11 @@ Only analyze code and suggest changes. :::tip 對帶參數的指令使用模式比對。`"grep *"` 允許執行 `grep pattern file.txt`,而單獨的 `"grep"` 則會阻止它。像 `git status` 這樣的指令適用於預設行為,但在傳遞參數時需要顯式權限(如 `"git status *"`)。 ::: + +--- + +## 沙箱互動 + +當 macOS 沙箱啟用時,被阻止的 bash 指令可以觸發單獨的 `bash:unsandboxed` 權限請求。 +當 OpenCode 偵測到可能的沙箱拒絕,或者指令在第一個非空行中使用 `# opencode:unsandboxed <原因>` 明確請求跳過沙箱時,就會發生這種情況。 +在 [`experimental.sandbox`](/docs/config#sandbox) 中設定沙箱本身。 diff --git a/packages/web/src/content/docs/zh-tw/tools.mdx b/packages/web/src/content/docs/zh-tw/tools.mdx index 6ce68d9fb5ec..47e9209288b8 100644 --- a/packages/web/src/content/docs/zh-tw/tools.mdx +++ b/packages/web/src/content/docs/zh-tw/tools.mdx @@ -60,6 +60,12 @@ description: 管理 LLM 可以使用的工具。 該工具允許 LLM 執行終端機指令,例如 `npm install`、`git status` 或其他任何 shell 指令。 +:::note +當 macOS 沙箱啟用時,bash 在檔案系統限制下執行,並可在需要時請求單獨的非沙箱重試。 +如果您知道某個指令必須在沙箱外啟動,請在指令的第一個非空行加上 `# opencode:unsandboxed <原因>`。 +有關支援的行為和限制,請參閱[沙箱設定](/docs/config#sandbox)。 +::: + --- ### edit From b4737787a306a61a628292689f9f17c5b4173f64 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Mon, 30 Mar 2026 15:05:10 +0200 Subject: [PATCH 14/23] fix unsandboxed command allowing pattern --- packages/opencode/src/session/prompt.ts | 13 +- packages/opencode/src/tool/bash.ts | 40 ++++- .../test/session/prompt-sandbox.test.ts | 139 ++++++++++++++++ .../opencode/test/tool/bash-sandbox.test.ts | 157 +++++++++++++++++- 4 files changed, 340 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a71800dd0329..0e722f9729c4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -49,6 +49,8 @@ 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 @@ -942,16 +944,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the 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 try { yield* permission.ask({ permission: "bash:unsandboxed", - patterns: [command], - always: [command], + patterns: unsandboxed, + always: unsandboxed, metadata: { reason: "explicit_request" satisfies SandboxSpawn.UnsandboxedReason, detail: request.detail, + command, }, sessionID: input.sessionID, tool: { @@ -998,10 +1002,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the try { yield* permission.ask({ permission: "bash:unsandboxed", - patterns: [command], - always: [command], + patterns: unsandboxed, + always: unsandboxed, metadata: { reason, + command, }, sessionID: input.sessionID, tool: { diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index cc448fa3e275..d9ea6d097983 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -389,6 +389,33 @@ const parser = lazy(async () => { return { bash, ps } }) +export async function commandFamilies(cmd: string): Promise { + const tree = await parser().then((p) => p.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", @@ -635,17 +662,21 @@ export const BashTool = Tool.define( let proactive = false let rejected = false let asked = false + const unsandboxed = yield* Effect.promise(() => + input.cfg.allow_unsandboxed_retry ? commandFamilies(input.command) : Promise.resolve([]), + ) if (input.command !== input.source && input.cfg.allow_unsandboxed_retry && launch.sandbox.active) { asked = true try { yield* ctx.ask({ permission: "bash:unsandboxed", - patterns: [input.command], - always: [input.command], + patterns: unsandboxed, + always: unsandboxed, metadata: { reason: "explicit_request" satisfies SandboxSpawn.UnsandboxedReason, detail: input.detail, + command: input.command, }, }) proactive = true @@ -685,10 +716,11 @@ export const BashTool = Tool.define( try { yield* ctx.ask({ permission: "bash:unsandboxed", - patterns: [input.command], - always: [input.command], + patterns: unsandboxed, + always: unsandboxed, metadata: { reason, + command: input.command, }, }) retried = true diff --git a/packages/opencode/test/session/prompt-sandbox.test.ts b/packages/opencode/test/session/prompt-sandbox.test.ts index 56587429ab70..2c462bc1160e 100644 --- a/packages/opencode/test/session/prompt-sandbox.test.ts +++ b/packages/opencode/test/session/prompt-sandbox.test.ts @@ -328,6 +328,145 @@ describe("session.prompt sandbox", () => { }) }) + 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({ + 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 Session.create({}) + + const run1 = SessionPrompt.shell({ + 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 Permission.reply({ 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 = SessionPrompt.shell({ + sessionID: session.id, + agent: "build", + command: '# opencode:unsandboxed read bar\ncat "$HOME/.ssh/bar"', + }) + await Bun.sleep(100) + const pending2 = await Permission.list() + 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 Session.remove(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({ + 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 Session.create({}) + + const run1 = SessionPrompt.shell({ + 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 Permission.reply({ requestID: pending1[0].id, reply: "always" }) + await run1 + + const run2 = SessionPrompt.shell({ + 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 Permission.list() + 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 Session.remove(session.id) + }, + }) + }) + test("signals when explicit rejection is followed by sandboxed launch failure", async () => { if (process.platform !== "darwin") return const plan = spyOn(SandboxRuntime, "plan").mockResolvedValue({ diff --git a/packages/opencode/test/tool/bash-sandbox.test.ts b/packages/opencode/test/tool/bash-sandbox.test.ts index cdde3b541d30..856e91a115c5 100644 --- a/packages/opencode/test/tool/bash-sandbox.test.ts +++ b/packages/opencode/test/tool/bash-sandbox.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" -import { BashTool } from "../../src/tool/bash" +import { BashTool, commandFamilies } from "../../src/tool/bash" import { SandboxRuntime } from "../../src/sandbox/runtime" import { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" @@ -494,4 +494,159 @@ describe("tool.bash sandbox", () => { }, }) }) + + 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: string[]; always: 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: string[] + always: 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: string[]; always: 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 *") + }, + }) + }) }) From 6e892035457e66f5c2d1480acdf8df59f571df79 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Tue, 7 Apr 2026 11:23:46 +0200 Subject: [PATCH 15/23] fix rebase --- packages/opencode/src/tool/bash.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index d9ea6d097983..9780cef20583 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -390,10 +390,10 @@ const parser = lazy(async () => { }) export async function commandFamilies(cmd: string): Promise { - const tree = await parser().then((p) => p.parse(cmd)) - if (!tree) return [cmd] + const root = await parse(cmd, false).catch(() => undefined) + if (!root) return [cmd] const result = new Set() - for (const node of tree.rootNode.descendantsOfType("command")) { + for (const node of root.descendantsOfType("command")) { if (!node) continue const tokens: string[] = [] for (let i = 0; i < node.childCount; i++) { From 9d3bc34955da4c8c20a7666c63d834fd8b5b5f19 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Tue, 7 Apr 2026 14:40:45 +0200 Subject: [PATCH 16/23] fixes --- package.json | 2 - packages/opencode/src/sandbox/runtime.ts | 61 ------------------- packages/opencode/src/session/prompt.ts | 3 +- .../opencode/test/sandbox/runtime.test.ts | 53 ---------------- .../test/session/prompt-sandbox.test.ts | 33 +++++----- .../opencode/test/tool/bash-sandbox.test.ts | 24 +++----- 6 files changed, 25 insertions(+), 151 deletions(-) delete mode 100644 packages/opencode/src/sandbox/runtime.ts delete mode 100644 packages/opencode/test/sandbox/runtime.test.ts diff --git a/package.json b/package.json index 1d745bc8074c..06bf9c91aef0 100644 --- a/package.json +++ b/package.json @@ -83,8 +83,6 @@ }, "devDependencies": { "@actions/artifact": "5.0.1", - "@opentui/core": "0.1.90", - "@opentui/solid": "0.1.90", "@tsconfig/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", diff --git a/packages/opencode/src/sandbox/runtime.ts b/packages/opencode/src/sandbox/runtime.ts deleted file mode 100644 index a504bba9ecb6..000000000000 --- a/packages/opencode/src/sandbox/runtime.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { SandboxSpawn } from "./spawn" - -export namespace SandboxRuntime { - export interface SpawnPlan { - active: boolean - file: string - args: string[] - env?: Record - diag: SandboxSpawn.Diag - } - - export interface Input { - file: string - args: string[] - cwd: string - project_root: string - worktree_root: string - preset?: string - mode?: SandboxSpawn.Mode - allow_network?: boolean - allow_unix_sockets?: boolean - cfg?: SandboxSpawn.Settings - } - - export async function plan(input: Input): Promise { - const sandbox = await SandboxSpawn.resolve( - { - cwd: input.cwd, - project_root: input.project_root, - worktree_root: input.worktree_root, - preset: input.preset, - mode: input.mode, - allow_network: input.allow_network, - allow_unix_sockets: input.allow_unix_sockets, - }, - input.cfg, - ) - - if (!sandbox.active || !sandbox.profile) { - return { - active: false, - file: input.file, - args: input.args, - diag: sandbox.diag, - } - } - - const cmd = SandboxSpawn.wrap({ - profile: sandbox.profile, - file: input.file, - args: input.args, - }) - - return { - active: true, - file: cmd.file, - args: cmd.args, - diag: sandbox.diag, - } - } -} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0e722f9729c4..79637b8789d8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -881,7 +881,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ) const cleanArgs = clean[shellName]?.args ?? clean[""]?.args ?? ["-c", input.command] - const raw = { file: sh, args: invocations[shellName]?.args ?? invocations[""].args } + 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 }) diff --git a/packages/opencode/test/sandbox/runtime.test.ts b/packages/opencode/test/sandbox/runtime.test.ts deleted file mode 100644 index c12184981302..000000000000 --- a/packages/opencode/test/sandbox/runtime.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { SandboxRuntime } from "../../src/sandbox/runtime" - -describe("sandbox.runtime", () => { - test("wraps commands with preset defaults", async () => { - const out = await SandboxRuntime.plan({ - file: "/bin/zsh", - args: ["-f", "-c", "pwd"], - cwd: "/tmp/project/app", - project_root: "/tmp/project/app", - worktree_root: "/tmp/project", - cfg: { - requested: true, - preset: "strict", - presets: {}, - extra_deny_paths: [], - excluded_commands: [], - allow_unsandboxed_retry: false, - fail_if_unavailable: false, - }, - }) - - expect(out.active).toBe(true) - expect(out.file).toBe("/usr/bin/sandbox-exec") - expect(out.args[2]).toBe("/bin/zsh") - expect(out.diag.mode).toBe("read-only") - expect(out.diag.allow_network).toBe(false) - }) - - test("lets explicit overrides win over preset defaults", async () => { - const out = await SandboxRuntime.plan({ - file: "/bin/zsh", - args: ["-f", "-c", "pwd"], - cwd: "/tmp/project/app", - project_root: "/tmp/project/app", - worktree_root: "/tmp/project", - mode: "read-only", - allow_network: false, - cfg: { - requested: true, - preset: "network", - presets: {}, - extra_deny_paths: [], - excluded_commands: [], - allow_unsandboxed_retry: false, - fail_if_unavailable: false, - }, - }) - - expect(out.diag.mode).toBe("read-only") - expect(out.diag.allow_network).toBe(false) - }) -}) diff --git a/packages/opencode/test/session/prompt-sandbox.test.ts b/packages/opencode/test/session/prompt-sandbox.test.ts index 2c462bc1160e..1f7136bad10b 100644 --- a/packages/opencode/test/session/prompt-sandbox.test.ts +++ b/packages/opencode/test/session/prompt-sandbox.test.ts @@ -3,7 +3,7 @@ import fs from "fs/promises" import path from "path" import { Permission } from "../../src/permission" import { Instance } from "../../src/project/instance" -import { SandboxRuntime } from "../../src/sandbox/runtime" +import { SandboxSpawn } from "../../src/sandbox/spawn" import { Session } from "../../src/session" import { SessionPrompt } from "../../src/session/prompt" import { tmpdir } from "../fixture/fixture" @@ -41,6 +41,7 @@ describe("session.prompt sandbox", () => { }, }) await using tmp = await tmpdir({ + git: true, config: { experimental: { sandbox: { @@ -82,9 +83,11 @@ describe("session.prompt sandbox", () => { 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: { @@ -140,6 +143,7 @@ describe("session.prompt sandbox", () => { test("blocks excluded commands before spawning", async () => { await using tmp = await tmpdir({ + git: true, config: { experimental: { sandbox: { @@ -182,6 +186,7 @@ describe("session.prompt sandbox", () => { }, }) await using tmp = await tmpdir({ + git: true, config: { experimental: { sandbox: { @@ -217,6 +222,7 @@ describe("session.prompt sandbox", () => { 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 Session.remove(session.id) }, }) @@ -228,9 +234,11 @@ describe("session.prompt sandbox", () => { 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: { @@ -266,6 +274,7 @@ describe("session.prompt sandbox", () => { 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 Session.remove(session.id) }, }) @@ -280,6 +289,7 @@ describe("session.prompt sandbox", () => { }, }) await using tmp = await tmpdir({ + git: true, config: { experimental: { sandbox: { @@ -338,6 +348,7 @@ describe("session.prompt sandbox", () => { }, }) await using tmp = await tmpdir({ + git: true, config: { experimental: { sandbox: { @@ -410,6 +421,7 @@ describe("session.prompt sandbox", () => { }, }) await using tmp = await tmpdir({ + git: true, config: { experimental: { sandbox: { @@ -469,26 +481,13 @@ describe("session.prompt sandbox", () => { test("signals when explicit rejection is followed by sandboxed launch failure", async () => { if (process.platform !== "darwin") return - const plan = spyOn(SandboxRuntime, "plan").mockResolvedValue({ - active: true, + const wrap = spyOn(SandboxSpawn, "wrap").mockReturnValue({ file: "/definitely/missing-sandbox-exec", args: [], - diag: { - requested: true, - active: true, - reason: "enabled", - wrapper: "/usr/bin/sandbox-exec", - cwd: "/tmp/project", - mode: "workspace-write", - read_roots: [], - write_roots: [], - unsafe_roots: [], - allow_network: false, - allow_unix_sockets: false, - }, }) try { await using tmp = await tmpdir({ + git: true, config: { experimental: { sandbox: { @@ -534,7 +533,7 @@ describe("session.prompt sandbox", () => { }, }) } finally { - plan.mockRestore() + wrap.mockRestore() } }) }) diff --git a/packages/opencode/test/tool/bash-sandbox.test.ts b/packages/opencode/test/tool/bash-sandbox.test.ts index 856e91a115c5..a2699ab5bd5e 100644 --- a/packages/opencode/test/tool/bash-sandbox.test.ts +++ b/packages/opencode/test/tool/bash-sandbox.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" import { BashTool, commandFamilies } from "../../src/tool/bash" -import { SandboxRuntime } from "../../src/sandbox/runtime" +import { SandboxSpawn } from "../../src/sandbox/spawn" import { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { SessionID, MessageID } from "../../src/session/schema" @@ -83,6 +83,7 @@ describe("tool.bash sandbox", () => { 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({ @@ -259,6 +260,7 @@ describe("tool.bash sandbox", () => { 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") }, }) }) @@ -269,6 +271,7 @@ describe("tool.bash sandbox", () => { 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({ @@ -302,6 +305,7 @@ describe("tool.bash sandbox", () => { 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") }, }) }) @@ -397,23 +401,9 @@ describe("tool.bash sandbox", () => { test("reports sandboxed fallback launch failures after explicit rejection", async () => { if (process.platform !== "darwin") return - const plan = spyOn(SandboxRuntime, "plan").mockResolvedValue({ - active: true, + const wrap = spyOn(SandboxSpawn, "wrap").mockReturnValue({ file: "/definitely/missing-sandbox-exec", args: [], - diag: { - requested: true, - active: true, - reason: "enabled", - wrapper: "/usr/bin/sandbox-exec", - cwd: "/tmp/project", - mode: "workspace-write", - read_roots: [], - write_roots: [], - unsafe_roots: [], - allow_network: false, - allow_unix_sockets: false, - }, }) try { await using tmp = await tmpdir({ @@ -446,7 +436,7 @@ describe("tool.bash sandbox", () => { }, }) } finally { - plan.mockRestore() + wrap.mockRestore() } }) From cbecd495a4fa898a8f6d3f9e079d4bb27a1a831f Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Tue, 7 Apr 2026 15:26:42 +0200 Subject: [PATCH 17/23] remove useless --- packages/opencode/src/sandbox/policy.ts | 8 --- packages/opencode/src/sandbox/spawn.ts | 6 --- .../opencode/test/pty/pty-session.test.ts | 50 ++++++++++++++++++- packages/opencode/test/sandbox/policy.test.ts | 9 ++-- 4 files changed, 53 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/sandbox/policy.ts b/packages/opencode/src/sandbox/policy.ts index 058aeb29e6c5..636d5027da2e 100644 --- a/packages/opencode/src/sandbox/policy.ts +++ b/packages/opencode/src/sandbox/policy.ts @@ -15,7 +15,6 @@ export namespace SandboxPolicy { extra_deny_paths?: string[] opencode_roots?: string[] allow_network?: boolean - allow_unix_sockets?: boolean } export interface Output { @@ -96,13 +95,6 @@ export namespace SandboxPolicy { ...deny(denyRoots), ...denyWrite(protectedRoots), ...(input.allow_network ? ["(allow network*)"] : []), - ...(input.allow_unix_sockets - ? [ - "(allow system-socket (socket-domain AF_UNIX))", - "(allow network-bind (local unix-socket))", - "(allow network-outbound (remote unix-socket))", - ] - : []), ].join("\n") return { profile, diff --git a/packages/opencode/src/sandbox/spawn.ts b/packages/opencode/src/sandbox/spawn.ts index fd5fe32ed926..d072a719a939 100644 --- a/packages/opencode/src/sandbox/spawn.ts +++ b/packages/opencode/src/sandbox/spawn.ts @@ -34,7 +34,6 @@ export namespace SandboxSpawn { write_roots: string[] unsafe_roots: string[] allow_network: boolean - allow_unix_sockets: boolean } export interface Settings { @@ -59,7 +58,6 @@ export namespace SandboxSpawn { preset?: string mode?: Mode allow_network?: boolean - allow_unix_sockets?: boolean } export interface PlanInput extends ResolveInput { @@ -224,7 +222,6 @@ export namespace SandboxSpawn { write_roots: [], unsafe_roots: [], allow_network: input.allow_network === true, - allow_unix_sockets: input.allow_unix_sockets === true, } satisfies Diag } @@ -370,7 +367,6 @@ export namespace SandboxSpawn { opencode_roots: input.opencode_roots, mode: input.mode, allow_network: input.allow_network, - allow_unix_sockets: input.allow_unix_sockets, }) const diag = { @@ -384,7 +380,6 @@ export namespace SandboxSpawn { write_roots: policy.write, unsafe_roots: [], allow_network: input.allow_network === true, - allow_unix_sockets: input.allow_unix_sockets === true, } satisfies Diag return { @@ -444,7 +439,6 @@ export namespace SandboxSpawn { extra_write_roots: mode === "read-only" ? writeRoots : [...writeRoots, ...temp], extra_deny_paths: raw.extra_deny_paths.map(Filesystem.resolve), allow_network: allowNetwork, - allow_unix_sockets: input.allow_unix_sockets, }) if (out.active) log.debug("sandbox active", out.diag) diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 53986aa05bc7..acb0edf96688 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) { @@ -139,6 +150,43 @@ describe("pty", () => { }) }) + 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 Pty.create({ 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 Pty.remove(info.id) + } + }, + }) + }) + test("blocks excluded commands on initial pty spawn", async () => { await using dir = await tmpdir({ config: { diff --git a/packages/opencode/test/sandbox/policy.test.ts b/packages/opencode/test/sandbox/policy.test.ts index 18b0114238bd..a9216cdc3d0b 100644 --- a/packages/opencode/test/sandbox/policy.test.ts +++ b/packages/opencode/test/sandbox/policy.test.ts @@ -41,20 +41,19 @@ describe("sandbox.policy", () => { expect(out.profile).toContain('(subpath "/opt/homebrew")') }) - test("adds network and unix socket rules only when requested", () => { + 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, - allow_unix_sockets: true, }) expect(out.profile).toContain("(allow network*)") - expect(out.profile).toContain("AF_UNIX") - expect(out.profile).toContain("network-bind") - expect(out.profile).toContain("network-outbound") + 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", () => { From 70fa171511e70156a1391e729237ddf071ab523d Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Thu, 9 Apr 2026 01:38:16 +0200 Subject: [PATCH 18/23] fix test --- packages/opencode/src/session/prompt.ts | 3 +++ turbo.json | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 79637b8789d8..03d4625b06a1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -784,9 +784,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the 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") } diff --git a/turbo.json b/turbo.json index 28c2fa2de0d2..eb0a088c0e76 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,9 @@ "globalEnv": ["CI", "OPENCODE_DISABLE_SHARE"], "globalPassThroughEnv": ["CI", "OPENCODE_DISABLE_SHARE"], "tasks": { - "typecheck": {}, + "typecheck": { + "dependsOn": ["^typecheck"] + }, "build": { "dependsOn": [], "outputs": ["dist/**"] From 7ea314c68360078da5968fc4c5cdae67137b10e3 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Mon, 13 Apr 2026 14:11:15 +0200 Subject: [PATCH 19/23] rebase fix tests --- packages/opencode/src/session/prompt.ts | 450 +++++++++--------- packages/opencode/src/tool/bash.ts | 45 +- .../test/session/prompt-sandbox.test.ts | 4 +- .../opencode/test/tool/bash-sandbox.test.ts | 41 +- 4 files changed, 296 insertions(+), 244 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 03d4625b06a1..b629e27e6d2d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -811,254 +811,266 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ) - try { - const cfg = yield* Effect.promise(() => SandboxSpawn.settings()) - const blocked = SandboxSpawn.excludedText(input.command, cfg.excluded_commands) - if (blocked) { - throw new SandboxSpawn.CommandError(blocked.command, blocked.rule) - } + return yield* Effect.gen(function* () { + try { + const cfg = yield* Effect.promise(() => 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", - ` + 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", - ` + ], + }, + 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] }, - } + ], + }, + 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) - 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)) - } - }), - ), - ) - yield* Effect.forkScoped( - Stream.runForEach(Stream.decodeText(handle.stderr), (chunk) => + 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) + 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)) + } + }), + ), + ) + 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)) + } + }), + ), + ) + return yield* handle.exitCode + }).pipe( + Effect.scoped, + Effect.onInterrupt(() => Effect.sync(() => { - stderr += chunk - output += chunk - if (part.state.status === "running") { - part.state.metadata = { output, description: "" } - void run.fork(sessions.updatePart(part)) - } + aborted = true }), ), + Effect.exit, ) - return yield* handle.exitCode - }).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) - } + if (Exit.isFailure(exit)) { + if (Cause.hasInterruptsOnly(exit.cause)) return { code: 1, stderr } + return yield* Effect.failCause(exit.cause) + } - return { code: exit.value, stderr } - }) + return { code: exit.value, stderr } + }) - 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 + 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 try { - 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 ?? []), - }) - proactive = true + result = yield* exec(proactive ? raw : call) } catch (error) { - rejected = true - log.info("proactive unsandboxed request rejected", { error, sessionID: input.sessionID }) + 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, + ) + } + throw error } - } - let retried = false - let reason: SandboxSpawn.RetryReason | undefined - let result - try { - result = yield* exec(proactive ? raw : call) - } catch (error) { - 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, - ) + if (!proactive) { + reason = SandboxSpawn.retryReason({ + active: sandbox.active, + code: result.code, + stderr: result.stderr, + allow_network: sandbox.diag.allow_network, + command, + }) } - throw error - } - - 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 - try { - 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 ?? []), - }) - retried = true - output = "" - if (part.state.status === "running") { - part.state.metadata = { output: "", description: "" } - yield* sessions.updatePart(part) + 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, + }) } - result = yield* exec(raw) - } catch (error) { - log.info("unsandboxed retry rejected", { error, sessionID: input.sessionID }) } - } - if (rejected) { - output += - "\n\n" + - ["", "Explicit unsandboxed request was rejected; command ran in sandbox", ""].join( - "\n", - ) - } + 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") - } + 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] } - } + 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* ( @@ -1709,7 +1721,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 9780cef20583..c72417e119d4 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -17,7 +17,7 @@ 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, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { SandboxSpawn } from "@/sandbox/spawn" @@ -390,10 +390,10 @@ const parser = lazy(async () => { }) export async function commandFamilies(cmd: string): Promise { - const root = await parse(cmd, false).catch(() => undefined) - if (!root) return [cmd] + const tree = await parser().then((p) => p.bash.parse(cmd)) + if (!tree) return [cmd] const result = new Set() - for (const node of root.descendantsOfType("command")) { + for (const node of tree.rootNode.descendantsOfType("command")) { if (!node) continue const tokens: string[] = [] for (let i = 0; i < node.childCount; i++) { @@ -662,14 +662,14 @@ export const BashTool = Tool.define( let proactive = false let rejected = false let asked = false - const unsandboxed = yield* Effect.promise(() => - input.cfg.allow_unsandboxed_retry ? commandFamilies(input.command) : Promise.resolve([]), - ) + 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 - try { - yield* ctx.ask({ + const exit = yield* ctx + .ask({ permission: "bash:unsandboxed", patterns: unsandboxed, always: unsandboxed, @@ -679,18 +679,20 @@ export const BashTool = Tool.define( command: input.command, }, }) + .pipe(Effect.exit) + if (Exit.isSuccess(exit)) { proactive = true - } catch (error) { + } else { rejected = true - log.info("proactive unsandboxed request rejected", { error }) + log.info("proactive unsandboxed request rejected", { error: Cause.squash(exit.cause) }) } } let reason: SandboxSpawn.RetryReason | undefined - let result - try { - result = yield* exec(proactive ? launch.plain : launch.proc) - } catch (error) { + 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( @@ -698,8 +700,9 @@ export const BashTool = Tool.define( error instanceof Error ? { cause: error } : undefined, ) } - throw error + return yield* Effect.failCause(first.cause) } + result = first.value if (!proactive) { reason = SandboxSpawn.retryReason({ @@ -713,8 +716,8 @@ export const BashTool = Tool.define( if (input.cfg.allow_unsandboxed_retry && !asked && !result.timedOut && !result.aborted && reason) { asked = true - try { - yield* ctx.ask({ + const exit = yield* ctx + .ask({ permission: "bash:unsandboxed", patterns: unsandboxed, always: unsandboxed, @@ -723,6 +726,8 @@ export const BashTool = Tool.define( command: input.command, }, }) + .pipe(Effect.exit) + if (Exit.isSuccess(exit)) { retried = true yield* resetOutput() yield* ctx.metadata({ @@ -732,8 +737,8 @@ export const BashTool = Tool.define( }, }) result = yield* exec(launch.plain) - } catch (error) { - log.info("unsandboxed retry rejected", { error }) + } else { + log.info("unsandboxed retry rejected", { error: Cause.squash(exit.cause) }) } } diff --git a/packages/opencode/test/session/prompt-sandbox.test.ts b/packages/opencode/test/session/prompt-sandbox.test.ts index 1f7136bad10b..de82728b4386 100644 --- a/packages/opencode/test/session/prompt-sandbox.test.ts +++ b/packages/opencode/test/session/prompt-sandbox.test.ts @@ -15,10 +15,10 @@ const env = { } async function waitForPending(count: number) { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 100; i++) { const list = await Permission.list() if (list.length === count) return list - await Bun.sleep(0) + await Bun.sleep(10) } return Permission.list() } diff --git a/packages/opencode/test/tool/bash-sandbox.test.ts b/packages/opencode/test/tool/bash-sandbox.test.ts index a2699ab5bd5e..1cbf96a01952 100644 --- a/packages/opencode/test/tool/bash-sandbox.test.ts +++ b/packages/opencode/test/tool/bash-sandbox.test.ts @@ -1,12 +1,18 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" -import { BashTool, commandFamilies } from "../../src/tool/bash" +import { BashTool as RawBashTool, commandFamilies } from "../../src/tool/bash" import { SandboxSpawn } from "../../src/sandbox/spawn" import { Tool } from "../../src/tool/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 "../../src/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/truncate" const env = { HOME: process.env.HOME, @@ -14,6 +20,27 @@ const env = { 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(""), @@ -21,13 +48,17 @@ const ctx = { agent: "build", abort: AbortSignal.any([]), messages: [], - metadata: () => {}, - ask: async () => {}, + metadata: () => Effect.void, + ask: () => Effect.void, } -const makeCtx = (ask: Tool.Context["ask"] = async () => {}) => ({ +const makeCtx = (ask: (input: Parameters[0]) => void | Promise = () => undefined) => ({ ...ctx, - ask, + 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(() => { From 813b69b00ddee59e20242f2d791412aeabe9deb4 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Mon, 13 Apr 2026 16:38:02 +0200 Subject: [PATCH 20/23] fix --- packages/opencode/src/session/prompt.ts | 11 +++++++---- packages/opencode/src/tool/bash.ts | 23 +++++++++++++---------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b629e27e6d2d..8c1e901d0f9e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -813,7 +813,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return yield* Effect.gen(function* () { try { - const cfg = yield* Effect.promise(() => SandboxSpawn.settings()) + 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) @@ -904,7 +904,7 @@ 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(proc) - yield* Effect.forkScoped( + const stdout = yield* Effect.forkScoped( Stream.runForEach(Stream.decodeText(handle.stdout), (chunk) => Effect.sync(() => { output += chunk @@ -915,7 +915,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ), ) - yield* Effect.forkScoped( + const err = yield* Effect.forkScoped( Stream.runForEach(Stream.decodeText(handle.stderr), (chunk) => Effect.sync(() => { stderr += chunk @@ -927,7 +927,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ), ) - return yield* handle.exitCode + const code = yield* handle.exitCode + yield* Fiber.await(stdout) + yield* Fiber.await(err) + return code }).pipe( Effect.scoped, Effect.onInterrupt(() => diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index c72417e119d4..3e3800a157dc 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -17,7 +17,7 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import * as Truncate from "./truncate" import { Plugin } from "@/plugin" -import { Cause, Effect, Exit, 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" @@ -424,7 +424,6 @@ export const BashTool = Tool.define( const fs = yield* AppFileSystem.Service const trunc = yield* Truncate.Service const plugin = yield* Plugin.Service - const sandbox = yield* Effect.promise(() => SandboxSpawn.settings()) const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) { const lines = yield* spawner @@ -610,18 +609,17 @@ export const BashTool = Tool.define( Effect.gen(function* () { const handle = yield* spawner.spawn(proc) - yield* Effect.forkScoped( + const out = yield* Effect.forkScoped( Stream.runForEach(Stream.decodeText(handle.stdout), (chunk) => { return write(chunk) }), ) - yield* Effect.forkScoped( + 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) @@ -646,6 +644,9 @@ export const BashTool = Tool.define( 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) @@ -794,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." @@ -801,7 +806,7 @@ 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) @@ -809,9 +814,7 @@ export const BashTool = Tool.define( .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)) .replaceAll( "${unsandboxed}", - sandbox.allow_unsandboxed_retry - ? "\n\nIf 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." - : "", + "\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) => @@ -825,7 +828,7 @@ 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(() => SandboxSpawn.settings()) + const cfg = yield* Effect.promise(Instance.bind(() => SandboxSpawn.settings())) const ps = PS.has(name) const root = yield* parse(command, ps) const scan = yield* collect(root, cwd, ps, shell, cfg.excluded_commands) From 651ef970673f8488c90f69cb0372af732c40834b Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Thu, 16 Apr 2026 12:00:43 +0200 Subject: [PATCH 21/23] more rebase fix --- bun.lock | 22 ++-- packages/opencode/src/config/config.ts | 35 ++++++ packages/opencode/src/pty/index.ts | 2 +- packages/opencode/src/sandbox/spawn.ts | 5 +- .../opencode/test/pty/pty-session.test.ts | 36 ++++-- .../test/sandbox/preset-permission.test.ts | 15 ++- packages/opencode/test/sandbox/preset.test.ts | 2 +- .../test/session/prompt-sandbox.test.ts | 107 +++++++++++------- .../opencode/test/tool/bash-sandbox.test.ts | 14 +-- packages/sdk/js/src/v2/gen/types.gen.ts | 56 +++++++++ 10 files changed, 213 insertions(+), 81 deletions(-) diff --git a/bun.lock b/bun.lock index 3483f9da05cc..c9a55f5c8e92 100644 --- a/bun.lock +++ b/bun.lock @@ -14,8 +14,6 @@ }, "devDependencies": { "@actions/artifact": "5.0.1", - "@opentui/core": "0.1.90", - "@opentui/solid": "0.1.90", "@tsconfig/bun": "catalog:", "@types/mime-types": "3.0.1", "@typescript/native-preview": "catalog:", @@ -3236,7 +3234,7 @@ "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - "find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="], @@ -3730,7 +3728,7 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], @@ -4134,7 +4132,7 @@ "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], - "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], + "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], @@ -5850,8 +5848,6 @@ "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "find-up/path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], @@ -5890,8 +5886,6 @@ "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], - "log-symbols/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -5972,7 +5966,7 @@ "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], - "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], @@ -6716,7 +6710,7 @@ "parse-bmfont-xml/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], "readable-stream/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -7052,7 +7046,9 @@ "ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], + + "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], "readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -7142,7 +7138,7 @@ "opencontrol/@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5c48a6b9e888..741bf0b6ddd6 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -43,6 +43,7 @@ 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" }) @@ -898,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/pty/index.ts b/packages/opencode/src/pty/index.ts index 83a2c54286b0..47979631229c 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -189,7 +189,7 @@ export const layer = Layer.effect( const id = PtyID.ascending() const command = input.command || Shell.preferred() const cwd = input.cwd || s.dir - const cfg = yield* Effect.promise(() => SandboxSpawn.settings()) + 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) diff --git a/packages/opencode/src/sandbox/spawn.ts b/packages/opencode/src/sandbox/spawn.ts index d072a719a939..fa56904fb1aa 100644 --- a/packages/opencode/src/sandbox/spawn.ts +++ b/packages/opencode/src/sandbox/spawn.ts @@ -1,10 +1,9 @@ -import { Config } from "@/config/config" +import { Config } from "@/config" import { Protected } from "@/file/protected" import { Flag } from "@/flag/flag" import { Global } from "@/global" import { BashArity } from "@/permission/arity" -import { Log } from "@/util/log" -import { Filesystem } from "@/util/filesystem" +import { Filesystem, Log } from "@/util" import os from "os" import path from "path" import { SandboxPolicy } from "./policy" diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index acb0edf96688..d625530d1f92 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -32,6 +32,22 @@ 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 @@ -127,10 +143,10 @@ describe("pty", () => { await Instance.provide({ directory: dir.path, fn: async () => { - const info = await Pty.create({ command: "cat", title: "cat" }) + const info = await createPty({ command: "cat", title: "cat" }) try { const out: string[] = [] - const ws: Parameters[1] = { + const ws: Parameters[1] = { readyState: 1, data: { id: info.id }, send: (data: unknown) => { @@ -139,12 +155,12 @@ describe("pty", () => { close: () => {}, } - await Pty.connect(info.id, ws) + await connectPty(info.id, ws) out.length = 0 - await Pty.write(info.id, "AAA\n") + await writePty(info.id, "AAA\n") await wait(() => out.join("").includes("AAA")) } finally { - await Pty.remove(info.id) + await removePty(info.id) } }, }) @@ -172,7 +188,7 @@ describe("pty", () => { await Instance.provide({ directory: dir.path, fn: async () => { - const info = await Pty.create({ command: "/bin/bash", title: "bash" }) + const info = await createPty({ command: "/bin/bash", title: "bash" }) try { await sleep(150) const hit = await fs @@ -181,7 +197,7 @@ describe("pty", () => { .catch(() => false) expect(hit).toBe(false) } finally { - await Pty.remove(info.id) + await removePty(info.id) } }, }) @@ -202,11 +218,11 @@ describe("pty", () => { await Instance.provide({ directory: dir.path, fn: async () => { - await expect(Pty.create({ command: "python", title: "py" })).rejects.toThrow("python") + await expect(createPty({ command: "python", title: "py" })).rejects.toThrow("python") await expect( - Pty.create({ command: "env", args: ["FOO=1", "python", "-c", "print(1)"], title: "env" }), + createPty({ command: "env", args: ["FOO=1", "python", "-c", "print(1)"], title: "env" }), ).rejects.toThrow("python") - await expect(Pty.create({ command: "sh", args: ["-c", "python -c 'print(1)'"], title: "sh" })).rejects.toThrow( + await expect(createPty({ command: "sh", args: ["-c", "python -c 'print(1)'"], title: "sh" })).rejects.toThrow( "python", ) }, diff --git a/packages/opencode/test/sandbox/preset-permission.test.ts b/packages/opencode/test/sandbox/preset-permission.test.ts index ff1f6aa37443..9e16aa470f55 100644 --- a/packages/opencode/test/sandbox/preset-permission.test.ts +++ b/packages/opencode/test/sandbox/preset-permission.test.ts @@ -1,4 +1,5 @@ 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" @@ -8,6 +9,10 @@ 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({ @@ -24,7 +29,7 @@ describe("sandbox preset permission overlay", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await getAgent("build") expect(Permission.evaluate("bash", "echo hello", build!.permission).action).toBe("ask") }, }) @@ -52,7 +57,7 @@ describe("sandbox preset permission overlay", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await getAgent("build") expect(Permission.evaluate("bash", "echo hello", build!.permission).action).toBe("allow") }, }) @@ -76,7 +81,7 @@ describe("sandbox preset permission overlay", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + const build = await getAgent("build") expect(Permission.evaluate("bash", "echo hello", build!.permission).action).toBe("deny") }, }) @@ -97,7 +102,7 @@ describe("sandbox preset permission overlay", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const general = await Agent.get("general") + const general = await getAgent("general") expect(Permission.evaluate("bash", "ls", general!.permission).action).toBe("ask") }, }) @@ -109,7 +114,7 @@ describe("sandbox preset permission overlay", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") + 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 index a4de1bc197f3..a896c60ee937 100644 --- a/packages/opencode/test/sandbox/preset.test.ts +++ b/packages/opencode/test/sandbox/preset.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Config } from "../../src/config/config" +import { Config } from "../../src/config" import { Instance } from "../../src/project/instance" import { SandboxPreset } from "../../src/sandbox/preset" import { tmpdir } from "../fixture/fixture" diff --git a/packages/opencode/test/session/prompt-sandbox.test.ts b/packages/opencode/test/session/prompt-sandbox.test.ts index de82728b4386..d6ce8fcdc774 100644 --- a/packages/opencode/test/session/prompt-sandbox.test.ts +++ b/packages/opencode/test/session/prompt-sandbox.test.ts @@ -1,6 +1,7 @@ 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" @@ -14,13 +15,37 @@ const env = { 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 Permission.list() + const list = await listPermissions() if (list.length === count) return list await Bun.sleep(10) } - return Permission.list() + return listPermissions() } afterEach(() => { @@ -62,8 +87,8 @@ describe("session.prompt sandbox", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) - const out = await SessionPrompt.shell({ + const session = await createSession() + const out = await runShell({ sessionID: session.id, agent: "build", command: "printf '%s' \"${OPENCODE_ZSHENV_HIT:-missing}\"", @@ -72,7 +97,7 @@ describe("session.prompt sandbox", () => { 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 Session.remove(session.id) + await removeSession(session.id) }, }) }) @@ -108,8 +133,8 @@ describe("session.prompt sandbox", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) - const denied = await SessionPrompt.shell({ + const session = await createSession() + const denied = await runShell({ sessionID: session.id, agent: "build", command: 'cat "$HOME/.ssh/secret"', @@ -120,14 +145,14 @@ describe("session.prompt sandbox", () => { expect(blocked.state.output).not.toContain("secret\n") expect(blocked.state.output).toContain("Operation not permitted") - const next = await Session.create({}) - const run = SessionPrompt.shell({ + const next = await createSession() + const run = runShell({ sessionID: next.id, agent: "build", command: "sleep 5", }) setTimeout(() => { - void SessionPrompt.cancel(next.id) + void cancelShell(next.id) }, 50) const out = await run const part = out.parts[0] @@ -135,8 +160,8 @@ describe("session.prompt sandbox", () => { if (part.state.status !== "completed") throw new Error("expected completed part") expect(part.state.output).toContain("User aborted the command") - await Session.remove(session.id) - await Session.remove(next.id) + await removeSession(session.id) + await removeSession(next.id) }, }) }) @@ -162,8 +187,8 @@ describe("session.prompt sandbox", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) - const out = await SessionPrompt.shell({ + const session = await createSession() + const out = await runShell({ sessionID: session.id, agent: "build", command: "FOO=1 curl https://example.com\necho done", @@ -172,7 +197,7 @@ describe("session.prompt sandbox", () => { 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 Session.remove(session.id) + await removeSession(session.id) }, }) }) @@ -211,8 +236,8 @@ describe("session.prompt sandbox", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) - const out = await SessionPrompt.shell({ + const session = await createSession() + const out = await runShell({ sessionID: session.id, agent: "build", command: 'cat "$HOME/.ssh/secret"', @@ -223,7 +248,7 @@ describe("session.prompt sandbox", () => { 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 Session.remove(session.id) + await removeSession(session.id) }, }) }) @@ -263,8 +288,8 @@ describe("session.prompt sandbox", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) - const out = await SessionPrompt.shell({ + const session = await createSession() + const out = await runShell({ sessionID: session.id, agent: "build", command: '# opencode:unsandboxed needs secret access\ncat "$HOME/.ssh/secret"', @@ -275,7 +300,7 @@ describe("session.prompt sandbox", () => { 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 Session.remove(session.id) + await removeSession(session.id) }, }) }) @@ -314,15 +339,15 @@ describe("session.prompt sandbox", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) - const run = SessionPrompt.shell({ + 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 Permission.reply({ + await replyPermission({ requestID: pending[0].id, reply: "reject", }) @@ -333,7 +358,7 @@ describe("session.prompt sandbox", () => { 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 Session.remove(session.id) + await removeSession(session.id) }, }) }) @@ -373,9 +398,9 @@ describe("session.prompt sandbox", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await createSession() - const run1 = SessionPrompt.shell({ + const run1 = runShell({ sessionID: session.id, agent: "build", command: '# opencode:unsandboxed read foo\ncat "$HOME/.ssh/foo"', @@ -385,20 +410,20 @@ describe("session.prompt sandbox", () => { expect(pending1[0].permission).toBe("bash:unsandboxed") expect(pending1[0].patterns).toEqual(["cat *"]) expect(pending1[0].always).toEqual(["cat *"]) - await Permission.reply({ requestID: pending1[0].id, reply: "always" }) + 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 = SessionPrompt.shell({ + const run2 = runShell({ sessionID: session.id, agent: "build", command: '# opencode:unsandboxed read bar\ncat "$HOME/.ssh/bar"', }) await Bun.sleep(100) - const pending2 = await Permission.list() + const pending2 = await listPermissions() expect(pending2).toHaveLength(0) const out2 = await run2 const part2 = out2.parts[0] @@ -406,7 +431,7 @@ describe("session.prompt sandbox", () => { if (part2.state.status !== "completed") throw new Error("expected completed part") expect(part2.state.output).toContain("bar-content") - await Session.remove(session.id) + await removeSession(session.id) }, }) }) @@ -446,9 +471,9 @@ describe("session.prompt sandbox", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) + const session = await createSession() - const run1 = SessionPrompt.shell({ + const run1 = runShell({ sessionID: session.id, agent: "build", command: '# opencode:unsandboxed env read\nFOO=1 cat "$HOME/.ssh/a" && echo done', @@ -457,16 +482,16 @@ describe("session.prompt sandbox", () => { expect(pending1).toHaveLength(1) expect(pending1[0].patterns).toContain("cat *") expect(pending1[0].patterns).toContain("echo *") - await Permission.reply({ requestID: pending1[0].id, reply: "always" }) + await replyPermission({ requestID: pending1[0].id, reply: "always" }) await run1 - const run2 = SessionPrompt.shell({ + 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 Permission.list() + const pending2 = await listPermissions() expect(pending2).toHaveLength(0) const out2 = await run2 const part2 = out2.parts[0] @@ -474,7 +499,7 @@ describe("session.prompt sandbox", () => { if (part2.state.status !== "completed") throw new Error("expected completed part") expect(part2.state.output).toContain("b-content") - await Session.remove(session.id) + await removeSession(session.id) }, }) }) @@ -510,15 +535,15 @@ describe("session.prompt sandbox", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({}) - const run = SessionPrompt.shell({ + 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 Permission.reply({ + await replyPermission({ requestID: pending[0].id, reply: "reject", }) @@ -529,7 +554,7 @@ describe("session.prompt sandbox", () => { expect(part.state.output).toContain( "Explicit unsandboxed request was rejected; sandboxed fallback failed before command start", ) - await Session.remove(session.id) + await removeSession(session.id) }, }) } finally { diff --git a/packages/opencode/test/tool/bash-sandbox.test.ts b/packages/opencode/test/tool/bash-sandbox.test.ts index 1cbf96a01952..55fe07b9e448 100644 --- a/packages/opencode/test/tool/bash-sandbox.test.ts +++ b/packages/opencode/test/tool/bash-sandbox.test.ts @@ -3,16 +3,16 @@ 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/tool" +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 "../../src/filesystem" +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/truncate" +import { Truncate } from "../../src/tool" const env = { HOME: process.env.HOME, @@ -551,7 +551,7 @@ describe("tool.bash sandbox", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const reqs: Array<{ permission: string; patterns: string[]; always: string[] }> = [] + const reqs: Array<{ permission: string; patterns: readonly string[]; always: readonly string[] }> = [] const bash = await BashTool.init() await bash.execute( { @@ -597,8 +597,8 @@ describe("tool.bash sandbox", () => { fn: async () => { const reqs: Array<{ permission: string - patterns: string[] - always: string[] + patterns: readonly string[] + always: readonly string[] metadata: { command?: string } }> = [] const bash = await BashTool.init() @@ -650,7 +650,7 @@ describe("tool.bash sandbox", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const reqs: Array<{ permission: string; patterns: string[]; always: string[] }> = [] + const reqs: Array<{ permission: string; patterns: readonly string[]; always: readonly string[] }> = [] const bash = await BashTool.init() await bash.execute( { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d14fab191949..4366bc4b7b07 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, PTY initial spawns, and LSP launches + */ + 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) */ From 16344e530d1f1d3140489119bdab5d140f4ab4e0 Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Thu, 16 Apr 2026 13:17:17 +0200 Subject: [PATCH 22/23] remove clutter --- bun.lock | 2 + packages/app/src/i18n/ar.ts | 7 -- packages/app/src/i18n/br.ts | 9 -- packages/app/src/i18n/bs.ts | 9 -- packages/app/src/i18n/da.ts | 8 -- packages/app/src/i18n/de.ts | 9 -- packages/app/src/i18n/es.ts | 9 -- packages/app/src/i18n/fr.ts | 8 -- packages/app/src/i18n/ja.ts | 8 -- packages/app/src/i18n/ko.ts | 8 -- packages/app/src/i18n/no.ts | 8 -- packages/app/src/i18n/pl.ts | 9 -- packages/app/src/i18n/ru.ts | 8 -- packages/app/src/i18n/th.ts | 9 -- packages/app/src/i18n/tr.ts | 9 -- packages/app/src/i18n/zh.ts | 7 -- packages/app/src/i18n/zht.ts | 6 - packages/opencode/src/config/config.ts | 4 +- packages/opencode/src/lsp/launch.ts | 10 +- packages/opencode/src/lsp/server.ts | 103 ++++++++---------- packages/opencode/test/lsp/launch.test.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/web/src/content/docs/ar/config.mdx | 45 -------- .../web/src/content/docs/ar/permissions.mdx | 9 -- packages/web/src/content/docs/ar/tools.mdx | 6 - packages/web/src/content/docs/bs/config.mdx | 45 -------- .../web/src/content/docs/bs/permissions.mdx | 9 -- packages/web/src/content/docs/bs/tools.mdx | 6 - packages/web/src/content/docs/da/config.mdx | 45 -------- .../web/src/content/docs/da/permissions.mdx | 9 -- packages/web/src/content/docs/da/tools.mdx | 6 - packages/web/src/content/docs/de/config.mdx | 45 -------- .../web/src/content/docs/de/permissions.mdx | 9 -- packages/web/src/content/docs/de/tools.mdx | 6 - packages/web/src/content/docs/es/config.mdx | 45 -------- .../web/src/content/docs/es/permissions.mdx | 9 -- packages/web/src/content/docs/es/tools.mdx | 6 - packages/web/src/content/docs/fr/config.mdx | 45 -------- .../web/src/content/docs/fr/permissions.mdx | 9 -- packages/web/src/content/docs/fr/tools.mdx | 6 - packages/web/src/content/docs/it/config.mdx | 45 -------- .../web/src/content/docs/it/permissions.mdx | 9 -- packages/web/src/content/docs/it/tools.mdx | 6 - packages/web/src/content/docs/ja/config.mdx | 45 -------- .../web/src/content/docs/ja/permissions.mdx | 9 -- packages/web/src/content/docs/ja/tools.mdx | 6 - packages/web/src/content/docs/ko/config.mdx | 45 -------- .../web/src/content/docs/ko/permissions.mdx | 9 -- packages/web/src/content/docs/ko/tools.mdx | 6 - packages/web/src/content/docs/nb/config.mdx | 45 -------- .../web/src/content/docs/nb/permissions.mdx | 9 -- packages/web/src/content/docs/nb/tools.mdx | 6 - packages/web/src/content/docs/pl/config.mdx | 45 -------- .../web/src/content/docs/pl/permissions.mdx | 9 -- packages/web/src/content/docs/pl/tools.mdx | 6 - .../web/src/content/docs/pt-br/config.mdx | 45 -------- .../src/content/docs/pt-br/permissions.mdx | 9 -- packages/web/src/content/docs/pt-br/tools.mdx | 6 - packages/web/src/content/docs/ru/config.mdx | 45 -------- .../web/src/content/docs/ru/permissions.mdx | 9 -- packages/web/src/content/docs/ru/tools.mdx | 6 - packages/web/src/content/docs/th/config.mdx | 45 -------- .../web/src/content/docs/th/permissions.mdx | 9 -- packages/web/src/content/docs/th/tools.mdx | 6 - packages/web/src/content/docs/tr/config.mdx | 45 -------- .../web/src/content/docs/tr/permissions.mdx | 9 -- packages/web/src/content/docs/tr/tools.mdx | 6 - .../web/src/content/docs/zh-cn/config.mdx | 45 -------- .../src/content/docs/zh-cn/permissions.mdx | 9 -- packages/web/src/content/docs/zh-cn/tools.mdx | 6 - .../web/src/content/docs/zh-tw/config.mdx | 45 -------- .../src/content/docs/zh-tw/permissions.mdx | 9 -- packages/web/src/content/docs/zh-tw/tools.mdx | 6 - turbo.json | 4 +- 74 files changed, 56 insertions(+), 1222 deletions(-) diff --git a/bun.lock b/bun.lock index c9a55f5c8e92..0ba00b23f8b2 100644 --- a/bun.lock +++ b/bun.lock @@ -5848,6 +5848,8 @@ "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "find-up/path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 0d911f0f4e81..9e9a88c2d052 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -707,13 +707,6 @@ export const dict = { "settings.permissions.tool.list.description": "سرد الملفات داخل دليل", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "تشغيل أوامر shell", - "settings.permissions.tool.bash_unsandboxed.title": "Bash (بدون صندوق حماية)", - "settings.permissions.tool.bash_unsandboxed.description": "أعد محاولة تشغيل أمر shell بدون قيود صندوق الحماية", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (بدون صندوق حماية)", - "settings.permissions.tool.bash_unsandboxed_network.description": - "تم تعطيل الشبكات في صندوق الحماية، لذلك ربما فشلت المحاولة السابقة بسبب صندوق الحماية.", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (بدون صندوق حماية)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": "طلب الأمر التشغيل بدون قيود صندوق الحماية.", "settings.permissions.tool.task.title": "مهمة", "settings.permissions.tool.task.description": "تشغيل الوكلاء الفرعيين", "settings.permissions.tool.skill.title": "مهارة", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 5b51f9e92902..5fd1aee76321 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -717,15 +717,6 @@ export const dict = { "settings.permissions.tool.list.description": "Listar arquivos dentro de um diretório", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Executar comandos shell", - "settings.permissions.tool.bash_unsandboxed.title": "Bash (sem sandbox)", - "settings.permissions.tool.bash_unsandboxed.description": - "Tente novamente um comando de shell sem restrições de sandbox", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (sem sandbox)", - "settings.permissions.tool.bash_unsandboxed_network.description": - "A rede do sandbox está desativada, então a tentativa anterior pode ter falhado por causa do sandbox.", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (sem sandbox)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": - "O comando solicitou a execução sem restrições de sandbox.", "settings.permissions.tool.task.title": "Tarefa", "settings.permissions.tool.task.description": "Lançar sub-agentes", "settings.permissions.tool.skill.title": "Habilidade", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 8df4602a265f..f872db1f00f9 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -791,15 +791,6 @@ export const dict = { "settings.permissions.tool.list.description": "Listanje datoteka unutar direktorija", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Pokretanje shell komandi", - "settings.permissions.tool.bash_unsandboxed.title": "Bash (bez sandboxa)", - "settings.permissions.tool.bash_unsandboxed.description": - "Ponovo pokušaj pokrenuti shell naredbu bez ograničenja sandboxa", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (bez sandboxa)", - "settings.permissions.tool.bash_unsandboxed_network.description": - "Mreža u sandboxu je onemogućena, pa je prethodni pokušaj možda neuspješno završio zbog sandboxa.", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (bez sandboxa)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": - "Naredba je zatražila pokretanje bez ograničenja sandboxa.", "settings.permissions.tool.task.title": "Zadatak", "settings.permissions.tool.task.description": "Pokretanje pod-agenta", "settings.permissions.tool.skill.title": "Vještina", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 1455d28e3001..82f4fe3f6389 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -785,14 +785,6 @@ export const dict = { "settings.permissions.tool.list.description": "List filer i en mappe", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Kør shell-kommandoer", - "settings.permissions.tool.bash_unsandboxed.title": "Bash (uden sandbox)", - "settings.permissions.tool.bash_unsandboxed.description": "Prøv en shell-kommando igen uden sandbox-begrænsninger", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (uden sandbox)", - "settings.permissions.tool.bash_unsandboxed_network.description": - "Sandbox-netværk er deaktiveret, så det forrige forsøg kan være mislykket på grund af sandboxen.", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (uden sandbox)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": - "Kommandoen anmodede om at køre uden sandbox-begrænsninger.", "settings.permissions.tool.task.title": "Opgave", "settings.permissions.tool.task.description": "Start underagenter", "settings.permissions.tool.skill.title": "Færdighed", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index a377e476e9c4..d5b95459ac79 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -728,15 +728,6 @@ export const dict = { "settings.permissions.tool.list.description": "Dateien in einem Verzeichnis auflisten", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Shell-Befehle ausführen", - "settings.permissions.tool.bash_unsandboxed.title": "Bash (ohne Sandbox)", - "settings.permissions.tool.bash_unsandboxed.description": - "Einen Shell-Befehl ohne Sandbox-Einschränkungen erneut ausführen", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (ohne Sandbox)", - "settings.permissions.tool.bash_unsandboxed_network.description": - "Das Sandbox-Netzwerk ist deaktiviert, daher könnte der vorherige Versuch an der Sandbox gescheitert sein.", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (ohne Sandbox)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": - "Der Befehl hat angefordert, ohne Sandbox-Einschränkungen ausgeführt zu werden.", "settings.permissions.tool.task.title": "Aufgabe", "settings.permissions.tool.task.description": "Unteragenten starten", "settings.permissions.tool.skill.title": "Fähigkeit", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 852df65e5cfc..12bc45cf38bd 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -798,15 +798,6 @@ export const dict = { "settings.permissions.tool.list.description": "Listar archivos dentro de un directorio", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Ejecutar comandos de shell", - "settings.permissions.tool.bash_unsandboxed.title": "Bash (sin sandbox)", - "settings.permissions.tool.bash_unsandboxed.description": - "Reintentar un comando de shell sin restricciones de sandbox", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (sin sandbox)", - "settings.permissions.tool.bash_unsandboxed_network.description": - "La red del sandbox está deshabilitada, por lo que el intento anterior pudo haber fallado a causa del sandbox.", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (sin sandbox)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": - "El comando solicitó ejecutarse sin restricciones de sandbox.", "settings.permissions.tool.task.title": "Tarea", "settings.permissions.tool.task.description": "Lanzar sub-agentes", "settings.permissions.tool.skill.title": "Habilidad", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index f4fe70bc7161..6c98b9ca1e50 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -726,14 +726,6 @@ export const dict = { "settings.permissions.tool.list.description": "Lister les fichiers dans un répertoire", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Exécuter des commandes shell", - "settings.permissions.tool.bash_unsandboxed.title": "Bash (sans sandbox)", - "settings.permissions.tool.bash_unsandboxed.description": "Réessayer une commande shell sans restrictions de sandbox", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (sans sandbox)", - "settings.permissions.tool.bash_unsandboxed_network.description": - "Le réseau du sandbox est désactivé, donc la tentative précédente a peut-être échoué à cause du sandbox.", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (sans sandbox)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": - "La commande a demandé à s'exécuter sans restrictions de sandbox.", "settings.permissions.tool.task.title": "Tâche", "settings.permissions.tool.task.description": "Lancer des sous-agents", "settings.permissions.tool.skill.title": "Compétence", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index b488cd7b2a67..76783341270b 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -712,14 +712,6 @@ export const dict = { "settings.permissions.tool.list.description": "ディレクトリ内のファイル一覧表示", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "シェルコマンドの実行", - "settings.permissions.tool.bash_unsandboxed.title": "Bash(サンドボックスなし)", - "settings.permissions.tool.bash_unsandboxed.description": "サンドボックスの制限なしでシェルコマンドを再試行します", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash(サンドボックスなし)", - "settings.permissions.tool.bash_unsandboxed_network.description": - "サンドボックスのネットワークが無効になっているため、前回の試行はサンドボックスが原因で失敗した可能性があります。", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash(サンドボックスなし)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": - "このコマンドはサンドボックスの制限なしで実行するよう要求しました。", "settings.permissions.tool.task.title": "タスク", "settings.permissions.tool.task.description": "サブエージェントの起動", "settings.permissions.tool.skill.title": "スキル", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 9d60ff229f18..76bf33df6fba 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -707,14 +707,6 @@ export const dict = { "settings.permissions.tool.list.description": "디렉터리 내 파일 나열", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "셸 명령어 실행", - "settings.permissions.tool.bash_unsandboxed.title": "Bash (샌드박스 없음)", - "settings.permissions.tool.bash_unsandboxed.description": "샌드박스 제한 없이 셸 명령을 다시 시도합니다", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (샌드박스 없음)", - "settings.permissions.tool.bash_unsandboxed_network.description": - "샌드박스 네트워킹이 비활성화되어 있으므로 이전 시도는 샌드박스 때문에 실패했을 수 있습니다.", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (샌드박스 없음)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": - "명령이 샌드박스 제한 없이 실행되도록 요청했습니다.", "settings.permissions.tool.task.title": "작업", "settings.permissions.tool.task.description": "하위 에이전트 실행", "settings.permissions.tool.skill.title": "기술", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index a8c86b25363d..75e557b16b30 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -792,14 +792,6 @@ export const dict = { "settings.permissions.tool.list.description": "List filer i en mappe", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Kjør shell-kommandoer", - "settings.permissions.tool.bash_unsandboxed.title": "Bash (uten sandbox)", - "settings.permissions.tool.bash_unsandboxed.description": "Prøv en skalkommando på nytt uten sandbox-begrensninger", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (uten sandbox)", - "settings.permissions.tool.bash_unsandboxed_network.description": - "Sandbox-nettverk er deaktivert, så det forrige forsøket kan ha mislyktes på grunn av sandboxen.", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (uten sandbox)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": - "Kommandoen ba om å kjøre uten sandbox-begrensninger.", "settings.permissions.tool.task.title": "Oppgave", "settings.permissions.tool.task.description": "Start underagenter", "settings.permissions.tool.skill.title": "Ferdighet", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index bc9c94bffb31..0ab4a6906cd9 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -714,15 +714,6 @@ export const dict = { "settings.permissions.tool.list.description": "Wyświetlanie listy plików w katalogu", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Uruchamianie poleceń powłoki", - "settings.permissions.tool.bash_unsandboxed.title": "Bash (bez sandboxa)", - "settings.permissions.tool.bash_unsandboxed.description": - "Ponów próbę uruchomienia polecenia powłoki bez ograniczeń sandboxa", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (bez sandboxa)", - "settings.permissions.tool.bash_unsandboxed_network.description": - "Sieć sandboxa jest wyłączona, więc poprzednia próba mogła nie powieść się z powodu sandboxa.", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (bez sandboxa)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": - "Polecenie zażądało uruchomienia bez ograniczeń sandboxa.", "settings.permissions.tool.task.title": "Zadanie", "settings.permissions.tool.task.description": "Uruchamianie pod-agentów", "settings.permissions.tool.skill.title": "Umiejętność", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 7c6230072637..135c8e66c48c 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -793,14 +793,6 @@ export const dict = { "settings.permissions.tool.list.description": "Список файлов в директории", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Запуск команд оболочки", - "settings.permissions.tool.bash_unsandboxed.title": "Bash (без песочницы)", - "settings.permissions.tool.bash_unsandboxed.description": "Повторить запуск shell-команды без ограничений песочницы", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (без песочницы)", - "settings.permissions.tool.bash_unsandboxed_network.description": - "Сеть в песочнице отключена, поэтому предыдущая попытка могла завершиться неудачей из-за песочницы.", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (без песочницы)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": - "Команда запросила запуск без ограничений песочницы.", "settings.permissions.tool.task.title": "Task", "settings.permissions.tool.task.description": "Запуск подагентов", "settings.permissions.tool.skill.title": "Skill", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 70bff54f6eb3..81674df32dda 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -781,15 +781,6 @@ export const dict = { "settings.permissions.tool.list.description": "แสดงรายการไฟล์ภายในไดเรกทอรี", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "เรียกใช้คำสั่งเชลล์", - "settings.permissions.tool.bash_unsandboxed.title": "Bash (ไม่มีแซนด์บ็อกซ์)", - "settings.permissions.tool.bash_unsandboxed.description": - "ลองเรียกใช้คำสั่ง shell อีกครั้งโดยไม่มีข้อจำกัดของแซนด์บ็อกซ์", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (ไม่มีแซนด์บ็อกซ์)", - "settings.permissions.tool.bash_unsandboxed_network.description": - "เครือข่ายของแซนด์บ็อกซ์ถูกปิดใช้งานอยู่ ดังนั้นความพยายามก่อนหน้านี้อาจล้มเหลวเพราะแซนด์บ็อกซ์", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (ไม่มีแซนด์บ็อกซ์)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": - "คำสั่งนี้ขอให้ทำงานโดยไม่มีข้อจำกัดของแซนด์บ็อกซ์", "settings.permissions.tool.task.title": "งาน", "settings.permissions.tool.task.description": "เปิดเอเจนต์ย่อย", "settings.permissions.tool.skill.title": "ทักษะ", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index cde39d310faf..f3cb3ab464b7 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -800,15 +800,6 @@ export const dict = { "settings.permissions.tool.list.description": "Bir dizindeki dosyaları listele", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Kabuk komutları çalıştır", - "settings.permissions.tool.bash_unsandboxed.title": "Bash (sandbox olmadan)", - "settings.permissions.tool.bash_unsandboxed.description": - "Bir kabuk komutunu sandbox kısıtlamaları olmadan yeniden dene", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (sandbox olmadan)", - "settings.permissions.tool.bash_unsandboxed_network.description": - "Sandbox ağı devre dışı, bu yüzden önceki deneme sandbox nedeniyle başarısız olmuş olabilir.", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (sandbox olmadan)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": - "Komut, sandbox kısıtlamaları olmadan çalıştırılmayı istedi.", "settings.permissions.tool.task.title": "Görev", "settings.permissions.tool.task.description": "Alt ajanlar başlat", "settings.permissions.tool.skill.title": "Beceri", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index a9d94b17b29c..d95bfd19ba59 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -779,13 +779,6 @@ export const dict = { "settings.permissions.tool.list.description": "列出目录中的文件", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "运行 shell 命令", - "settings.permissions.tool.bash_unsandboxed.title": "Bash(无沙箱)", - "settings.permissions.tool.bash_unsandboxed.description": "在没有沙箱限制的情况下重试 shell 命令", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash(无沙箱)", - "settings.permissions.tool.bash_unsandboxed_network.description": - "沙箱网络已被禁用,因此上一次尝试可能因沙箱而失败。", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash(无沙箱)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": "该命令请求在没有沙箱限制的情况下运行。", "settings.permissions.tool.task.title": "任务", "settings.permissions.tool.task.description": "启动子智能体", "settings.permissions.tool.skill.title": "技能", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 420bfdecee1a..4a88ca4fc833 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -775,12 +775,6 @@ export const dict = { "settings.permissions.tool.list.description": "列出目錄中的檔案", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "執行 shell 命令", - "settings.permissions.tool.bash_unsandboxed.title": "Bash(無沙箱)", - "settings.permissions.tool.bash_unsandboxed.description": "在沒有沙箱限制的情況下重試 shell 指令", - "settings.permissions.tool.bash_unsandboxed_network.title": "Bash(無沙箱)", - "settings.permissions.tool.bash_unsandboxed_network.description": "沙箱網路已停用,因此上一次嘗試可能因沙箱而失敗。", - "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash(無沙箱)", - "settings.permissions.tool.bash_unsandboxed_explicit.description": "此指令要求在沒有沙箱限制的情況下執行。", "settings.permissions.tool.task.title": "Task", "settings.permissions.tool.task.description": "啟動子代理程式", "settings.permissions.tool.skill.title": "Skill", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 741bf0b6ddd6..f1d00cec99fc 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -93,7 +93,7 @@ const SandboxPresetConfig = Schema.Struct({ const SandboxConfig = Schema.Struct({ enabled: Schema.optional(Schema.Boolean).annotate({ - description: "Enable macOS sandboxing for bash, session shell commands, PTY initial spawns, and LSP launches", + 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)", @@ -132,7 +132,7 @@ const SandboxConfig = Schema.Struct({ enabled: z .boolean() .optional() - .describe("Enable macOS sandboxing for bash, session shell commands, PTY initial spawns, and LSP launches"), + .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"]) diff --git a/packages/opencode/src/lsp/launch.ts b/packages/opencode/src/lsp/launch.ts index dba61549a92e..fb84666b015f 100644 --- a/packages/opencode/src/lsp/launch.ts +++ b/packages/opencode/src/lsp/launch.ts @@ -3,15 +3,13 @@ import { Process } from "../util" type Child = Process.Child & ChildProcessWithoutNullStreams -export function spawn(cmd: string, args: string[], opts?: Process.Options): Promise -export function spawn(cmd: string, opts?: Process.Options): Promise -export async function spawn(cmd: string, argsOrOpts?: string[] | Process.Options, opts?: Process.Options) { +export function spawn(cmd: string, args: string[], opts?: Process.Options): Child +export function spawn(cmd: string, opts?: Process.Options): Child +export function spawn(cmd: string, argsOrOpts?: string[] | Process.Options, opts?: Process.Options) { const args = Array.isArray(argsOrOpts) ? [...argsOrOpts] : [] const cfg = Array.isArray(argsOrOpts) ? opts : argsOrOpts - const cwd = cfg?.cwd ?? process.cwd() const proc = Process.spawn([cmd, ...args], { - ...(cfg ?? {}), - cwd, + ...cfg, stdin: "pipe", stdout: "pipe", stderr: "pipe", diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 6a1f8a3d539f..918236806356 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -84,7 +84,7 @@ export const Deno: Info = { return } return { - process: await spawn(deno, ["lsp"], { + process: spawn(deno, ["lsp"], { cwd: root, }), } @@ -104,16 +104,7 @@ export const Typescript: Info = { if (!tsserver) return const bin = await Npm.which("typescript-language-server") if (!bin) return - const args = ["--stdio", "--tsserver-log-verbosity", "off", "--tsserver-path", tsserver] - - if ( - !(await pathExists(path.join(root, "tsconfig.json"))) && - !(await pathExists(path.join(root, "jsconfig.json"))) - ) { - args.push("--ignore-node-modules") - } - - const proc = await spawn(bin, args, { + const proc = spawn(bin, ["--stdio"], { cwd: root, env: { ...process.env, @@ -144,7 +135,7 @@ export const Vue: Info = { binary = resolved } args.push("--stdio") - const proc = await spawn(binary, args, { + const proc = spawn(binary, args, { cwd: root, env: { ...process.env, @@ -203,7 +194,7 @@ export const ESLint: Info = { log.info("installed VS Code ESLint server", { serverPath }) } - const proc = await spawn("node", [serverPath, "--stdio"], { + const proc = spawn("node", [serverPath, "--stdio"], { cwd: root, env: { ...process.env, @@ -257,13 +248,13 @@ export const Oxlint: Info = { } if (lintBin) { - const proc = await spawn(lintBin, ["--help"]) + const proc = spawn(lintBin, ["--help"]) await proc.exited if (proc.stdout) { const help = await text(proc.stdout) if (help.includes("--lsp")) { return { - process: await spawn(lintBin, ["--lsp"], { + process: spawn(lintBin, ["--lsp"], { cwd: root, }), } @@ -278,7 +269,7 @@ export const Oxlint: Info = { } if (serverBin) { return { - process: await spawn(serverBin, [], { + process: spawn(serverBin, [], { cwd: root, }), } @@ -338,7 +329,7 @@ export const Biome: Info = { args = ["lsp-proxy", "--stdio"] } - const proc = await spawn(bin, args, { + const proc = spawn(bin, args, { cwd: root, env: { ...process.env, @@ -383,7 +374,7 @@ export const Gopls: Info = { }) } return { - process: await spawn(bin!, { + process: spawn(bin!, { cwd: root, }), } @@ -421,7 +412,7 @@ export const Rubocop: Info = { }) } return { - process: await spawn(bin!, ["--lsp"], { + process: spawn(bin!, ["--lsp"], { cwd: root, }), } @@ -479,7 +470,7 @@ export const Ty: Info = { return } - const proc = await spawn(binary, ["server"], { + const proc = spawn(binary, ["server"], { cwd: root, }) @@ -521,7 +512,7 @@ export const Pyright: Info = { } } - const proc = await spawn(binary, args, { + const proc = spawn(binary, args, { cwd: root, env: { ...process.env, @@ -590,7 +581,7 @@ export const ElixirLS: Info = { } return { - process: await spawn(binary, { + process: spawn(binary, { cwd: root, }), } @@ -702,7 +693,7 @@ export const Zls: Info = { } return { - process: await spawn(bin, { + process: spawn(bin, { cwd: root, }), } @@ -739,7 +730,7 @@ export const CSharp: Info = { } return { - process: await spawn(bin, { + process: spawn(bin, { cwd: root, }), } @@ -776,7 +767,7 @@ export const FSharp: Info = { } return { - process: await spawn(bin, { + process: spawn(bin, { cwd: root, }), } @@ -793,7 +784,7 @@ export const SourceKit: Info = { const sourcekit = which("sourcekit-lsp") if (sourcekit) { return { - process: await spawn(sourcekit, { + process: spawn(sourcekit, { cwd: root, }), } @@ -810,7 +801,7 @@ export const SourceKit: Info = { const bin = lspLoc.text.trim() return { - process: await spawn(bin, { + process: spawn(bin, { cwd: root, }), } @@ -856,7 +847,7 @@ export const RustAnalyzer: Info = { return } return { - process: await spawn(bin, { + process: spawn(bin, { cwd: root, }), } @@ -872,7 +863,7 @@ export const Clangd: Info = { const fromPath = which("clangd") if (fromPath) { return { - process: await spawn(fromPath, args, { + process: spawn(fromPath, args, { cwd: root, }), } @@ -882,7 +873,7 @@ export const Clangd: Info = { const direct = path.join(Global.Path.bin, "clangd" + ext) if (await Filesystem.exists(direct)) { return { - process: await spawn(direct, args, { + process: spawn(direct, args, { cwd: root, }), } @@ -895,7 +886,7 @@ export const Clangd: Info = { const candidate = path.join(Global.Path.bin, entry.name, "bin", "clangd" + ext) if (await Filesystem.exists(candidate)) { return { - process: await spawn(candidate, args, { + process: spawn(candidate, args, { cwd: root, }), } @@ -1002,7 +993,7 @@ export const Clangd: Info = { log.info(`installed clangd`, { bin }) return { - process: await spawn(bin, args, { + process: spawn(bin, args, { cwd: root, }), } @@ -1023,7 +1014,7 @@ export const Svelte: Info = { binary = resolved } args.push("--stdio") - const proc = await spawn(binary, args, { + const proc = spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1057,7 +1048,7 @@ export const Astro: Info = { binary = resolved } args.push("--stdio") - const proc = await spawn(binary, args, { + const proc = spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1170,7 +1161,7 @@ export const JDTLS: Info = { ) const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-jdtls-data")) return { - process: await spawn( + process: spawn( java, [ "-jar", @@ -1287,7 +1278,7 @@ export const KotlinLS: Info = { return } return { - process: await spawn(launcherScript, ["--stdio"], { + process: spawn(launcherScript, ["--stdio"], { cwd: root, }), } @@ -1308,7 +1299,7 @@ export const YamlLS: Info = { binary = resolved } args.push("--stdio") - const proc = await spawn(binary, args, { + const proc = spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1454,7 +1445,7 @@ export const LuaLS: Info = { } return { - process: await spawn(bin, { + process: spawn(bin, { cwd: root, }), } @@ -1475,7 +1466,7 @@ export const PHPIntelephense: Info = { binary = resolved } args.push("--stdio") - const proc = await spawn(binary, args, { + const proc = spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1503,7 +1494,7 @@ export const Prisma: Info = { return } return { - process: await spawn(prisma, ["language-server"], { + process: spawn(prisma, ["language-server"], { cwd: root, }), } @@ -1521,7 +1512,7 @@ export const Dart: Info = { return } return { - process: await spawn(dart, ["language-server", "--lsp"], { + process: spawn(dart, ["language-server", "--lsp"], { cwd: root, }), } @@ -1539,7 +1530,7 @@ export const Ocaml: Info = { return } return { - process: await spawn(bin, { + process: spawn(bin, { cwd: root, }), } @@ -1559,7 +1550,7 @@ export const BashLS: Info = { binary = resolved } args.push("start") - const proc = await spawn(binary, args, { + const proc = spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1639,7 +1630,7 @@ export const TerraformLS: Info = { } return { - process: await spawn(bin, ["serve"], { + process: spawn(bin, ["serve"], { cwd: root, }), initialization: { @@ -1733,7 +1724,7 @@ export const TexLab: Info = { } return { - process: await spawn(bin, { + process: spawn(bin, { cwd: root, }), } @@ -1754,7 +1745,7 @@ export const DockerfileLS: Info = { binary = resolved } args.push("--stdio") - const proc = await spawn(binary, args, { + const proc = spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1777,7 +1768,7 @@ export const Gleam: Info = { return } return { - process: await spawn(gleam, ["lsp"], { + process: spawn(gleam, ["lsp"], { cwd: root, }), } @@ -1798,7 +1789,7 @@ export const Clojure: Info = { return } return { - process: await spawn(bin, ["listen"], { + process: spawn(bin, ["listen"], { cwd: root, }), } @@ -1826,7 +1817,7 @@ export const Nixd: Info = { return } return { - process: await spawn(nixd, [], { + process: spawn(nixd, [], { cwd: root, env: { ...process.env, @@ -1923,7 +1914,7 @@ export const Tinymist: Info = { } return { - process: await spawn(bin, { cwd: root }), + process: spawn(bin, { cwd: root }), } }, } @@ -1939,7 +1930,7 @@ export const HLS: Info = { return } return { - process: await spawn(bin, ["--lsp"], { + process: spawn(bin, ["--lsp"], { cwd: root, }), } @@ -1957,13 +1948,9 @@ export const JuliaLS: Info = { return } return { - process: await spawn( - julia, - ["--startup-file=no", "--history-file=no", "-e", "using LanguageServer; runserver()"], - { - cwd: root, - }, - ), + process: spawn(julia, ["--startup-file=no", "--history-file=no", "-e", "using LanguageServer; runserver()"], { + cwd: root, + }), } }, } diff --git a/packages/opencode/test/lsp/launch.test.ts b/packages/opencode/test/lsp/launch.test.ts index 0514ff72175c..258e92524d86 100644 --- a/packages/opencode/test/lsp/launch.test.ts +++ b/packages/opencode/test/lsp/launch.test.ts @@ -15,7 +15,7 @@ describe("lsp.launch", () => { await fs.mkdir(dir, { recursive: true }) await Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n") - const proc = await spawn(file, ["--stdio"]) + const proc = spawn(file, ["--stdio"]) expect(await proc.exited).toBe(0) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4366bc4b7b07..5edc2c014ac5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1664,7 +1664,7 @@ export type Config = { batch_tool?: boolean sandbox?: { /** - * Enable macOS sandboxing for bash, session shell commands, PTY initial spawns, and LSP launches + * Enable macOS sandboxing for bash, session shell commands, and PTY initial spawns */ enabled?: boolean /** diff --git a/packages/web/src/content/docs/ar/config.mdx b/packages/web/src/content/docs/ar/config.mdx index 784da8015d57..5a1c294bf216 100644 --- a/packages/web/src/content/docs/ar/config.mdx +++ b/packages/web/src/content/docs/ar/config.mdx @@ -624,51 +624,6 @@ opencode run "Hello world" الخيارات التجريبية غير مستقرة. قد تتغير أو تُزال دون إشعار. ::: -### Sandbox - -يمكن لـ OpenCode تشغيل أوامر bash وأوامر shell للجلسة وبدء PTY داخل بيئة معزولة (sandbox) على macOS. -ميزة Sandbox تجريبية واختيارية ومعطّلة افتراضيًا. - -```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"] - } - } -} -``` - -الخيارات المتاحة: - -| الخيار | النوع | الوصف | -| ------------------------- | ---------------------------------- | -------------------------------------------------------------------------------- | -| `enabled` | `boolean` | تفعيل البيئة المعزولة لمسارات التنفيذ المدعومة على macOS. | -| `preset` | `string` | اختيار إعداد مسبق مدمج (`default` أو `strict` أو `network`) أو اسم مخصص. | -| `mode` | `"workspace-write" \| "read-only"` | تجاوز وضع الإعداد المسبق. | -| `network` | `boolean` | تجاوز ما إذا كان الوصول إلى الشبكة مسموحًا. | -| `protected_roots` | `string[]` | مسارات نسبية لمساحة العمل تبقى محمية ضد الكتابة حتى داخل الجذور القابلة للكتابة. | -| `extra_read_roots` | `string[]` | مسارات مطلقة إضافية يمكن للبيئة المعزولة قراءتها. | -| `extra_write_roots` | `string[]` | مسارات مطلقة إضافية يمكن للبيئة المعزولة الكتابة فيها. | -| `extra_deny_paths` | `string[]` | مسارات مطلقة إضافية يجب على البيئة المعزولة رفضها. | -| `excluded_commands` | `string[]` | بادئات أوامر يجب حظرها قبل التنفيذ. | -| `allow_unsandboxed_retry` | `boolean` | السماح بإعادة محاولة منفصلة عبر إذن `bash:unsandboxed` بعد رفض البيئة المعزولة. | -| `fail_if_unavailable` | `boolean` | فشل صريح عندما تكون البيئة المعزولة مفعّلة لكن لا يمكن تنشيطها. | -| `presets` | `Record` | تعريف إعدادات مسبقة مخصصة مع `mode` و`network` والجذور وتجاوزات الأذونات. | - -:::note -يمكن للأوامر المعزولة قراءة جذور النظام المدمجة مثل `/bin` و`/usr` و`/opt/homebrew` و`/System` و`/Library` و`/dev` و`/tmp` و`/private/etc`. -تظل المسارات الحساسة في المنزل مثل `~/.ssh` و`~/.gnupg` وأدلة بيانات الاعتماد السحابية مرفوضة افتراضيًا. -راجع [سياسة الأمان](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) لنموذج التهديد الكامل والأسطح المغطاة والقيود الحالية. -::: - --- ## المتغيرات diff --git a/packages/web/src/content/docs/ar/permissions.mdx b/packages/web/src/content/docs/ar/permissions.mdx index ad9ee26504f5..bb21d00b243a 100644 --- a/packages/web/src/content/docs/ar/permissions.mdx +++ b/packages/web/src/content/docs/ar/permissions.mdx @@ -134,7 +134,6 @@ description: تحكّم في الإجراءات التي تتطلب موافقة - `glob` — مطابقة أسماء الملفات (يطابق نمط الـ glob) - `grep` — البحث في المحتوى (يطابق نمط regex) - `bash` — تشغيل أوامر shell (يطابق الأوامر المُحلَّلة مثل `git status --porcelain`) -- `bash:unsandboxed` — إعادة تشغيل أمر shell خارج البيئة المعزولة بعد الرفض أو بعد طلب صريح لتشغيله بدون عزل - `task` — تشغيل وكلاء فرعيين (يطابق نوع الوكيل الفرعي) - `skill` — تحميل مهارة (يطابق اسم المهارة) - `lsp` — تشغيل استعلامات LSP (حاليًا دون قواعد دقيقة) @@ -234,11 +233,3 @@ Only analyze code and suggest changes. :::tip استخدم مطابقة الأنماط للأوامر التي تحتوي على معاملات. يسمح `"grep *"` بتنفيذ `grep pattern file.txt`، بينما سيحظر `"grep"` وحده ذلك. تعمل أوامر مثل `git status` للسلوك الافتراضي، لكنها تتطلب إذنًا صريحًا (مثل `"git status *"`) عند تمرير معاملات. ::: - ---- - -## التفاعل مع البيئة المعزولة - -عند تفعيل البيئة المعزولة على macOS، يمكن أن يؤدي حظر أمر bash إلى تشغيل طلب إذن منفصل `bash:unsandboxed`. -يحدث ذلك عندما يكتشف OpenCode رفضًا محتملًا من البيئة المعزولة، أو عندما يطلب الأمر صراحةً تخطي البيئة المعزولة عبر `# opencode:unsandboxed ` في أول سطر غير فارغ. -اضبط البيئة المعزولة نفسها في [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/ar/tools.mdx b/packages/web/src/content/docs/ar/tools.mdx index ca6fbe0271bf..3f3c9ee06850 100644 --- a/packages/web/src/content/docs/ar/tools.mdx +++ b/packages/web/src/content/docs/ar/tools.mdx @@ -60,12 +60,6 @@ description: إدارة الأدوات التي يمكن لـ LLM استخدام تتيح هذه الأداة لـ LLM تشغيل أوامر terminal مثل `npm install` و`git status` أو أي أمر shell آخر. -:::note -عند تفعيل البيئة المعزولة على macOS، يعمل bash مع قيود على نظام الملفات ويمكنه طلب إعادة محاولة منفصلة بدون عزل عند الحاجة. -إذا كنت تعلم أن أمرًا ما يجب أن يبدأ خارج البيئة المعزولة، ضع `# opencode:unsandboxed ` في أول سطر غير فارغ من الأمر. -راجع [إعدادات البيئة المعزولة](/docs/config#sandbox) للسلوك المدعوم والقيود. -::: - --- ### edit diff --git a/packages/web/src/content/docs/bs/config.mdx b/packages/web/src/content/docs/bs/config.mdx index dcada7760443..3183a2f92df9 100644 --- a/packages/web/src/content/docs/bs/config.mdx +++ b/packages/web/src/content/docs/bs/config.mdx @@ -624,51 +624,6 @@ Ključ `experimental` sadrži opcije koje su u aktivnom razvoju. Eksperimentalne opcije nisu stabilne. Mogu se promijeniti ili ukloniti bez prethodne najave. ::: -### Sandbox - -OpenCode može pokrenuti bash komande, shell komande sesije i PTY pokretanje u sandbox okruženju na macOS-u. -Sandboxing je eksperimentalan, opcionalan i isključen po defaultu. - -```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"] - } - } -} -``` - -Dostupne opcije: - -| Opcija | Tip | Opis | -| ------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------- | -| `enabled` | `boolean` | Omogući sandboxing za podržane macOS putanje izvršavanja. | -| `preset` | `string` | Izaberite ugrađeni preset (`default`, `strict`, `network`) ili prilagođeni naziv. | -| `mode` | `"workspace-write" \| "read-only"` | Zaobiđi preset mod. | -| `network` | `boolean` | Zaobiđi da li je pristup mreži dozvoljen. | -| `protected_roots` | `string[]` | Putanje relativne radnom prostoru koje ostaju zaštićene od pisanja čak i unutar piših korijena. | -| `extra_read_roots` | `string[]` | Dodatne apsolutne putanje koje sandbox može čitati. | -| `extra_write_roots` | `string[]` | Dodatne apsolutne putanje u koje sandbox može pisati. | -| `extra_deny_paths` | `string[]` | Dodatne apsolutne putanje koje sandbox mora odbiti. | -| `excluded_commands` | `string[]` | Prefiksi komandi koji moraju biti blokirani prije izvršavanja. | -| `allow_unsandboxed_retry` | `boolean` | Dozvoli odvojeni pokušaj putem `bash:unsandboxed` dozvole nakon odbijanja sandboxa. | -| `fail_if_unavailable` | `boolean` | Potpuni neuspjeh kada je sandboxing omogućen ali se ne može aktivirati. | -| `presets` | `Record` | Definirajte prilagođene presete sa `mode`, `network`, korijenima i dozvolama. | - -:::note -Sandbox komande mogu čitati ugrađene sistemske korijene kao što su `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` i `/private/etc`. -Osjetljive putanje u kućnom direktoriju kao što su `~/.ssh`, `~/.gnupg` i direktoriji cloud kredencijala ostaju odbijene po defaultu. -Pogledajte [sigurnosnu politiku](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) za potpuni model prijetnji, pokrivene površine i trenutna ograničenja. -::: - --- ## Varijable diff --git a/packages/web/src/content/docs/bs/permissions.mdx b/packages/web/src/content/docs/bs/permissions.mdx index dedf5b9db7d0..e27fa130b362 100644 --- a/packages/web/src/content/docs/bs/permissions.mdx +++ b/packages/web/src/content/docs/bs/permissions.mdx @@ -129,7 +129,6 @@ Dozvole OpenCode su označene imenom alata, plus nekoliko sigurnosnih mjera: - `glob` — globbiranje fajla (odgovara glob uzorku) - `grep` — pretraga sadržaja (podudara se sa regularnim izrazom) - `bash` — izvođenje komandi ljuske (podudara se s raščlanjenim komandama kao što je `git status --porcelain`) -- `bash:unsandboxed` — ponovno pokretanje shell komande izvan sandboxa nakon odbijanja ili nakon eksplicitnog zahtjeva za pokretanje bez sandboxa - `task` — pokretanje subagenta (odgovara tipu podagenta) - `skill` — učitavanje vještine (odgovara nazivu vještine) - `lsp` — pokretanje LSP upita (trenutno negranularno) @@ -227,11 +226,3 @@ Only analyze code and suggest changes. :::tip Koristite podudaranje uzoraka za naredbe s argumentima. `"grep *"` dozvoljava `grep pattern file.txt`, dok bi ga samo `"grep"` blokirao. Naredbe poput `git status` rade za zadano ponašanje, ali zahtijevaju eksplicitnu dozvolu (kao `"git status *"`) kada se prosljeđuju argumenti. ::: - ---- - -## Interakcija sa sandboxom - -Kada je macOS sandboxing omogućen, blokirana bash komanda može pokrenuti odvojeni `bash:unsandboxed` zahtjev za dozvolom. -Ovo se dešava kada OpenCode detektuje vjerovatno odbijanje sandboxa, ili kada komanda eksplicitno traži preskakanje sandboxa sa `# opencode:unsandboxed ` na prvom nepraznom redu. -Konfigurirajte sam sandbox u [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/bs/tools.mdx b/packages/web/src/content/docs/bs/tools.mdx index 866b322e7517..db04295fd2f1 100644 --- a/packages/web/src/content/docs/bs/tools.mdx +++ b/packages/web/src/content/docs/bs/tools.mdx @@ -60,12 +60,6 @@ Izvrsava shell komande u okruzenju projekta. Ovaj alat omogucava LLM-u da pokrece terminalske komande kao `npm install`, `git status` i druge shell komande. -:::note -Kada je macOS sandboxing omogućen, bash se pokreće sa ograničenjima fajl sistema i može zatražiti odvojeni pokušaj bez sandboxa kada je potrebno. -Ako znate da komanda mora početi izvan sandboxa, stavite `# opencode:unsandboxed ` na prvi neprazni red komande. -Pogledajte [sandbox konfiguraciju](/docs/config#sandbox) za podržano ponašanje i ograničenja. -::: - --- ### edit diff --git a/packages/web/src/content/docs/da/config.mdx b/packages/web/src/content/docs/da/config.mdx index 7612d2a5e60a..18b462580b74 100644 --- a/packages/web/src/content/docs/da/config.mdx +++ b/packages/web/src/content/docs/da/config.mdx @@ -627,51 +627,6 @@ Nøglen `experimental` indeholder muligheder, der er under aktiv udvikling. Eksperimentelle muligheder er ikke stabile. De kan ændres eller fjernes uden varsel. ::: -### Sandbox - -OpenCode kan køre bash-kommandoer, sessions shell-kommandoer og PTY-opstart i en sandbox på macOS. -Sandboxing er eksperimentelt, opt-in og deaktiveret som standard. - -```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"] - } - } -} -``` - -Tilgængelige muligheder: - -| Mulighed | Type | Beskrivelse | -| ------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------- | -| `enabled` | `boolean` | Aktiver sandboxing for de understøttede macOS-eksekveringsstier. | -| `preset` | `string` | Vælg et indbygget preset (`default`, `strict`, `network`) eller et brugerdefineret navn. | -| `mode` | `"workspace-write" \| "read-only"` | Tilsidesæt preset-tilstanden. | -| `network` | `boolean` | Tilsidesæt om udgående netværksadgang er tilladt. | -| `protected_roots` | `string[]` | Arbejdsområde-relative stier, der forbliver skrivebeskyttede selv inden for skrivbare rødder. | -| `extra_read_roots` | `string[]` | Yderligere absolutte stier, som sandboxen kan læse. | -| `extra_write_roots` | `string[]` | Yderligere absolutte stier, som sandboxen kan skrive til. | -| `extra_deny_paths` | `string[]` | Yderligere absolutte stier, som sandboxen skal nægte. | -| `excluded_commands` | `string[]` | Kommandopræfikser, der skal blokeres før udførelse. | -| `allow_unsandboxed_retry` | `boolean` | Tillad et separat `bash:unsandboxed` tilladelsesbeskyttet genforsøg efter en sandbox-afvisning. | -| `fail_if_unavailable` | `boolean` | Hård fejl, når sandboxing er aktiveret, men ikke kan aktiveres. | -| `presets` | `Record` | Definer brugerdefinerede presets med `mode`, `network`, rødder og tilladelsestilsidesættelser. | - -:::note -Sandboxede kommandoer kan læse indbyggede systemrødder som `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` og `/private/etc`. -Følsomme hjemmestier som `~/.ssh`, `~/.gnupg` og cloud-legitimationsmapper forbliver nægtede som standard. -Se [sikkerhedspolitikken](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) for den fulde trusselsmodel, dækkede overflader og aktuelle begrænsninger. -::: - --- ## Variabler diff --git a/packages/web/src/content/docs/da/permissions.mdx b/packages/web/src/content/docs/da/permissions.mdx index 39e9589aa04b..176dd568e185 100644 --- a/packages/web/src/content/docs/da/permissions.mdx +++ b/packages/web/src/content/docs/da/permissions.mdx @@ -134,7 +134,6 @@ OpenCode tilladelser indtastes efter værktøjsnavn plus et par sikkerhedsafskæ - `glob` — fil-globing (matcher glob-mønsteret) - `grep` — indholdssøgning (matcher regex-mønsteret) - `bash` — kører shell-kommandoer (matcher parsede kommandoer som `git status --porcelain`) -- `bash:unsandboxed` — genkørsel af en shell-kommando uden for sandboxen efter afvisning eller efter en eksplicit anmodning om at køre uden sandbox - `task` — lancering af underagenter (matcher underagenttypen) - `skill` — indlæsning af en færdighed (matcher færdighedsnavnet) - `lsp` — kører LSP forespørgsler (i øjeblikket ikke-granulære) @@ -234,11 +233,3 @@ Only analyze code and suggest changes. :::tip Brug mønstermatchning til kommandoer med argumenter. `"grep *"` tillader `grep pattern file.txt`, mens `"grep"` alene ville blokere det. Kommandoer som `git status` fungerer for standardadfærd, men kræver eksplicit tilladelse (som `"git status *"`), når argumenter sendes. ::: - ---- - -## Sandbox-interaktion - -Når macOS-sandboxing er aktiveret, kan en blokeret bash-kommando udløse en separat `bash:unsandboxed` tilladelsesanmodning. -Dette sker, når OpenCode registrerer en sandsynlig sandbox-afvisning, eller når kommandoen eksplicit beder om at springe sandboxen over med `# opencode:unsandboxed ` på den første ikke-tomme linje. -Konfigurer selve sandboxen i [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/da/tools.mdx b/packages/web/src/content/docs/da/tools.mdx index dee2e345b264..6f6f95c9c582 100644 --- a/packages/web/src/content/docs/da/tools.mdx +++ b/packages/web/src/content/docs/da/tools.mdx @@ -60,12 +60,6 @@ Utfør shellkommandoer i prosjektmiljøet ditt. Dette verktøyet lar LLM kjøre terminalkommandoer som `npm install`, `git status` eller en hvilken som helst annen shell-kommando. -:::note -Når macOS-sandboxing er aktiveret, kører bash med filsystembegrænsninger og kan anmode om et separat genforsøg uden sandbox, når det er nødvendigt. -Hvis du ved, at en kommando skal starte uden for sandboxen, sæt `# opencode:unsandboxed ` på den første ikke-tomme linje i kommandoen. -Se [sandbox-konfiguration](/docs/config#sandbox) for understøttet adfærd og begrænsninger. -::: - --- ### edit diff --git a/packages/web/src/content/docs/de/config.mdx b/packages/web/src/content/docs/de/config.mdx index 7495847180b9..0a2040be7a1f 100644 --- a/packages/web/src/content/docs/de/config.mdx +++ b/packages/web/src/content/docs/de/config.mdx @@ -623,51 +623,6 @@ Der Schlüssel `experimental` enthält Optionen, die sich in der aktiven Entwick Experimentelle Optionen sind nicht stabil. Sie können ohne vorherige Ankündigung geändert oder entfernt werden. ::: -### Sandbox - -OpenCode kann Bash-Befehle, Session-Shell-Befehle und PTY-Starts unter macOS in einer Sandbox ausführen. -Sandboxing ist experimentell, opt-in und standardmäßig deaktiviert. - -```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"] - } - } -} -``` - -Verfügbare Optionen: - -| Option | Typ | Beschreibung | -| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `enabled` | `boolean` | Sandboxing für die unterstützten macOS-Ausführungspfade aktivieren. | -| `preset` | `string` | Ein eingebautes Preset (`default`, `strict`, `network`) oder einen benutzerdefinierten Namen wählen. | -| `mode` | `"workspace-write" \| "read-only"` | Den Preset-Modus überschreiben. | -| `network` | `boolean` | Überschreiben, ob ausgehender Netzwerkzugriff erlaubt ist. | -| `protected_roots` | `string[]` | Workspace-relative Pfade, die auch innerhalb beschreibbarer Wurzeln schreibgeschützt bleiben. | -| `extra_read_roots` | `string[]` | Zusätzliche absolute Pfade, die die Sandbox lesen kann. | -| `extra_write_roots` | `string[]` | Zusätzliche absolute Pfade, in die die Sandbox schreiben kann. | -| `extra_deny_paths` | `string[]` | Zusätzliche absolute Pfade, die die Sandbox verweigern muss. | -| `excluded_commands` | `string[]` | Befehlspräfixe, die vor der Ausführung blockiert werden müssen. | -| `allow_unsandboxed_retry` | `boolean` | Einen separaten `bash:unsandboxed`-Berechtigungs-Wiederholungsversuch nach einer Sandbox-Ablehnung erlauben. | -| `fail_if_unavailable` | `boolean` | Harter Fehler, wenn Sandboxing aktiviert ist, aber nicht aktiviert werden kann. | -| `presets` | `Record` | Benutzerdefinierte Presets mit `mode`, `network`, Wurzeln und Berechtigungsüberschreibungen definieren. | - -:::note -Sandbox-Befehle können eingebaute Systemwurzeln wie `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` und `/private/etc` lesen. -Sensible Home-Pfade wie `~/.ssh`, `~/.gnupg` und Cloud-Anmeldedatenverzeichnisse bleiben standardmäßig verweigert. -Siehe die [Sicherheitsrichtlinie](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) für das vollständige Bedrohungsmodell, abgedeckte Oberflächen und aktuelle Einschränkungen. -::: - --- ## Variablen diff --git a/packages/web/src/content/docs/de/permissions.mdx b/packages/web/src/content/docs/de/permissions.mdx index 286ddec532f2..6b647ca3662f 100644 --- a/packages/web/src/content/docs/de/permissions.mdx +++ b/packages/web/src/content/docs/de/permissions.mdx @@ -134,7 +134,6 @@ OpenCode-Berechtigungen basieren auf Tool-Namen sowie einigen Sicherheitsvorkehr - `glob` – Datei-Globbing (entspricht dem Glob-Muster) - `grep` – Inhaltssuche (entspricht dem Regex-Muster) - `bash` – Ausführen von Shell-Befehlen (entspricht analysierten Befehlen wie `git status --porcelain`) -- `bash:unsandboxed` – erneute Ausführung eines Shell-Befehls außerhalb der Sandbox nach einer Ablehnung oder nach einer expliziten Anforderung ohne Sandbox - `task` – Subagenten starten (entspricht dem Subagententyp) - `skill` – Laden einer Fertigkeit (entspricht dem Fertigkeitsnamen) - `lsp` – Ausführen von LSP-Abfragen (derzeit nicht granular) @@ -234,11 +233,3 @@ Only analyze code and suggest changes. :::tip Verwenden Sie den Mustervergleich für Befehle mit Argumenten. `"grep *"` erlaubt `grep pattern file.txt`, während `"grep"` allein es blockieren würde. Befehle wie `git status` funktionieren für das Standardverhalten, erfordern jedoch eine explizite Erlaubnis (wie `"git status *"`), wenn Argumente übergeben werden. ::: - ---- - -## Sandbox-Interaktion - -Wenn macOS-Sandboxing aktiviert ist, kann ein blockierter Bash-Befehl eine separate `bash:unsandboxed`-Berechtigungsanfrage auslösen. -Dies geschieht, wenn OpenCode eine wahrscheinliche Sandbox-Ablehnung erkennt, oder wenn der Befehl explizit das Überspringen der Sandbox mit `# opencode:unsandboxed ` in der ersten nicht-leeren Zeile anfordert. -Konfigurieren Sie die Sandbox selbst unter [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/de/tools.mdx b/packages/web/src/content/docs/de/tools.mdx index f136498bcde0..6012148c6a88 100644 --- a/packages/web/src/content/docs/de/tools.mdx +++ b/packages/web/src/content/docs/de/tools.mdx @@ -64,12 +64,6 @@ Fuehrt Shell-Befehle in deiner Projektumgebung aus. Damit kann das LLM Terminal-Befehle wie `npm install`, `git status` oder andere Shell-Kommandos ausfuehren. -:::note -Wenn macOS-Sandboxing aktiviert ist, laeuft bash mit Dateisystembeschraenkungen und kann bei Bedarf einen separaten Wiederholungsversuch ohne Sandbox anfordern. -Wenn du weisst, dass ein Befehl ausserhalb der Sandbox starten muss, setze `# opencode:unsandboxed ` in die erste nicht-leere Zeile des Befehls. -Siehe [Sandbox-Konfiguration](/docs/config#sandbox) fuer das unterstuetzte Verhalten und Einschraenkungen. -::: - --- ### edit diff --git a/packages/web/src/content/docs/es/config.mdx b/packages/web/src/content/docs/es/config.mdx index 1fca485336fd..c6142e699016 100644 --- a/packages/web/src/content/docs/es/config.mdx +++ b/packages/web/src/content/docs/es/config.mdx @@ -624,51 +624,6 @@ La clave `experimental` contiene opciones que se encuentran en desarrollo activo Las opciones experimentales no son estables. Pueden cambiar o eliminarse sin previo aviso. ::: -### Sandbox - -OpenCode puede ejecutar comandos bash, comandos de shell de sesión y el inicio de PTY en un sandbox en macOS. -El sandbox es experimental, requiere activación explícita y está deshabilitado por defecto. - -```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"] - } - } -} -``` - -Opciones disponibles: - -| Opción | Tipo | Descripción | -| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `enabled` | `boolean` | Habilitar sandbox para las rutas de ejecución compatibles en macOS. | -| `preset` | `string` | Seleccionar un preset integrado (`default`, `strict`, `network`) o un nombre personalizado. | -| `mode` | `"workspace-write" \| "read-only"` | Anular el modo del preset. | -| `network` | `boolean` | Anular si se permite el acceso a la red de salida. | -| `protected_roots` | `string[]` | Rutas relativas al workspace que permanecen protegidas contra escritura incluso dentro de raíces escribibles. | -| `extra_read_roots` | `string[]` | Rutas absolutas adicionales que el sandbox puede leer. | -| `extra_write_roots` | `string[]` | Rutas absolutas adicionales que el sandbox puede escribir. | -| `extra_deny_paths` | `string[]` | Rutas absolutas adicionales que el sandbox debe denegar. | -| `excluded_commands` | `string[]` | Prefijos de comandos que deben bloquearse antes de la ejecución. | -| `allow_unsandboxed_retry` | `boolean` | Permitir un reintento separado con permiso `bash:unsandboxed` después de una denegación del sandbox. | -| `fail_if_unavailable` | `boolean` | Fallo grave cuando el sandbox está habilitado pero no puede activarse. | -| `presets` | `Record` | Definir presets personalizados con `mode`, `network`, raíces y anulaciones de permisos. | - -:::note -Los comandos en sandbox pueden leer rutas del sistema integradas como `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` y `/private/etc`. -Las rutas sensibles del directorio personal como `~/.ssh`, `~/.gnupg` y los directorios de credenciales en la nube permanecen denegadas por defecto. -Consulte la [política de seguridad](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) para el modelo de amenazas completo, superficies cubiertas y limitaciones actuales. -::: - --- ## Variables diff --git a/packages/web/src/content/docs/es/permissions.mdx b/packages/web/src/content/docs/es/permissions.mdx index cf5541396e8d..6923368e402a 100644 --- a/packages/web/src/content/docs/es/permissions.mdx +++ b/packages/web/src/content/docs/es/permissions.mdx @@ -134,7 +134,6 @@ Los permisos OpenCode están codificados por el nombre de la herramienta, ademá - `glob` — globalización de archivos (coincide con el patrón global) - `grep` — búsqueda de contenido (coincide con el patrón de expresiones regulares) - `bash`: ejecuta comandos de shell (coincide con comandos analizados como `git status --porcelain`) -- `bash:unsandboxed` — reejecutar un comando de shell fuera del sandbox después de una denegación o después de una solicitud explícita sin sandbox - `task` — lanzamiento de subagentes (coincide con el tipo de subagente) - `skill` — cargar una habilidad (coincide con el nombre de la habilidad) - `lsp`: ejecución de consultas LSP (actualmente no granulares) @@ -234,11 +233,3 @@ Only analyze code and suggest changes. :::tip Utilice la coincidencia de patrones para comandos con argumentos. `"grep *"` permite `grep pattern file.txt`, mientras que `"grep"` solo lo bloquearía. Los comandos como `git status` funcionan para el comportamiento predeterminado pero requieren permiso explícito (como `"git status *"`) cuando se pasan argumentos. ::: - ---- - -## Interacción con el sandbox - -Cuando el sandbox de macOS está habilitado, un comando bash bloqueado puede activar una solicitud de permiso `bash:unsandboxed` separada. -Esto ocurre cuando OpenCode detecta una denegación probable del sandbox, o cuando el comando solicita explícitamente omitir el sandbox con `# opencode:unsandboxed ` en la primera línea no vacía. -Configure el sandbox en [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/es/tools.mdx b/packages/web/src/content/docs/es/tools.mdx index 557da6a92cc8..83d61f5325fb 100644 --- a/packages/web/src/content/docs/es/tools.mdx +++ b/packages/web/src/content/docs/es/tools.mdx @@ -60,12 +60,6 @@ Ejecute comandos de shell en el entorno de su proyecto. Esta herramienta permite que LLM ejecute comandos de terminal como `npm install`, `git status` o cualquier otro comando de shell. -:::note -Cuando el sandbox de macOS está habilitado, bash se ejecuta con restricciones del sistema de archivos y puede solicitar un reintento separado sin sandbox cuando sea necesario. -Si sabe que un comando debe iniciarse fuera del sandbox, ponga `# opencode:unsandboxed ` en la primera línea no vacía del comando. -Consulte la [configuración del sandbox](/docs/config#sandbox) para el comportamiento y los límites admitidos. -::: - --- ### edit diff --git a/packages/web/src/content/docs/fr/config.mdx b/packages/web/src/content/docs/fr/config.mdx index 12b60871bd05..c576fe2da11b 100644 --- a/packages/web/src/content/docs/fr/config.mdx +++ b/packages/web/src/content/docs/fr/config.mdx @@ -625,51 +625,6 @@ La clé `experimental` contient des options en cours de développement actif. Les options expérimentales ne sont pas stables. Elles peuvent changer ou être supprimées sans préavis. ::: -### Sandbox - -OpenCode peut exécuter les commandes bash, les commandes shell de session et le démarrage PTY dans un bac à sable (sandbox) sur macOS. -Le sandboxing est expérimental, optionnel et désactivé par défaut. - -```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"] - } - } -} -``` - -Options disponibles : - -| Option | Type | Description | -| ------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `enabled` | `boolean` | Activer le sandboxing pour les chemins d'exécution pris en charge sur macOS. | -| `preset` | `string` | Sélectionner un preset intégré (`default`, `strict`, `network`) ou un nom personnalisé. | -| `mode` | `"workspace-write" \| "read-only"` | Remplacer le mode du preset. | -| `network` | `boolean` | Remplacer l'autorisation d'accès réseau sortant. | -| `protected_roots` | `string[]` | Chemins relatifs au workspace qui restent protégés en écriture même à l'intérieur de racines accessibles en écriture. | -| `extra_read_roots` | `string[]` | Chemins absolus supplémentaires que le sandbox peut lire. | -| `extra_write_roots` | `string[]` | Chemins absolus supplémentaires que le sandbox peut écrire. | -| `extra_deny_paths` | `string[]` | Chemins absolus supplémentaires que le sandbox doit refuser. | -| `excluded_commands` | `string[]` | Préfixes de commandes à bloquer avant l'exécution. | -| `allow_unsandboxed_retry` | `boolean` | Autoriser une nouvelle tentative séparée avec permission `bash:unsandboxed` après un refus du sandbox. | -| `fail_if_unavailable` | `boolean` | Échec fatal lorsque le sandboxing est activé mais ne peut pas être mis en service. | -| `presets` | `Record` | Définir des presets personnalisés avec `mode`, `network`, racines et remplacements de permissions. | - -:::note -Les commandes sandboxées peuvent lire les racines système intégrées telles que `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` et `/private/etc`. -Les chemins sensibles du répertoire personnel tels que `~/.ssh`, `~/.gnupg` et les répertoires d'identifiants cloud restent refusés par défaut. -Consultez la [politique de sécurité](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) pour le modèle de menaces complet, les surfaces couvertes et les limitations actuelles. -::: - --- ## Variables diff --git a/packages/web/src/content/docs/fr/permissions.mdx b/packages/web/src/content/docs/fr/permissions.mdx index f0063e645737..b1c1d6800f5c 100644 --- a/packages/web/src/content/docs/fr/permissions.mdx +++ b/packages/web/src/content/docs/fr/permissions.mdx @@ -134,7 +134,6 @@ Les autorisations OpenCode sont classées par nom d'outil, plus quelques garde-f - `glob` — globalisation de fichiers (correspond au modèle global) - `grep` — recherche de contenu (correspond au modèle regex) - `bash` - exécution de commandes shell (correspond aux commandes analysées comme `git status --porcelain`) -- `bash:unsandboxed` — réexécuter une commande shell en dehors du sandbox après un refus ou après une demande explicite sans sandbox - `task` — lancement de sous-agents (correspond au type de sous-agent) - `skill` — chargement d'une compétence (correspond au nom de la compétence) - `lsp` — exécution de requêtes LSP (actuellement non granulaires) @@ -234,11 +233,3 @@ Only analyze code and suggest changes. :::tip Utilisez la correspondance de modèles pour les commandes avec des arguments. `"grep *"` autorise `grep pattern file.txt`, tandis que `"grep"` seul le bloquerait. Les commandes comme `git status` fonctionnent pour le comportement par défaut mais nécessitent une autorisation explicite (comme `"git status *"`) lorsque des arguments sont passés. ::: - ---- - -## Interaction avec le sandbox - -Lorsque le sandboxing macOS est activé, une commande bash bloquée peut déclencher une demande de permission `bash:unsandboxed` séparée. -Cela se produit lorsque OpenCode détecte un refus probable du sandbox, ou lorsque la commande demande explicitement de contourner le sandbox avec `# opencode:unsandboxed ` sur la première ligne non vide. -Configurez le sandbox dans [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/fr/tools.mdx b/packages/web/src/content/docs/fr/tools.mdx index 1ba6b183e8da..4f3f1804693e 100644 --- a/packages/web/src/content/docs/fr/tools.mdx +++ b/packages/web/src/content/docs/fr/tools.mdx @@ -60,12 +60,6 @@ Exécutez des commandes shell dans votre environnement de projet. Cet outil permet au LLM d'exécuter des commandes de terminal telles que `npm install`, `git status` ou toute autre commande shell. -:::note -Lorsque le sandboxing macOS est activé, bash s'exécute avec des restrictions de système de fichiers et peut demander une nouvelle tentative séparée sans sandbox si nécessaire. -Si vous savez qu'une commande doit démarrer en dehors du sandbox, mettez `# opencode:unsandboxed ` sur la première ligne non vide de la commande. -Consultez la [configuration du sandbox](/docs/config#sandbox) pour le comportement et les limites pris en charge. -::: - --- ### modifier diff --git a/packages/web/src/content/docs/it/config.mdx b/packages/web/src/content/docs/it/config.mdx index 7b29e5a2fdf3..05741e172ed4 100644 --- a/packages/web/src/content/docs/it/config.mdx +++ b/packages/web/src/content/docs/it/config.mdx @@ -624,51 +624,6 @@ La chiave `experimental` contiene opzioni in sviluppo attivo. Le opzioni sperimentali non sono stabili. Possono cambiare o essere rimosse senza preavviso. ::: -### Sandbox - -OpenCode puo eseguire comandi bash, comandi shell di sessione e l'avvio PTY in un sandbox su macOS. -Il sandboxing e sperimentale, opt-in e disabilitato per impostazione predefinita. - -```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"] - } - } -} -``` - -Opzioni disponibili: - -| Opzione | Tipo | Descrizione | -| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------ | -| `enabled` | `boolean` | Abilita il sandboxing per i percorsi di esecuzione supportati su macOS. | -| `preset` | `string` | Seleziona un preset integrato (`default`, `strict`, `network`) o un nome personalizzato. | -| `mode` | `"workspace-write" \| "read-only"` | Sovrascrive la modalita del preset. | -| `network` | `boolean` | Sovrascrive se l'accesso alla rete in uscita e consentito. | -| `protected_roots` | `string[]` | Percorsi relativi alla workspace che restano protetti in scrittura anche dentro root scrivibili. | -| `extra_read_roots` | `string[]` | Percorsi assoluti aggiuntivi che il sandbox puo leggere. | -| `extra_write_roots` | `string[]` | Percorsi assoluti aggiuntivi che il sandbox puo scrivere. | -| `extra_deny_paths` | `string[]` | Percorsi assoluti aggiuntivi che il sandbox deve negare. | -| `excluded_commands` | `string[]` | Prefissi di comandi che devono essere bloccati prima dell'esecuzione. | -| `allow_unsandboxed_retry` | `boolean` | Consenti un retry separato con permesso `bash:unsandboxed` dopo un rifiuto del sandbox. | -| `fail_if_unavailable` | `boolean` | Errore fatale quando il sandboxing e abilitato ma non puo essere attivato. | -| `presets` | `Record` | Definisci preset personalizzati con `mode`, `network`, root e override dei permessi. | - -:::note -I comandi in sandbox possono leggere le root di sistema integrate come `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` e `/private/etc`. -I percorsi sensibili della home come `~/.ssh`, `~/.gnupg` e le directory delle credenziali cloud restano negati per impostazione predefinita. -Consulta la [policy di sicurezza](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) per il modello di minaccia completo, le superfici coperte e le limitazioni attuali. -::: - --- ## Variabili diff --git a/packages/web/src/content/docs/it/permissions.mdx b/packages/web/src/content/docs/it/permissions.mdx index ccc039cb602f..49f0e8e4d38e 100644 --- a/packages/web/src/content/docs/it/permissions.mdx +++ b/packages/web/src/content/docs/it/permissions.mdx @@ -134,7 +134,6 @@ I permessi di OpenCode sono indicizzati per nome dello strumento, piu' un paio d - `glob` — ricerca file tramite glob (corrisponde al pattern glob) - `grep` — ricerca nel contenuto (corrisponde al pattern regex) - `bash` — esecuzione comandi di shell (corrisponde a comandi parsati come `git status --porcelain`) -- `bash:unsandboxed` — rieseguire un comando di shell al di fuori del sandbox dopo un rifiuto o dopo una richiesta esplicita senza sandbox - `task` — avvio subagenti (corrisponde al tipo di subagente) - `skill` — caricamento di una skill (corrisponde al nome della skill) - `lsp` — esecuzione query LSP (attualmente non granulare) @@ -234,11 +233,3 @@ Only analyze code and suggest changes. :::tip Usa il pattern matching per comandi con argomenti. `"grep *"` consente `grep pattern file.txt`, mentre `"grep"` da solo lo bloccherebbe. Comandi come `git status` funzionano per il comportamento di default ma richiedono un permesso esplicito (come `"git status *"`) quando vengono passati argomenti. ::: - ---- - -## Interazione con il sandbox - -Quando il sandboxing macOS e abilitato, un comando bash bloccato puo attivare una richiesta di permesso `bash:unsandboxed` separata. -Questo accade quando OpenCode rileva un probabile rifiuto del sandbox, o quando il comando richiede esplicitamente di saltare il sandbox con `# opencode:unsandboxed ` sulla prima riga non vuota. -Configura il sandbox in [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/it/tools.mdx b/packages/web/src/content/docs/it/tools.mdx index 4864f36d843e..c1e69f8beb4f 100644 --- a/packages/web/src/content/docs/it/tools.mdx +++ b/packages/web/src/content/docs/it/tools.mdx @@ -60,12 +60,6 @@ Esegui comandi di shell nel tuo ambiente di progetto. Questo strumento permette all'LLM di eseguire comandi da terminale come `npm install`, `git status` o qualunque altro comando di shell. -:::note -Quando il sandboxing macOS e abilitato, bash viene eseguito con restrizioni sul filesystem e puo richiedere un retry separato senza sandbox quando necessario. -Se sai che un comando deve avviarsi al di fuori del sandbox, metti `# opencode:unsandboxed ` sulla prima riga non vuota del comando. -Consulta la [configurazione del sandbox](/docs/config#sandbox) per il comportamento e i limiti supportati. -::: - --- ### edit diff --git a/packages/web/src/content/docs/ja/config.mdx b/packages/web/src/content/docs/ja/config.mdx index 14543b6fa1cb..20e29190dae0 100644 --- a/packages/web/src/content/docs/ja/config.mdx +++ b/packages/web/src/content/docs/ja/config.mdx @@ -623,51 +623,6 @@ OpenCode は起動時に新しいアップデートを自動的にダウンロ 実験的なオプションは安定していません。予告なく変更または削除される場合があります。 ::: -### サンドボックス - -OpenCode は macOS 上で bash コマンド、セッションシェルコマンド、PTY の起動をサンドボックス化できます。 -サンドボックスは実験的な機能であり、オプトインで、デフォルトでは無効になっています。 - -```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"] - } - } -} -``` - -利用可能なオプション: - -| オプション | 型 | 説明 | -| ------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------- | -| `enabled` | `boolean` | macOS でサポートされている実行パスのサンドボックスを有効にします。 | -| `preset` | `string` | 組み込みプリセット (`default`、`strict`、`network`) またはカスタムプリセット名を選択します。 | -| `mode` | `"workspace-write" \| "read-only"` | プリセットのモードをオーバーライドします。 | -| `network` | `boolean` | 送信ネットワークアクセスを許可するかどうかをオーバーライドします。 | -| `protected_roots` | `string[]` | 書き込み可能なルート内でも書き込み保護のままにするワークスペース相対パス。 | -| `extra_read_roots` | `string[]` | サンドボックスが読み取れる追加の絶対パス。 | -| `extra_write_roots` | `string[]` | サンドボックスが書き込める追加の絶対パス。 | -| `extra_deny_paths` | `string[]` | サンドボックスが拒否する追加の絶対パス。 | -| `excluded_commands` | `string[]` | 実行前にブロックする必要があるコマンドプレフィックス。 | -| `allow_unsandboxed_retry` | `boolean` | サンドボックス拒否後に、別の `bash:unsandboxed` 権限付きリトライを許可します。 | -| `fail_if_unavailable` | `boolean` | サンドボックスが有効だが起動できない場合にハードフェイルします。 | -| `presets` | `Record` | `mode`、`network`、ルート、権限オーバーライドを使用してカスタムプリセットを定義します。 | - -:::note -サンドボックス化されたコマンドは、`/bin`、`/usr`、`/opt/homebrew`、`/System`、`/Library`、`/dev`、`/tmp`、`/private/etc` などの組み込みシステムルートを読み取ることができます。 -`~/.ssh`、`~/.gnupg`、クラウド認証ディレクトリなどの機密性の高いホームパスは、デフォルトで拒否されたままです。 -完全な脅威モデル、カバーされる範囲、および現在の制限事項については、[セキュリティポリシー](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) を参照してください。 -::: - --- ## 変数 diff --git a/packages/web/src/content/docs/ja/permissions.mdx b/packages/web/src/content/docs/ja/permissions.mdx index 7d657e34146a..f2b0978259a8 100644 --- a/packages/web/src/content/docs/ja/permissions.mdx +++ b/packages/web/src/content/docs/ja/permissions.mdx @@ -134,7 +134,6 @@ OpenCode の権限は、ツール名に加えて、いくつかの安全対策 - `glob` — ファイルのグロビング (グロブパターンと一致) - `grep` — コンテンツ検索 (正規表現パターンと一致) - `bash` — シェルコマンドの実行 (`git status --porcelain` などの解析されたコマンドと一致します) -- `bash:unsandboxed` — サンドボックス拒否後、または明示的なサンドボックス外リクエスト後にサンドボックス外でシェルコマンドを再実行します - `task` — サブエージェントの起動 (サブエージェントのタイプと一致) - `skill` — スキルをロードしています(スキル名と一致します) - `lsp` — LSP クエリの実行 (現在は非細分性) @@ -281,11 +280,3 @@ Only analyze code and suggest changes. :::tip 引数のあるコマンドにはパターン マッチングを使用します。 `"grep *"` は `grep pattern file.txt` を許可しますが、`"grep"` だけではブロックされます。 `git status` のようなコマンドはデフォルトの動作で機能しますが、引数を渡すときに明示的な許可 (`"git status *"` など) が必要です。 ::: - ---- - -## サンドボックスとの連携 - -macOS サンドボックスが有効になっている場合、ブロックされた bash コマンドは個別の `bash:unsandboxed` 権限リクエストをトリガーできます。 -これは、OpenCode がサンドボックス拒否の可能性を検出した場合、またはコマンドが最初の空でない行で `# opencode:unsandboxed <理由>` を使用して明示的にサンドボックスのスキップを要求した場合に発生します。 -サンドボックスの設定は [`experimental.sandbox`](/docs/config#sandbox) で行います。 diff --git a/packages/web/src/content/docs/ja/tools.mdx b/packages/web/src/content/docs/ja/tools.mdx index 6e0d6bd7145a..394506393687 100644 --- a/packages/web/src/content/docs/ja/tools.mdx +++ b/packages/web/src/content/docs/ja/tools.mdx @@ -60,12 +60,6 @@ OpenCode で利用可能なすべての組み込みツールを次に示しま このツールを使用すると、LLM は `npm install`、`git status`、またはその他のシェルコマンドなどのターミナルコマンドを実行できます。 -:::note -macOS サンドボックスが有効な場合、bash はファイルシステムの制限付きで実行され、必要に応じてサンドボックス外での個別のリトライを要求できます。 -コマンドがサンドボックスの外で開始する必要があることがわかっている場合は、コマンドの最初の空でない行に `# opencode:unsandboxed <理由>` を記述してください。 -サポートされる動作と制限については、[サンドボックス設定](/docs/config#sandbox) を参照してください。 -::: - --- ### edit diff --git a/packages/web/src/content/docs/ko/config.mdx b/packages/web/src/content/docs/ko/config.mdx index 2a2e98272db7..2f08824d699c 100644 --- a/packages/web/src/content/docs/ko/config.mdx +++ b/packages/web/src/content/docs/ko/config.mdx @@ -624,51 +624,6 @@ provider를 하나씩 비활성화하는 대신, OpenCode가 특정 provider만 experimental 옵션은 안정적이지 않습니다. 예고 없이 변경되거나 제거될 수 있습니다. ::: -### Sandbox - -OpenCode는 macOS에서 bash 명령, 세션 셸 명령, PTY 시작을 샌드박스할 수 있습니다. -샌드박싱은 실험적이며 opt-in 방식이고 기본적으로 비활성화되어 있습니다. - -```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"] - } - } -} -``` - -사용 가능한 옵션: - -| 옵션 | 타입 | 설명 | -| ------------------------- | ---------------------------------- | --------------------------------------------------------------------------------- | -| `enabled` | `boolean` | 지원되는 macOS 실행 경로에 대해 샌드박싱을 활성화합니다. | -| `preset` | `string` | 내장 프리셋(`default`, `strict`, `network`) 또는 커스텀 프리셋 이름을 선택합니다. | -| `mode` | `"workspace-write" \| "read-only"` | 프리셋 모드를 오버라이드합니다. | -| `network` | `boolean` | 아웃바운드 네트워크 접근 허용 여부를 오버라이드합니다. | -| `protected_roots` | `string[]` | 쓰기 가능한 루트 안에서도 쓰기 보호를 유지할 작업 공간 상대 경로입니다. | -| `extra_read_roots` | `string[]` | 샌드박스가 읽을 수 있는 추가 절대 경로입니다. | -| `extra_write_roots` | `string[]` | 샌드박스가 쓸 수 있는 추가 절대 경로입니다. | -| `extra_deny_paths` | `string[]` | 샌드박스가 거부해야 하는 추가 절대 경로입니다. | -| `excluded_commands` | `string[]` | 실행 전에 차단해야 하는 명령 접두사입니다. | -| `allow_unsandboxed_retry` | `boolean` | 샌드박스 거부 후 별도의 `bash:unsandboxed` 권한 기반 재시도를 허용합니다. | -| `fail_if_unavailable` | `boolean` | 샌드박싱이 활성화되었지만 작동할 수 없을 때 하드 실패합니다. | -| `presets` | `Record` | `mode`, `network`, 루트 및 권한 오버라이드로 커스텀 프리셋을 정의합니다. | - -:::note -샌드박스된 명령은 `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp`, `/private/etc` 같은 내장 시스템 루트를 읽을 수 있습니다. -`~/.ssh`, `~/.gnupg` 및 클라우드 자격 증명 디렉토리 같은 민감한 홈 경로는 기본적으로 거부됩니다. -전체 위협 모델, 적용 범위 및 현재 제한 사항은 [보안 정책](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md)을 참조하세요. -::: - --- ## Variables diff --git a/packages/web/src/content/docs/ko/permissions.mdx b/packages/web/src/content/docs/ko/permissions.mdx index 94a3c7386214..0742089d6b7b 100644 --- a/packages/web/src/content/docs/ko/permissions.mdx +++ b/packages/web/src/content/docs/ko/permissions.mdx @@ -134,7 +134,6 @@ opencode 권한은 도구 이름에 의해 키 입력되며, 두 개의 안전 - `glob` - 파일 globbing (glob 패턴 매칭) - `grep` - 콘텐츠 검색 ( regex 패턴 매칭) - `bash` - shell 명령 실행 (`git status --porcelain`와 같은 팟 명령) -- `bash:unsandboxed` — 샌드박스 거부 후 또는 명시적 비샌드박스 요청 후 셸 명령을 샌드박스 밖에서 다시 실행 - `task` - 에이전트 실행 (작업 에이전트 유형) - `skill` - 기술을 로딩 (기술 이름을 매칭) - `lsp` - LSP 쿼리 실행 (현재 비 과립) @@ -234,11 +233,3 @@ Only analyze code and suggest changes. :::tip 인자와 명령에 대한 패턴 매칭을 사용합니다. `"grep *"`는 `grep pattern file.txt`를 허용하고, `"grep"`는 혼자 그것을 막을 것입니다. `git status`와 같은 명령은 기본 동작을 위해 작동하지만, 인수가 전달될 때 명시된 권한 (`"git status *"`와 같은)이 필요합니다. ::: - ---- - -## 샌드박스 상호작용 - -macOS 샌드박싱이 활성화되면, 차단된 bash 명령은 별도의 `bash:unsandboxed` 권한 요청을 트리거할 수 있습니다. -이는 OpenCode가 샌드박스 거부를 감지하거나, 명령이 첫 번째 비어있지 않은 줄에 `# opencode:unsandboxed `을 넣어 명시적으로 샌드박스를 건너뛰도록 요청할 때 발생합니다. -샌드박스 자체는 [`experimental.sandbox`](/docs/config#sandbox)에서 설정하세요. diff --git a/packages/web/src/content/docs/ko/tools.mdx b/packages/web/src/content/docs/ko/tools.mdx index f6823aedb382..49bea93cb2ea 100644 --- a/packages/web/src/content/docs/ko/tools.mdx +++ b/packages/web/src/content/docs/ko/tools.mdx @@ -60,12 +60,6 @@ description: LLM이 사용할 수 있는 도구를 관리합니다. 이 도구는 `npm install`, `git status` 또는 다른 shell 명령과 같은 terminal 명령을 실행하는 LLM을 허용합니다. -:::note -macOS 샌드박싱이 활성화되면 bash는 파일 시스템 제한이 적용된 상태로 실행되며, 필요한 경우 별도의 비샌드박스 재시도를 요청할 수 있습니다. -명령이 샌드박스 밖에서 시작되어야 하는 경우, 명령의 첫 번째 비어있지 않은 줄에 `# opencode:unsandboxed `을 넣으세요. -지원되는 동작과 제한 사항은 [sandbox config](/docs/config#sandbox)를 참조하세요. -::: - --- ### edit diff --git a/packages/web/src/content/docs/nb/config.mdx b/packages/web/src/content/docs/nb/config.mdx index cac360f66d13..e8b32d5a0676 100644 --- a/packages/web/src/content/docs/nb/config.mdx +++ b/packages/web/src/content/docs/nb/config.mdx @@ -627,51 +627,6 @@ Hvis en leverandør vises i både `enabled_providers` og `disabled_providers`, h Eksperimentelle alternativer er ikke stabile. De kan endres eller fjernes uten varsel. ::: -### Sandbox - -OpenCode kan sandkasse bash-kommandoer, øktskallkommandoer og PTY-oppstart på macOS. -Sandkassing er eksperimentelt, opt-in og deaktivert som standard. - -```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"] - } - } -} -``` - -Tilgjengelige alternativer: - -| Alternativ | Type | Beskrivelse | -| ------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------- | -| `enabled` | `boolean` | Aktiver sandkassing for de støttede macOS-kjørebanene. | -| `preset` | `string` | Velg et innebygd forhåndsvalg (`default`, `strict`, `network`) eller et egendefinert navn. | -| `mode` | `"workspace-write" \| "read-only"` | Overstyr forhåndsvalgmodusen. | -| `network` | `boolean` | Overstyr om utgående nettverkstilgang er tillatt. | -| `protected_roots` | `string[]` | Arbeidsområde-relative stier som forblir skrivebeskyttet selv inne i skrivbare røtter. | -| `extra_read_roots` | `string[]` | Ekstra absolutte stier sandkassen kan lese. | -| `extra_write_roots` | `string[]` | Ekstra absolutte stier sandkassen kan skrive. | -| `extra_deny_paths` | `string[]` | Ekstra absolutte stier sandkassen må nekte. | -| `excluded_commands` | `string[]` | Kommandoprefikser som må blokkeres før kjøring. | -| `allow_unsandboxed_retry` | `boolean` | Tillat et separat `bash:unsandboxed`-tillatelsesbeskyttet nytt forsøk etter en sandkasseavvisning. | -| `fail_if_unavailable` | `boolean` | Hard-feil når sandkassing er aktivert men ikke kan aktiveres. | -| `presets` | `Record` | Definer egendefinerte forhåndsvalg med `mode`, `network`, røtter og tillatelsesoverstyrelser. | - -:::note -Sandkassede kommandoer kan lese innebygde systemrøtter som `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` og `/private/etc`. -Sensitive hjemmestier som `~/.ssh`, `~/.gnupg` og skylegitimjonskatalogene forblir nektet som standard. -Se [sikkerhetspolicyen](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) for den fullstendige trusselmodellen, dekkede overflater og nåværende begrensninger. -::: - --- ## Variabler diff --git a/packages/web/src/content/docs/nb/permissions.mdx b/packages/web/src/content/docs/nb/permissions.mdx index 76f80e2e95d0..5c63b251e35d 100644 --- a/packages/web/src/content/docs/nb/permissions.mdx +++ b/packages/web/src/content/docs/nb/permissions.mdx @@ -134,7 +134,6 @@ OpenCode-tillatelser tastes inn etter verktøynavn, pluss et par sikkerhetsvakte - `glob` — fil-globing (tilsvarer glob-mønsteret) - `grep` — innholdssøk (samsvarer med regex-mønsteret) - `bash` — kjører skallkommandoer (matcher analyserte kommandoer som `git status --porcelain`) -- `bash:unsandboxed` — kjører en skallkommando på nytt utenfor sandkassen etter avvisning eller etter en eksplisitt forespørsel uten sandkasse - `task` — start av subagenter (tilsvarer subagenttypen) - `skill` — laster en ferdighet (tilsvarer navnet på ferdigheten) - `lsp` — kjører LSP-spørringer (for øyeblikket ikke-granulære) @@ -234,11 +233,3 @@ Only analyze code and suggest changes. :::tip Bruk mønstertilpasning for kommandoer med argumenter. `"grep *"` tillater `grep pattern file.txt`, mens `"grep"` alene ville blokkert den. Kommandoer som `git status` fungerer for standard oppførsel, men krever eksplisitt tillatelse (som `"git status *"`) når argumenter sendes. ::: - ---- - -## Sandkasse-interaksjon - -Når macOS-sandkassing er aktivert, kan en blokkert bash-kommando utløse en separat `bash:unsandboxed`-tillatelsesforespørsel. -Dette skjer når OpenCode oppdager en sannsynlig sandkasseavvisning, eller når kommandoen eksplisitt ber om å hoppe over sandkassen med `# opencode:unsandboxed ` på den første ikke-tomme linjen. -Konfigurer sandkassen selv i [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/nb/tools.mdx b/packages/web/src/content/docs/nb/tools.mdx index 443a7e36405b..8c871f11c9ad 100644 --- a/packages/web/src/content/docs/nb/tools.mdx +++ b/packages/web/src/content/docs/nb/tools.mdx @@ -60,12 +60,6 @@ Utfør skallkommandoer i prosjektmiljøet ditt. Dette verktøyet lar LLM kjøre terminalkommandoer som `npm install`, `git status` eller en hvilken som helst annen skallkommando. -:::note -Når macOS-sandkassing er aktivert, kjører bash med filsystembegrensninger og kan be om et separat nytt forsøk uten sandkasse ved behov. -Hvis du vet at en kommando må starte utenfor sandkassen, legg `# opencode:unsandboxed ` på den første ikke-tomme linjen i kommandoen. -Se [sandbox-konfigurasjon](/docs/config#sandbox) for støttet oppførsel og begrensninger. -::: - --- ### edit diff --git a/packages/web/src/content/docs/pl/config.mdx b/packages/web/src/content/docs/pl/config.mdx index 72dc8bd07913..a6a6fb156d74 100644 --- a/packages/web/src/content/docs/pl/config.mdx +++ b/packages/web/src/content/docs/pl/config.mdx @@ -619,51 +619,6 @@ Klucz `experimental` zawiera opcje, które są we wczesnej fazie rozwoju. Opcje eksperymentalne nie są stabilne. Mogą ulec zmianie lub zostać usunięte bez ostrzeżenia. ::: -### Sandbox - -OpenCode może sandboxować polecenia bash, polecenia powłoki sesji i uruchamianie PTY na macOS. -Sandboxowanie jest eksperymentalne, wymaga włączenia i jest domyślnie wyłączone. - -```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"] - } - } -} -``` - -Dostępne opcje: - -| Opcja | Typ | Opis | -| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `enabled` | `boolean` | Włącz sandboxowanie dla obsługiwanych ścieżek wykonawczych macOS. | -| `preset` | `string` | Wybierz wbudowany preset (`default`, `strict`, `network`) lub nazwę niestandardowego. | -| `mode` | `"workspace-write" \| "read-only"` | Nadpisz tryb presetu. | -| `network` | `boolean` | Nadpisz, czy dozwolony jest wychodzący dostęp do sieci. | -| `protected_roots` | `string[]` | Ścieżki względne do workspace, które pozostają chronione przed zapisem nawet wewnątrz zapisywalnych korzeni. | -| `extra_read_roots` | `string[]` | Dodatkowe ścieżki absolutne, które sandbox może odczytywać. | -| `extra_write_roots` | `string[]` | Dodatkowe ścieżki absolutne, do których sandbox może zapisywać. | -| `extra_deny_paths` | `string[]` | Dodatkowe ścieżki absolutne, które sandbox musi odrzucać. | -| `excluded_commands` | `string[]` | Prefiksy poleceń, które muszą być zablokowane przed wykonaniem. | -| `allow_unsandboxed_retry` | `boolean` | Zezwól na osobne ponowienie z uprawnieniem `bash:unsandboxed` po odrzuceniu przez sandbox. | -| `fail_if_unavailable` | `boolean` | Twardy błąd, gdy sandboxowanie jest włączone, ale nie może być aktywowane. | -| `presets` | `Record` | Zdefiniuj niestandardowe presety z `mode`, `network`, korzeniami i nadpisaniami uprawnień. | - -:::note -Polecenia w sandboxie mogą odczytywać wbudowane korzenie systemowe, takie jak `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` i `/private/etc`. -Wrażliwe ścieżki domowe, takie jak `~/.ssh`, `~/.gnupg` i katalogi poświadczeń chmury, pozostają domyślnie odrzucone. -Zobacz [politykę bezpieczeństwa](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md), aby poznać pełny model zagrożeń, pokryte powierzchnie i aktualne ograniczenia. -::: - --- ## Zmienne diff --git a/packages/web/src/content/docs/pl/permissions.mdx b/packages/web/src/content/docs/pl/permissions.mdx index ddefea99ef41..a5c05b6dc634 100644 --- a/packages/web/src/content/docs/pl/permissions.mdx +++ b/packages/web/src/content/docs/pl/permissions.mdx @@ -134,7 +134,6 @@ Uprawnienia opencode są określane na podstawie nazwy narzędzia i kilku zabezp - `glob` — maglowanie plików (pasuje do wzorców globowania) - `grep` — wyszukiwanie treści (pasuje do wzorca regularnego) - `bash` — uruchamianie poleceń shell (pasuje do poleceń przeanalizowanych, takich jak `git status --porcelain`) -- `bash:unsandboxed` — ponowne uruchomienie polecenia shell poza sandboxem po odrzuceniu lub po jawnym żądaniu bez sandboxa - `task` — uruchamianie podagentów (odpowiada typowi podagenta) - `skill` — ładowanie umiejętności (pasuje do nazwy umiejętności) - `lsp` — uruchamianie zapytań LSP (obecnie nieszczegółowych) @@ -234,11 +233,3 @@ Only analyze code and suggest changes. :::tip zastosowanie dopasowywania wzorców dla pierwotnych z argumentami. `"grep *"` pozwala na `grep pattern file.txt`, podczas gdy sam `"grep"` blokuje to. Polecenia takie jak `git status` w przypadku postępowania dyscyplinarnego, ale ostatecznego zastosowania (np. `"git status *"`) podczas stosowania argumentów. ::: - ---- - -## Interakcja z sandboxem - -Gdy sandboxowanie macOS jest włączone, zablokowane polecenie bash może wywołać osobne żądanie uprawnienia `bash:unsandboxed`. -Dzieje się tak, gdy OpenCode wykryje prawdopodobne odrzucenie przez sandbox lub gdy polecenie jawnie prosi o pominięcie sandboxa za pomocą `# opencode:unsandboxed ` w pierwszej niepustej linii. -Skonfiguruj sam sandbox w [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/pl/tools.mdx b/packages/web/src/content/docs/pl/tools.mdx index 4cac3f9037d9..180e043cd567 100644 --- a/packages/web/src/content/docs/pl/tools.mdx +++ b/packages/web/src/content/docs/pl/tools.mdx @@ -60,12 +60,6 @@ Wykonuj polecenia powłoki (shell) w środowisku projektu. To narzędzie umożliwia LLM uruchamianie poleceń terminalowych, takich jak `npm install`, `git status` lub dowolne inne polecenie powłoki. -:::note -Gdy sandboxowanie macOS jest włączone, bash działa z ograniczeniami systemu plików i może żądać osobnego ponowienia bez sandboxa w razie potrzeby. -Jeśli wiesz, że polecenie musi być uruchomione poza sandboxem, umieść `# opencode:unsandboxed ` w pierwszej niepustej linii polecenia. -Zobacz [konfigurację sandboxa](/docs/config#sandbox), aby poznać obsługiwane zachowania i ograniczenia. -::: - --- ### edit diff --git a/packages/web/src/content/docs/pt-br/config.mdx b/packages/web/src/content/docs/pt-br/config.mdx index 9c21fcc98acc..4684bb199ecf 100644 --- a/packages/web/src/content/docs/pt-br/config.mdx +++ b/packages/web/src/content/docs/pt-br/config.mdx @@ -625,51 +625,6 @@ A chave `experimental` contém opções que estão em desenvolvimento ativo. Opções experimentais não são estáveis. Elas podem mudar ou ser removidas sem aviso prévio. ::: -### Sandbox - -O opencode pode sandboxar comandos bash, comandos de shell de sessão e inicialização de PTY no macOS. -O sandboxing é experimental, opt-in e desabilitado por padrão. - -```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"] - } - } -} -``` - -Opções disponíveis: - -| Opção | Tipo | Descrição | -| ------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `enabled` | `boolean` | Habilitar sandboxing para os caminhos de execução suportados no macOS. | -| `preset` | `string` | Selecionar um preset embutido (`default`, `strict`, `network`) ou um nome de preset personalizado. | -| `mode` | `"workspace-write" \| "read-only"` | Substituir o modo do preset. | -| `network` | `boolean` | Substituir se o acesso de rede de saída é permitido. | -| `protected_roots` | `string[]` | Caminhos relativos ao workspace que permanecem protegidos contra escrita mesmo dentro de raízes graváveis. | -| `extra_read_roots` | `string[]` | Caminhos absolutos adicionais que o sandbox pode ler. | -| `extra_write_roots` | `string[]` | Caminhos absolutos adicionais que o sandbox pode escrever. | -| `extra_deny_paths` | `string[]` | Caminhos absolutos adicionais que o sandbox deve negar. | -| `excluded_commands` | `string[]` | Prefixos de comandos que devem ser bloqueados antes da execução. | -| `allow_unsandboxed_retry` | `boolean` | Permitir uma nova tentativa separada com permissão `bash:unsandboxed` após uma negação do sandbox. | -| `fail_if_unavailable` | `boolean` | Falha severa quando o sandboxing está habilitado mas não pode ser ativado. | -| `presets` | `Record` | Definir presets personalizados com `mode`, `network`, raízes e substituições de permissões. | - -:::note -Comandos em sandbox podem ler raízes de sistema embutidas como `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` e `/private/etc`. -Caminhos sensíveis do home como `~/.ssh`, `~/.gnupg` e diretórios de credenciais de nuvem permanecem negados por padrão. -Veja a [política de segurança](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) para o modelo de ameaças completo, superfícies cobertas e limitações atuais. -::: - --- ## Variáveis diff --git a/packages/web/src/content/docs/pt-br/permissions.mdx b/packages/web/src/content/docs/pt-br/permissions.mdx index 8a210c033ca3..4facc9f72b8f 100644 --- a/packages/web/src/content/docs/pt-br/permissions.mdx +++ b/packages/web/src/content/docs/pt-br/permissions.mdx @@ -134,7 +134,6 @@ As permissões do opencode são indexadas pelo nome da ferramenta, além de algu - `glob` — globbing de arquivos (corresponde ao padrão glob) - `grep` — busca de conteúdo (corresponde ao padrão regex) - `bash` — execução de comandos de shell (corresponde a comandos analisados como `git status --porcelain`) -- `bash:unsandboxed` — reexecução de um comando de shell fora do sandbox após negação ou após uma solicitação explícita sem sandbox - `task` — lançamento de subagentes (corresponde ao tipo de subagente) - `skill` — carregamento de uma habilidade (corresponde ao nome da habilidade) - `lsp` — execução de consultas LSP (atualmente não granular) @@ -234,11 +233,3 @@ Only analyze code and suggest changes. :::tip Use correspondência de padrões para comandos com argumentos. `"grep *"` permite `grep pattern file.txt`, enquanto `"grep"` sozinho o bloquearia. Comandos como `git status` funcionam para o comportamento padrão, mas requerem permissão explícita (como `"git status *"`) quando argumentos são passados. ::: - ---- - -## Interação com o Sandbox - -Quando o sandboxing do macOS está habilitado, um comando bash bloqueado pode acionar uma solicitação de permissão `bash:unsandboxed` separada. -Isso acontece quando o opencode detecta uma provável negação do sandbox, ou quando o comando solicita explicitamente pular o sandbox com `# opencode:unsandboxed ` na primeira linha não vazia. -Configure o sandbox em [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/pt-br/tools.mdx b/packages/web/src/content/docs/pt-br/tools.mdx index 9abbe4eba9d6..4c7b37197179 100644 --- a/packages/web/src/content/docs/pt-br/tools.mdx +++ b/packages/web/src/content/docs/pt-br/tools.mdx @@ -60,12 +60,6 @@ Execute comandos de shell no ambiente do seu projeto. Esta ferramenta permite que o LLM execute comandos de terminal como `npm install`, `git status` ou qualquer outro comando de shell. -:::note -Quando o sandboxing do macOS está habilitado, o bash é executado com restrições de sistema de arquivos e pode solicitar uma nova tentativa separada sem sandbox quando necessário. -Se você sabe que um comando precisa iniciar fora do sandbox, coloque `# opencode:unsandboxed ` na primeira linha não vazia do comando. -Veja [configuração do sandbox](/docs/config#sandbox) para o comportamento suportado e limites. -::: - --- ### edit diff --git a/packages/web/src/content/docs/ru/config.mdx b/packages/web/src/content/docs/ru/config.mdx index f8b69b537739..5d91dc5e01b6 100644 --- a/packages/web/src/content/docs/ru/config.mdx +++ b/packages/web/src/content/docs/ru/config.mdx @@ -624,51 +624,6 @@ opencode автоматически загрузит все новые обно Экспериментальные варианты не стабильны. Они могут быть изменены или удалены без предварительного уведомления. ::: -### Sandbox - -OpenCode может изолировать команды bash, команды оболочки сеанса и запуск PTY в песочнице на macOS. -Песочница экспериментальна, включается явно и по умолчанию отключена. - -```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"] - } - } -} -``` - -Доступные параметры: - -| Параметр | Тип | Описание | -| ------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------- | -| `enabled` | `boolean` | Включить песочницу для поддерживаемых путей выполнения macOS. | -| `preset` | `string` | Выбрать встроенный пресет (`default`, `strict`, `network`) или пользовательский. | -| `mode` | `"workspace-write" \| "read-only"` | Переопределить режим пресета. | -| `network` | `boolean` | Переопределить разрешение исходящего сетевого доступа. | -| `protected_roots` | `string[]` | Пути относительно рабочей области, защищённые от записи даже внутри записываемых корней. | -| `extra_read_roots` | `string[]` | Дополнительные абсолютные пути для чтения из песочницы. | -| `extra_write_roots` | `string[]` | Дополнительные абсолютные пути для записи из песочницы. | -| `extra_deny_paths` | `string[]` | Дополнительные абсолютные пути, которые песочница должна блокировать. | -| `excluded_commands` | `string[]` | Префиксы команд, которые должны быть заблокированы перед выполнением. | -| `allow_unsandboxed_retry` | `boolean` | Разрешить отдельную попытку `bash:unsandboxed` с запросом разрешения после отказа песочницы. | -| `fail_if_unavailable` | `boolean` | Жёсткий отказ, если песочница включена, но не может быть активирована. | -| `presets` | `Record` | Определить пользовательские пресеты с `mode`, `network`, корневыми путями и переопределениями разрешений. | - -:::note -Команды в песочнице могут читать встроенные системные пути, такие как `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` и `/private/etc`. -Чувствительные домашние пути, такие как `~/.ssh`, `~/.gnupg` и каталоги облачных учётных данных, по умолчанию запрещены. -См. [политику безопасности](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) для полной модели угроз, покрытых поверхностей и текущих ограничений. -::: - --- ## Переменные diff --git a/packages/web/src/content/docs/ru/permissions.mdx b/packages/web/src/content/docs/ru/permissions.mdx index 6305a2b845be..961a0682435d 100644 --- a/packages/web/src/content/docs/ru/permissions.mdx +++ b/packages/web/src/content/docs/ru/permissions.mdx @@ -134,7 +134,6 @@ opencode использует конфигурацию `permission`, чтобы - `glob` — подстановка файла (соответствует шаблону подстановки) - `grep` — поиск по контенту (соответствует шаблону регулярного выражения) - `bash` — запуск shell-команд (соответствует проанализированным командам, например `git status --porcelain`) -- `bash:unsandboxed` — повторный запуск shell-команды вне песочницы после отказа или явного запроса без песочницы - `task` — запуск субагентов (соответствует типу субагента) - `skill` — загрузка навыка (соответствует названию навыка) - `lsp` — выполнение запросов LSP (в настоящее время не детализированных) @@ -234,11 +233,3 @@ Only analyze code and suggest changes. :::tip Используйте сопоставление с образцом для команд с аргументами. `"grep *"` разрешает `grep pattern file.txt`, а сам `"grep"` блокирует его. Такие команды, как `git status`, работают по умолчанию, но требуют явного разрешения (например, `"git status *"`) при передаче аргументов. ::: - ---- - -## Взаимодействие с песочницей - -Когда песочница macOS включена, заблокированная команда bash может вызвать отдельный запрос разрешения `bash:unsandboxed`. -Это происходит, когда OpenCode обнаруживает вероятный отказ песочницы, или когда команда явно запрашивает пропуск песочницы с помощью `# opencode:unsandboxed <причина>` в первой непустой строке. -Настройте саму песочницу в [`experimental.sandbox`](/docs/config#sandbox). diff --git a/packages/web/src/content/docs/ru/tools.mdx b/packages/web/src/content/docs/ru/tools.mdx index d42625019dee..35958e036c1b 100644 --- a/packages/web/src/content/docs/ru/tools.mdx +++ b/packages/web/src/content/docs/ru/tools.mdx @@ -60,12 +60,6 @@ description: Управляйте инструментами, которые м Этот инструмент позволяет LLM запускать команды терминала, такие как `npm install`, `git status` или любую другую shell-команду. -:::note -Когда песочница macOS включена, bash запускается с ограничениями файловой системы и может запросить отдельную попытку без песочницы при необходимости. -Если вы знаете, что команда должна запускаться вне песочницы, добавьте `# opencode:unsandboxed <причина>` в первую непустую строку команды. -См. [настройку песочницы](/docs/config#sandbox) для поддерживаемого поведения и ограничений. -::: - --- ### edit diff --git a/packages/web/src/content/docs/th/config.mdx b/packages/web/src/content/docs/th/config.mdx index a912b7e02d04..c58469c77ab0 100644 --- a/packages/web/src/content/docs/th/config.mdx +++ b/packages/web/src/content/docs/th/config.mdx @@ -629,51 +629,6 @@ OpenCode จะดาวน์โหลดการอัปเดตใหม ตัวเลือกการทดลองไม่เสถียร อาจมีการเปลี่ยนแปลงหรือลบออกโดยไม่ต้องแจ้งให้ทราบล่วงหน้า ::: -### Sandbox - -OpenCode สามารถ sandbox คำสั่ง bash, คำสั่ง shell ของเซสชัน และการเริ่มต้น PTY บน macOS -Sandboxing เป็นฟีเจอร์ทดลอง ต้องเปิดใช้งานเอง และถูกปิดใช้งานตามค่าเริ่มต้น - -```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"] - } - } -} -``` - -ตัวเลือกที่มี: - -| ตัวเลือก | ประเภท | คำอธิบาย | -| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------- | -| `enabled` | `boolean` | เปิดใช้งาน sandboxing สำหรับเส้นทางการทำงาน macOS ที่รองรับ | -| `preset` | `string` | เลือกพรีเซ็ตในตัว (`default`, `strict`, `network`) หรือชื่อพรีเซ็ตที่กำหนดเอง | -| `mode` | `"workspace-write" \| "read-only"` | แทนที่โหมดพรีเซ็ต | -| `network` | `boolean` | แทนที่ว่าอนุญาตให้เข้าถึงเครือข่ายขาออกหรือไม่ | -| `protected_roots` | `string[]` | เส้นทางที่สัมพันธ์กับ workspace ที่ยังคงป้องกันการเขียนแม้จะอยู่ภายในรูทที่เขียนได้ | -| `extra_read_roots` | `string[]` | เส้นทางสัมบูรณ์เพิ่มเติมที่ sandbox สามารถอ่านได้ | -| `extra_write_roots` | `string[]` | เส้นทางสัมบูรณ์เพิ่มเติมที่ sandbox สามารถเขียนได้ | -| `extra_deny_paths` | `string[]` | เส้นทางสัมบูรณ์เพิ่มเติมที่ sandbox ต้องปฏิเสธ | -| `excluded_commands` | `string[]` | คำนำหน้าคำสั่งที่ต้องถูกบล็อกก่อนดำเนินการ | -| `allow_unsandboxed_retry` | `boolean` | อนุญาตให้ลองใหม่แบบ `bash:unsandboxed` แยกต่างหากที่มีการควบคุมสิทธิ์หลังจาก sandbox ปฏิเสธ | -| `fail_if_unavailable` | `boolean` | ล้มเหลวทันทีเมื่อเปิดใช้งาน sandboxing แต่ไม่สามารถเปิดใช้งานได้ | -| `presets` | `Record` | กำหนดพรีเซ็ตที่กำหนดเองด้วย `mode`, `network`, รูท และการแทนที่สิทธิ์ | - -:::note -คำสั่งใน sandbox สามารถอ่านรูทระบบในตัว เช่น `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` และ `/private/etc` -เส้นทางบ้านที่ละเอียดอ่อน เช่น `~/.ssh`, `~/.gnupg` และไดเรกทอรีข้อมูลรับรองคลาวด์ จะถูกปฏิเสธตามค่าเริ่มต้น -ดู [นโยบายความปลอดภัย](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) สำหรับรูปแบบภัยคุกคามฉบับเต็ม พื้นผิวที่ครอบคลุม และข้อจำกัดปัจจุบัน -::: - --- ## ตัวแปร diff --git a/packages/web/src/content/docs/th/permissions.mdx b/packages/web/src/content/docs/th/permissions.mdx index 262dddd2c137..5fed616159a4 100644 --- a/packages/web/src/content/docs/th/permissions.mdx +++ b/packages/web/src/content/docs/th/permissions.mdx @@ -134,7 +134,6 @@ OpenCode ใช้การกำหนดค่า `permission` เพื่อ - `glob` — ไฟล์ globbing (ตรงกับรูปแบบ glob) - `grep` — การค้นหาเนื้อหา (ตรงกับรูปแบบ regex) - `bash` — การรันคำสั่ง shell (ตรงกับคำสั่งที่แยกวิเคราะห์เช่น `git status --porcelain`) -- `bash:unsandboxed` — รันคำสั่ง shell ซ้ำนอก sandbox หลังจากถูกปฏิเสธหรือหลังจากมีคำขอ unsandboxed อย่างชัดเจน - `task` — การเปิดตัวตัวแทนย่อย (ตรงกับประเภทตัวแทนย่อย) - `skill` — กำลังโหลดทักษะ (ตรงกับชื่อทักษะ) - `lsp` — กำลังเรียกใช้คำสั่ง LSP (ปัจจุบันยังไม่ละเอียด) @@ -234,11 +233,3 @@ Only analyze code and suggest changes. :::tip ใช้การจับคู่รูปแบบสำหรับคำสั่งที่มีอาร์กิวเมนต์ `"grep *"` อนุญาต `grep pattern file.txt` ในขณะที่ `"grep"` คนเดียวจะบล็อกได้ คำสั่งเช่น `git status` ใช้งานได้กับพฤติกรรมเริ่มต้น แต่ต้องได้รับอนุญาตอย่างชัดเจน (เช่น `"git status *"`) เมื่ออาร์กิวเมนต์ถูกส่งผ่าน ::: - ---- - -## การโต้ตอบกับ Sandbox - -เมื่อเปิดใช้งาน sandbox ของ macOS คำสั่ง bash ที่ถูกบล็อกสามารถเรียกคำขอสิทธิ์ `bash:unsandboxed` แยกต่างหากได้ -สิ่งนี้เกิดขึ้นเมื่อ OpenCode ตรวจพบการปฏิเสธจาก sandbox ที่น่าจะเป็นไปได้ หรือเมื่อคำสั่งร้องขออย่างชัดเจนให้ข้าม sandbox ด้วย `# opencode:unsandboxed <เหตุผล>` ในบรรทัดแรกที่ไม่ว่าง -กำหนดค่า sandbox ใน [`experimental.sandbox`](/docs/config#sandbox) diff --git a/packages/web/src/content/docs/th/tools.mdx b/packages/web/src/content/docs/th/tools.mdx index dc07e299264e..0ead638461af 100644 --- a/packages/web/src/content/docs/th/tools.mdx +++ b/packages/web/src/content/docs/th/tools.mdx @@ -60,12 +60,6 @@ description: จัดการเครื่องมือที่ LLM ส เครื่องมือนี้อนุญาตให้ LLM รันคำสั่ง terminal เช่น `npm install`, `git status` หรือคำสั่ง shell อื่น ๆ -:::note -เมื่อเปิดใช้งาน sandbox ของ macOS bash จะทำงานโดยมีข้อจำกัดของระบบไฟล์และสามารถร้องขอการลองใหม่แบบ unsandboxed แยกต่างหากเมื่อจำเป็น -หากคุณทราบว่าคำสั่งต้องเริ่มนอก sandbox ให้ใส่ `# opencode:unsandboxed <เหตุผล>` ในบรรทัดแรกที่ไม่ว่างของคำสั่ง -ดู [การกำหนดค่า sandbox](/docs/config#sandbox) สำหรับพฤติกรรมและข้อจำกัดที่รองรับ -::: - --- ### edit diff --git a/packages/web/src/content/docs/tr/config.mdx b/packages/web/src/content/docs/tr/config.mdx index 45c97161bdf7..8a769ba69081 100644 --- a/packages/web/src/content/docs/tr/config.mdx +++ b/packages/web/src/content/docs/tr/config.mdx @@ -626,51 +626,6 @@ Bir sağlayıcı hem `enabled_providers` hem de `disabled_providers`'de görün Deneysel seçenekler kararlı değildir. Bildirim yapılmaksızın değişebilir veya kaldırılabilirler. ::: -### Sandbox - -OpenCode, macOS üzerinde bash komutlarını, oturum kabuk komutlarını ve PTY başlangıcını sandbox'layabilir. -Sandbox deneyseldir, isteğe bağlıdır ve varsayılan olarak devre dışıdır. - -```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"] - } - } -} -``` - -Mevcut seçenekler: - -| Seçenek | Tür | Açıklama | -| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------- | -| `enabled` | `boolean` | Desteklenen macOS yürütme yolları için sandbox'ı etkinleştirin. | -| `preset` | `string` | Yerleşik bir ön ayar (`default`, `strict`, `network`) veya özel ön ayar adı seçin. | -| `mode` | `"workspace-write" \| "read-only"` | Ön ayar modunu geçersiz kılın. | -| `network` | `boolean` | Giden ağ erişimine izin verilip verilmeyeceğini geçersiz kılın. | -| `protected_roots` | `string[]` | Yazılabilir kökler içinde bile yazma korumalı kalan çalışma alanına göreli yollar. | -| `extra_read_roots` | `string[]` | Sandbox'ın okuyabileceği ek mutlak yollar. | -| `extra_write_roots` | `string[]` | Sandbox'ın yazabileceği ek mutlak yollar. | -| `extra_deny_paths` | `string[]` | Sandbox'ın reddetmesi gereken ek mutlak yollar. | -| `excluded_commands` | `string[]` | Yürütme öncesinde engellenmesi gereken komut önekleri. | -| `allow_unsandboxed_retry` | `boolean` | Sandbox reddinden sonra ayrı bir izin denetimli `bash:unsandboxed` yeniden denemesine izin verin. | -| `fail_if_unavailable` | `boolean` | Sandbox etkinleştirildiğinde ancak aktif edilemediğinde kesin hata verin. | -| `presets` | `Record` | `mode`, `network`, kökler ve izin geçersiz kılmalarıyla özel ön ayarlar tanımlayın. | - -:::note -Sandbox'lı komutlar `/bin`, `/usr`, `/opt/homebrew`, `/System`, `/Library`, `/dev`, `/tmp` ve `/private/etc` gibi yerleşik sistem köklerini okuyabilir. -`~/.ssh`, `~/.gnupg` gibi hassas ev dizini yolları ve bulut kimlik bilgileri dizinleri varsayılan olarak reddedilir. -Tam tehdit modeli, kapsanan yüzeyler ve mevcut sınırlamalar için [güvenlik politikasına](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md) bakın. -::: - --- ## Değişkenler diff --git a/packages/web/src/content/docs/tr/permissions.mdx b/packages/web/src/content/docs/tr/permissions.mdx index e02fb4fc31ab..976ee0a7ffb8 100644 --- a/packages/web/src/content/docs/tr/permissions.mdx +++ b/packages/web/src/content/docs/tr/permissions.mdx @@ -134,7 +134,6 @@ opencode izinleri araç adına ve birkaç güvenlik önlemine göre anahtarlanı - `glob` — dosya genellemesi (glob düzeniyle eşleşir) - `grep` — içerik arama (regex modeliyle eşleşir) - `bash` — kabuk komutlarını çalıştırma (`git status --porcelain` gibi ayrıştırılmış komutlarla eşleşir) -- `bash:unsandboxed` — sandbox reddinden sonra veya açık bir sandbox dışı istekten sonra bir kabuk komutunu sandbox dışında yeniden çalıştırma - `task` — alt agent'ların başlatılması (alt agent türüyle eşleşir) - `skill` — bir skill yükleniyor (skill adıyla eşleşir) - `lsp` — LSP sorgularını çalıştırıyor (şu anda ayrıntılı değil) @@ -234,11 +233,3 @@ Only analyze code and suggest changes. :::tip Bağımsız değişken içeren komutlar için kalıp eşleştirmeyi kullanın. `"grep *"`, `grep pattern file.txt`'ye izin verir, ancak `"grep"` tek başına onu engeller. `git status` gibi komutlar varsayılan davranış için çalışır ancak argümanlar aktarıldığında açık izin (`"git status *"` gibi) gerektirir. ::: - ---- - -## Sandbox Etkileşimi - -macOS sandbox'ı etkinleştirildiğinde, engellenen bir bash komutu ayrı bir `bash:unsandboxed` izin isteği tetikleyebilir. -Bu, OpenCode muhtemel bir sandbox reddini tespit ettiğinde veya komut ilk boş olmayan satırda `# opencode:unsandboxed ` ile açıkça sandbox'ı atlamayı istediğinde gerçekleşir. -Sandbox'ın kendisini [`experimental.sandbox`](/docs/config#sandbox) içinde yapılandırın. diff --git a/packages/web/src/content/docs/tr/tools.mdx b/packages/web/src/content/docs/tr/tools.mdx index 997820d0ada8..2beb19009441 100644 --- a/packages/web/src/content/docs/tr/tools.mdx +++ b/packages/web/src/content/docs/tr/tools.mdx @@ -60,12 +60,6 @@ Proje ortamınızda kabuk komutları çalıştırır. Bu araç LLM'in `npm install`, `git status` gibi terminal komutlarını veya diğer kabuk komutlarını çalıştırmasını sağlar. -:::note -macOS sandbox'ı etkinleştirildiğinde bash, dosya sistemi kısıtlamalarıyla çalışır ve gerektiğinde ayrı bir sandbox dışı yeniden deneme isteyebilir. -Bir komutun sandbox dışında başlaması gerektiğini biliyorsanız, komutun ilk boş olmayan satırına `# opencode:unsandboxed ` koyun. -Desteklenen davranış ve sınırlamalar için [sandbox yapılandırmasına](/docs/config#sandbox) bakın. -::: - --- ### edit diff --git a/packages/web/src/content/docs/zh-cn/config.mdx b/packages/web/src/content/docs/zh-cn/config.mdx index 316154751017..c401bcf121fa 100644 --- a/packages/web/src/content/docs/zh-cn/config.mdx +++ b/packages/web/src/content/docs/zh-cn/config.mdx @@ -622,51 +622,6 @@ OpenCode 启动时会自动下载新版本。您可以使用 `autoupdate` 选项 实验性选项不稳定。它们可能会在不另行通知的情况下被更改或移除。 ::: -### Sandbox - -OpenCode 可以在 macOS 上对 bash 命令、会话 shell 命令和 PTY 启动进行沙箱隔离。 -沙箱功能为实验性功能,需要手动启用,默认处于关闭状态。 - -```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"] - } - } -} -``` - -可用选项: - -| 选项 | 类型 | 描述 | -| ------------------------- | ---------------------------------- | ---------------------------------------------------------------- | -| `enabled` | `boolean` | 为支持的 macOS 执行路径启用沙箱。 | -| `preset` | `string` | 选择内置预设(`default`、`strict`、`network`)或自定义预设名称。 | -| `mode` | `"workspace-write" \| "read-only"` | 覆盖预设模式。 | -| `network` | `boolean` | 覆盖是否允许出站网络访问。 | -| `protected_roots` | `string[]` | 即使在可写根目录内也保持写保护的工作空间相对路径。 | -| `extra_read_roots` | `string[]` | 沙箱可以读取的额外绝对路径。 | -| `extra_write_roots` | `string[]` | 沙箱可以写入的额外绝对路径。 | -| `extra_deny_paths` | `string[]` | 沙箱必须拒绝的额外绝对路径。 | -| `excluded_commands` | `string[]` | 执行前必须阻止的命令前缀。 | -| `allow_unsandboxed_retry` | `boolean` | 允许在沙箱拒绝后进行单独的 `bash:unsandboxed` 权限控制重试。 | -| `fail_if_unavailable` | `boolean` | 当沙箱已启用但无法激活时硬性失败。 | -| `presets` | `Record` | 使用 `mode`、`network`、根路径和权限覆盖定义自定义预设。 | - -:::note -沙箱中的命令可以读取内置系统根目录,如 `/bin`、`/usr`、`/opt/homebrew`、`/System`、`/Library`、`/dev`、`/tmp` 和 `/private/etc`。 -敏感的主目录路径(如 `~/.ssh`、`~/.gnupg` 和云凭据目录)默认被拒绝。 -请参阅[安全策略](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md)了解完整的威胁模型、覆盖范围和当前限制。 -::: - --- ## 变量 diff --git a/packages/web/src/content/docs/zh-cn/permissions.mdx b/packages/web/src/content/docs/zh-cn/permissions.mdx index d7123711f6f4..f928554f2a84 100644 --- a/packages/web/src/content/docs/zh-cn/permissions.mdx +++ b/packages/web/src/content/docs/zh-cn/permissions.mdx @@ -134,7 +134,6 @@ OpenCode 的权限以工具名称为键,外加几个安全防护项: - `glob` — 文件通配(匹配通配模式) - `grep` — 内容搜索(匹配正则表达式模式) - `bash` — 运行 shell 命令(匹配解析后的命令,如 `git status --porcelain`) -- `bash:unsandboxed` — 在沙箱拒绝后或在明确的非沙箱请求后,在沙箱外重新运行 shell 命令 - `task` — 启动子代理(匹配子代理类型) - `skill` — 加载技能(匹配技能名称) - `lsp` — 运行 LSP 查询(当前不支持细粒度配置) @@ -234,11 +233,3 @@ Only analyze code and suggest changes. :::tip 对带参数的命令使用模式匹配。`"grep *"` 允许执行 `grep pattern file.txt`,而单独的 `"grep"` 则会阻止它。像 `git status` 这样的命令适用于默认行为,但在传递参数时需要显式权限(如 `"git status *"`)。 ::: - ---- - -## 沙箱交互 - -当 macOS 沙箱启用时,被阻止的 bash 命令可以触发单独的 `bash:unsandboxed` 权限请求。 -当 OpenCode 检测到可能的沙箱拒绝,或者命令在第一个非空行中使用 `# opencode:unsandboxed <原因>` 明确请求跳过沙箱时,就会发生这种情况。 -在 [`experimental.sandbox`](/docs/config#sandbox) 中配置沙箱本身。 diff --git a/packages/web/src/content/docs/zh-cn/tools.mdx b/packages/web/src/content/docs/zh-cn/tools.mdx index 149eb2d0c774..4c603705901a 100644 --- a/packages/web/src/content/docs/zh-cn/tools.mdx +++ b/packages/web/src/content/docs/zh-cn/tools.mdx @@ -60,12 +60,6 @@ description: 管理 LLM 可以使用的工具。 该工具允许 LLM 运行终端命令,例如 `npm install`、`git status` 或其他任何 shell 命令。 -:::note -当 macOS 沙箱启用时,bash 在文件系统限制下运行,并可在需要时请求单独的非沙箱重试。 -如果您知道某个命令必须在沙箱外启动,请在命令的第一个非空行添加 `# opencode:unsandboxed <原因>`。 -有关支持的行为和限制,请参阅[沙箱配置](/docs/config#sandbox)。 -::: - --- ### edit diff --git a/packages/web/src/content/docs/zh-tw/config.mdx b/packages/web/src/content/docs/zh-tw/config.mdx index d614516fedb1..a694823a65f9 100644 --- a/packages/web/src/content/docs/zh-tw/config.mdx +++ b/packages/web/src/content/docs/zh-tw/config.mdx @@ -626,51 +626,6 @@ OpenCode 啟動時會自動下載新版本。您可以使用 `autoupdate` 選項 實驗性選項不穩定。它們可能會在不另行通知的情況下被變更或移除。 ::: -### Sandbox - -OpenCode 可以在 macOS 上對 bash 指令、工作階段 shell 指令和 PTY 啟動進行沙箱隔離。 -沙箱功能為實驗性功能,需要手動啟用,預設處於關閉狀態。 - -```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"] - } - } -} -``` - -可用選項: - -| 選項 | 類型 | 描述 | -| ------------------------- | ---------------------------------- | -------------------------------------------------------------- | -| `enabled` | `boolean` | 為支援的 macOS 執行路徑啟用沙箱。 | -| `preset` | `string` | 選擇內建預設(`default`、`strict`、`network`)或自訂預設名稱。 | -| `mode` | `"workspace-write" \| "read-only"` | 覆寫預設模式。 | -| `network` | `boolean` | 覆寫是否允許出站網路存取。 | -| `protected_roots` | `string[]` | 即使在可寫根目錄內也保持寫入保護的工作空間相對路徑。 | -| `extra_read_roots` | `string[]` | 沙箱可以讀取的額外絕對路徑。 | -| `extra_write_roots` | `string[]` | 沙箱可以寫入的額外絕對路徑。 | -| `extra_deny_paths` | `string[]` | 沙箱必須拒絕的額外絕對路徑。 | -| `excluded_commands` | `string[]` | 執行前必須阻止的指令前綴。 | -| `allow_unsandboxed_retry` | `boolean` | 允許在沙箱拒絕後進行單獨的 `bash:unsandboxed` 權限控制重試。 | -| `fail_if_unavailable` | `boolean` | 當沙箱已啟用但無法啟動時硬性失敗。 | -| `presets` | `Record` | 使用 `mode`、`network`、根路徑和權限覆寫定義自訂預設。 | - -:::note -沙箱中的指令可以讀取內建系統根目錄,如 `/bin`、`/usr`、`/opt/homebrew`、`/System`、`/Library`、`/dev`、`/tmp` 和 `/private/etc`。 -敏感的主目錄路徑(如 `~/.ssh`、`~/.gnupg` 和雲端憑證目錄)預設被拒絕。 -請參閱[安全政策](https://github.com/anomalyco/opencode/blob/dev/SECURITY.md)了解完整的威脅模型、涵蓋範圍和目前限制。 -::: - --- ## 變數 diff --git a/packages/web/src/content/docs/zh-tw/permissions.mdx b/packages/web/src/content/docs/zh-tw/permissions.mdx index a5d2cb4deeed..bacd87c1ed56 100644 --- a/packages/web/src/content/docs/zh-tw/permissions.mdx +++ b/packages/web/src/content/docs/zh-tw/permissions.mdx @@ -134,7 +134,6 @@ OpenCode 的權限以工具名稱為鍵,外加幾個安全防護項: - `glob` — 檔案萬用字元比對(比對萬用字元模式) - `grep` — 內容搜尋(比對正規表示式模式) - `bash` — 執行 shell 指令(比對解析後的指令,如 `git status --porcelain`) -- `bash:unsandboxed` — 在沙箱拒絕後或在明確的非沙箱請求後,在沙箱外重新執行 shell 指令 - `task` — 啟動子代理(比對子代理類型) - `skill` — 載入技能(比對技能名稱) - `lsp` — 執行 LSP 查詢(目前不支援細粒度設定) @@ -234,11 +233,3 @@ Only analyze code and suggest changes. :::tip 對帶參數的指令使用模式比對。`"grep *"` 允許執行 `grep pattern file.txt`,而單獨的 `"grep"` 則會阻止它。像 `git status` 這樣的指令適用於預設行為,但在傳遞參數時需要顯式權限(如 `"git status *"`)。 ::: - ---- - -## 沙箱互動 - -當 macOS 沙箱啟用時,被阻止的 bash 指令可以觸發單獨的 `bash:unsandboxed` 權限請求。 -當 OpenCode 偵測到可能的沙箱拒絕,或者指令在第一個非空行中使用 `# opencode:unsandboxed <原因>` 明確請求跳過沙箱時,就會發生這種情況。 -在 [`experimental.sandbox`](/docs/config#sandbox) 中設定沙箱本身。 diff --git a/packages/web/src/content/docs/zh-tw/tools.mdx b/packages/web/src/content/docs/zh-tw/tools.mdx index 47e9209288b8..6ce68d9fb5ec 100644 --- a/packages/web/src/content/docs/zh-tw/tools.mdx +++ b/packages/web/src/content/docs/zh-tw/tools.mdx @@ -60,12 +60,6 @@ description: 管理 LLM 可以使用的工具。 該工具允許 LLM 執行終端機指令,例如 `npm install`、`git status` 或其他任何 shell 指令。 -:::note -當 macOS 沙箱啟用時,bash 在檔案系統限制下執行,並可在需要時請求單獨的非沙箱重試。 -如果您知道某個指令必須在沙箱外啟動,請在指令的第一個非空行加上 `# opencode:unsandboxed <原因>`。 -有關支援的行為和限制,請參閱[沙箱設定](/docs/config#sandbox)。 -::: - --- ### edit diff --git a/turbo.json b/turbo.json index eb0a088c0e76..28c2fa2de0d2 100644 --- a/turbo.json +++ b/turbo.json @@ -3,9 +3,7 @@ "globalEnv": ["CI", "OPENCODE_DISABLE_SHARE"], "globalPassThroughEnv": ["CI", "OPENCODE_DISABLE_SHARE"], "tasks": { - "typecheck": { - "dependsOn": ["^typecheck"] - }, + "typecheck": {}, "build": { "dependsOn": [], "outputs": ["dist/**"] From affc1c0b704a3e69bd580b9741f3c739d100591b Mon Sep 17 00:00:00 2001 From: Flavien Darche Date: Mon, 20 Apr 2026 12:51:33 +0200 Subject: [PATCH 23/23] fix rebase --- packages/opencode/src/session/prompt.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8c1e901d0f9e..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,7 +44,7 @@ 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" @@ -986,10 +987,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the let retried = false let reason: SandboxSpawn.RetryReason | undefined - let result - try { - result = yield* exec(proactive ? raw : call) - } catch (error) { + 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( @@ -997,8 +998,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the error instanceof Error ? { cause: error } : undefined, ) } - throw error + return yield* Effect.failCause(first.cause) } + result = first.value if (!proactive) { reason = SandboxSpawn.retryReason({