diff --git a/SECURITY.md b/SECURITY.md index e7e59f4a27ac..b8738e7db7da 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,11 +12,71 @@ submit one that will be an automatic ban from the project. OpenCode is an AI-powered coding assistant that runs locally on your machine. It provides an agent system with access to powerful tools including shell execution, file operations, and web access. -### No Sandbox +### Sandboxing (macOS only, experimental) -OpenCode does **not** sandbox the agent. The permission system exists as a UX feature to help users stay aware of what actions the agent is taking - it prompts for confirmation before executing commands, writing files, etc. However, it is not designed to provide security isolation. +OpenCode can optionally sandbox certain command execution paths on macOS using `sandbox-exec`. This feature is **opt-in**, **experimental**, and **off by default**. It is not available on Linux or Windows. -If you need true isolation, run OpenCode inside a Docker container or VM. +#### Covered surfaces + +| Surface | Sandbox profile | Excluded-command check | Unsandboxed retry | +| ----------------------------------------------------------- | --------------- | ---------------------- | ----------------------------------- | +| **Bash tool** (agent-issued non-interactive commands) | Yes | Yes (pre-spawn) | Yes (`bash:unsandboxed` permission) | +| **Session command path** (user-initiated command execution) | Yes | Yes (pre-spawn) | Yes (`bash:unsandboxed` permission) | +| **PTY interactive sessions** | Yes | Initial spawn only | No | + +PTY sessions apply the sandbox profile to the initial process spawn and check `excluded_commands` before spawning. In-band command filtering inside a running PTY session is **not** performed — once a PTY shell is running, commands typed into it are not individually inspected or blocked. + +#### Presets + +Built-in presets control mode, network, and permission defaults: + +| Preset | Mode | Network | Notes | +| ------------- | ----------------- | ------- | ----------------------------------------- | +| **`default`** | `workspace-write` | No | Read system paths, read/write workspace | +| **`strict`** | `read-only` | No | Writes limited to `/tmp`; bash/edit = ask | +| **`network`** | `workspace-write` | Yes | Same as default but allows network access | + +Custom presets can be defined under `experimental.sandbox.presets` in `opencode.json`. Selecting a preset via the `preset` field resolves the named preset, then any sibling sandbox fields (`mode`, `network`, `protected_roots`, `extra_read_roots`, `extra_write_roots`) override the preset values. + +#### Protected roots + +Inside writable workspace roots, `.git` and `.opencode` are always write-protected. If the workspace is a git worktree, the resolved gitdir target (read from the `.git` file) is also write-protected. These deny rules are emitted after the write-allow rules in the `sandbox-exec` profile, so they take precedence. + +#### Modes + +- **`workspace-write`** (default) — the sandboxed process can read system paths and read/write within the project workspace. +- **`read-only`** — the sandboxed process can read, and writes are limited to `/tmp`, `/private/tmp`, and explicitly configured extra write roots. + +There is no `danger-full-access` or unrestricted mode. Even the most permissive built-in preset (`network`) still enforces filesystem boundaries and protected roots. + +#### Configuration options + +All options live under `experimental.sandbox` in `opencode.json`: + +- **`preset`** — selects a built-in or custom preset by name. Defaults to `default`. +- **`presets`** — defines custom presets keyed by name. Each preset can specify `mode`, `network`, `protected_roots`, `permission`, `extra_read_roots`, and `extra_write_roots`. +- **`mode`** — overrides the preset mode (`workspace-write` or `read-only`). +- **`network`** — overrides the preset network policy (`true` or `false`). +- **`protected_roots`** — overrides the preset list of write-protected workspace-relative paths (defaults to `.git` and `.opencode`). +- **`extra_read_roots`** — additional absolute paths the sandbox allows reading. +- **`extra_write_roots`** — additional absolute paths the sandbox allows writing. +- **`excluded_commands`** — a pre-spawn deny list of command prefixes. Matched commands are blocked before execution on all covered surfaces. +- **`fail_if_unavailable`** — when `true`, hard-fails activation if sandboxing is enabled but `sandbox-exec` is missing or the platform is unsupported. +- **`extra_deny_paths`** — extends the default set of denied paths (secrets directories like `.ssh`, `.gnupg`, `.aws`, etc.). +- **`allow_unsandboxed_retry`** — when `true`, adds a distinct `bash:unsandboxed` permission-gated retry for the bash tool and session command path only. If a sandboxed command fails due to a sandbox denial, the user is prompted to allow an unsandboxed re-execution. PTY sessions do **not** support unsandboxed retry. + +#### Not covered + +The following are explicitly **not** sandboxed: + +- MCP server processes (local stdio servers and SSE connections) +- Internal spawn utilities (`util/process.ts`, `cross-spawn-spawner.ts`) not routed through the three surfaces above +- Domain/proxy-mediated network controls +- All non-macOS platforms (Linux, Windows, etc.) + +The permission system (confirmation prompts before commands, file writes, etc.) remains a UX layer, not a security boundary. A sandbox denial can still block a command that the permission system allowed. + +For stronger isolation, run OpenCode inside a Docker container or VM. ### Server Mode @@ -24,13 +84,13 @@ Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to requ ### Out of Scope -| Category | Rationale | -| ------------------------------- | ----------------------------------------------------------------------- | -| **Server access when opted-in** | If you enable server mode, API access is expected behavior | -| **Sandbox escapes** | The permission system is not a sandbox (see above) | -| **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies | -| **MCP server behavior** | External MCP servers you configure are outside our trust boundary | -| **Malicious config files** | Users control their own config; modifying it is not an attack vector | +| Category | Rationale | +| ------------------------------------- | ---------------------------------------------------------------------------- | +| **Server access when opted-in** | If you enable server mode, API access is expected behavior | +| **Sandbox escapes (uncovered paths)** | MCP servers, non-macOS execution, and in-band PTY commands are not sandboxed | +| **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies | +| **MCP server behavior** | External MCP servers you configure are outside our trust boundary | +| **Malicious config files** | Users control their own config; modifying it is not an attack vector | --- diff --git a/bun.lock b/bun.lock index 483f551d31dd..043760593962 100644 --- a/bun.lock +++ b/bun.lock @@ -5685,8 +5685,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=="], diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 6c3f3bb55ef4..f6dde6cdf46b 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -705,6 +705,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 (بدون صندوق حماية)", + "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 63880462a467..dc8f73dc2379 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -715,6 +715,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 (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 2b589eb35f62..287e5587e3cb 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -789,6 +789,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 (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 b096d87b4b7b..f3b68e1bc76b 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -783,6 +783,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 (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 6dc0b0497245..f47eb4c06f1f 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -726,6 +726,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 (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/en.ts b/packages/app/src/i18n/en.ts index c6bcc37b116f..c069fa6c3857 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -887,6 +887,12 @@ export const dict = { "settings.permissions.tool.list.description": "List files within a directory", "settings.permissions.tool.bash.title": "Bash", "settings.permissions.tool.bash.description": "Run shell commands", + "settings.permissions.tool.bash_unsandboxed.title": "Bash (Unsandboxed)", + "settings.permissions.tool.bash_unsandboxed.description": "Retry a shell command without sandbox restrictions", + "settings.permissions.tool.bash_unsandboxed_network.title": "Bash (Unsandboxed)", + "settings.permissions.tool.bash_unsandboxed_network.description": "Sandbox networking is disabled, so the previous attempt may have failed because of the sandbox.", + "settings.permissions.tool.bash_unsandboxed_explicit.title": "Bash (Unsandboxed)", + "settings.permissions.tool.bash_unsandboxed_explicit.description": "The command requested to run without sandbox restrictions.", "settings.permissions.tool.task.title": "Task", "settings.permissions.tool.task.description": "Launch sub-agents", "settings.permissions.tool.skill.title": "Skill", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index c600232ef613..e47d092697ef 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -796,6 +796,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 (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 a140c1e3a123..b471fb577151 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -724,6 +724,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 (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 3da1c4b43b58..5ed1f1418b37 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -710,6 +710,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(サンドボックスなし)", + "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 0f2f7647abf5..10278a1ab3cb 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -709,6 +709,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 (샌드박스 없음)", + "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 a0a968179cd0..986994dc8946 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -790,6 +790,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 (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 88d209f11ff2..fa6fbbdbc2db 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -712,6 +712,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 (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 688289b7e812..90a04fbb8e25 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -791,6 +791,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 (без песочницы)", + "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 5decf3adb531..94905b5cb814 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -779,6 +779,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 (ไม่มีแซนด์บ็อกซ์)", + "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 6a3ade0d0b07..ed5040721834 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -798,6 +798,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 (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 28231733eaba..648d3800c4d2 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -777,6 +777,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(无沙箱)", + "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 4abdf5db574d..df78e9c49033 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -773,6 +773,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(無沙箱)", + "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/pages/session/composer/session-permission-dock.tsx b/packages/app/src/pages/session/composer/session-permission-dock.tsx index 06ff4f4aa715..f5f6c737ca06 100644 --- a/packages/app/src/pages/session/composer/session-permission-dock.tsx +++ b/packages/app/src/pages/session/composer/session-permission-dock.tsx @@ -13,7 +13,18 @@ export function SessionPermissionDock(props: { const language = useLanguage() const toolDescription = () => { - const key = `settings.permissions.tool.${props.request.permission}.description` + let permission = props.request.permission + if (permission === "bash:unsandboxed") { + const reason = props.request.metadata?.reason + if (reason === "possible_network_sandbox_denial") { + permission = "bash_unsandboxed_network" + } else if (reason === "explicit_request") { + permission = "bash_unsandboxed_explicit" + } else { + permission = "bash_unsandboxed" + } + } + const key = `settings.permissions.tool.${permission}.description` const value = language.t(key as Parameters[0]) if (value === key) return "" return value diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 0c6fe6ec91c8..1d68508970de 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, ServiceMap, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" @@ -103,6 +105,13 @@ export namespace Agent { }) 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: { @@ -115,6 +124,7 @@ export namespace Agent { question: "allow", plan_enter: "allow", }), + overlay, user, ), mode: "primary", @@ -152,6 +162,7 @@ export namespace Agent { Permission.fromConfig({ todowrite: "deny", }), + overlay, user, ), options: {}, @@ -243,7 +254,7 @@ export namespace Agent { item = agents[key] = { name: key, mode: "all", - permission: Permission.merge(defaults, user), + permission: Permission.merge(defaults, overlay, user), options: {}, native: false, } diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 04130aa95113..17e55dfa5d87 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -192,7 +192,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 d4ae8db61c28..89d24bc21094 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -32,6 +32,7 @@ import type { } from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" +import { SandboxSpawn } from "@/sandbox/spawn" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" @@ -1778,7 +1779,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) @@ -1812,6 +1820,8 @@ function Bash(props: ToolProps) { return `# ${desc} in ${wd}` }) + const command = createMemo(() => SandboxSpawn.directive(props.input.command ?? "").command) + return ( @@ -1822,7 +1832,7 @@ function Bash(props: ToolProps) { onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined} > - $ {props.input.command} + $ {command()} {limited()} @@ -1833,8 +1843,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 e0b5002b61bb..7a62ee3fdac9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -17,6 +17,7 @@ import { Global } from "@/global" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" +import { SandboxSpawn } from "@/sandbox/spawn" type PermissionStage = "permission" | "always" | "reject" @@ -286,7 +287,8 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { if (permission === "bash") { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" - const command = typeof data.command === "string" ? data.command : "" + const rawCommand = typeof data.command === "string" ? data.command : "" + const command = SandboxSpawn.directive(rawCommand).command return { icon: "#", title, @@ -300,6 +302,36 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } + if (permission === "bash:unsandboxed") { + const rawCommand = typeof data.command === "string" ? data.command : "" + const command = SandboxSpawn.directive(rawCommand).command + const reason = props.request.metadata?.reason + const detail = typeof props.request.metadata?.detail === "string" ? props.request.metadata.detail : "" + const isNetwork = reason === "possible_network_sandbox_denial" + const isExplicit = reason === "explicit_request" + return { + icon: "#", + title: isExplicit ? "Run shell command without sandbox" : "Retry shell command without sandbox", + body: ( + + + {isExplicit + ? "The command requested to run without sandbox restrictions." + : isNetwork + ? "Sandbox networking is disabled, so the previous attempt may have failed because of the sandbox." + : "The previous sandboxed attempt was denied."} + + + {detail} + + + {"$ " + command} + + + ), + } + } + if (permission === "task") { const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown" const desc = typeof data.description === "string" ? data.description : "" diff --git a/packages/opencode/src/cli/cmd/tui/util/transcript.ts b/packages/opencode/src/cli/cmd/tui/util/transcript.ts index a89559c953cf..fdf168f85834 100644 --- a/packages/opencode/src/cli/cmd/tui/util/transcript.ts +++ b/packages/opencode/src/cli/cmd/tui/util/transcript.ts @@ -99,7 +99,14 @@ export function formatPart(part: Part, options: TranscriptOptions): string { result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`\n` } if (options.toolDetails && part.state.status === "completed" && part.state.output) { - result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`\n` + let output = part.state.output + if (part.tool === "bash") { + output = output + .replace(/[\s\S]*?(?:<\/bash_metadata>|$)/g, "") + .replace(/[\s\S]*?(?:<\/metadata>|$)/g, "") + .trim() + } + result += `\n**Output:**\n\`\`\`\n${output}\n\`\`\`\n` } if (options.toolDetails && part.state.status === "error" && part.state.error) { result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`\n` diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1952e3b57249..741bd9318c4d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -37,6 +37,7 @@ import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@/filesystem" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { SandboxPreset } from "@/sandbox/preset" import { Duration, Effect, Layer, Option, ServiceMap } from "effect" import { Flock } from "@/util/flock" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" @@ -509,6 +510,65 @@ export namespace Config { }) export type Command = z.infer + const SandboxPresetConfig = 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(), + }) + + const SandboxConfig = z + .object({ + enabled: z + .boolean() + .optional() + .describe("Enable macOS sandboxing for bash, session shell commands, and PTY initial spawns"), + preset: z.string().optional().describe("Named sandbox preset (default, strict, network, or a custom preset)"), + mode: z + .enum(["workspace-write", "read-only"]) + .optional() + .describe("Sandbox mode for command execution (default: preset default, otherwise workspace-write)"), + network: z.boolean().optional().describe("Allow outbound network access inside the macOS sandbox"), + protected_roots: z + .array(z.string()) + .optional() + .describe("Workspace-relative paths that remain write-protected inside writable roots"), + extra_read_roots: z.array(z.string()).optional().describe("Additional read-only roots for macOS sandboxing"), + extra_write_roots: z.array(z.string()).optional().describe("Additional writable roots for macOS sandboxing"), + extra_deny_paths: z.array(z.string()).optional().describe("Additional denied paths for macOS sandboxing"), + excluded_commands: z + .array(z.string()) + .optional() + .describe("Command prefixes that must be blocked before execution"), + allow_unsandboxed_retry: z + .boolean() + .optional() + .describe("Allow an explicit unsandboxed retry after a sandbox denial"), + fail_if_unavailable: z.boolean().optional().describe("Hard-fail when sandboxing is enabled but cannot activate"), + presets: z.record(z.string(), SandboxPresetConfig).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}"`, + }) + }) + export const Skills = z.object({ paths: z.array(z.string()).optional().describe("Additional paths to skill folders"), urls: z @@ -1077,6 +1137,7 @@ export namespace Config { .object({ disable_paste_summary: z.boolean().optional(), batch_tool: z.boolean().optional().describe("Enable the batch tool"), + sandbox: SandboxConfig.optional(), openTelemetry: z .boolean() .optional() diff --git a/packages/opencode/src/file/protected.ts b/packages/opencode/src/file/protected.ts index d5197461932a..dfd0272c13b2 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/filesystem" const home = os.homedir() @@ -56,4 +57,33 @@ export namespace Protected { if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n)) 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) + } } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index f091fa02a987..dc5ae8f6732c 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -59,6 +59,7 @@ export namespace Flag { ).pipe(Config.withDefault(false)) export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY") + export declare const OPENCODE_EXPERIMENTAL_SANDBOX: boolean const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"] export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = @@ -157,3 +158,11 @@ Object.defineProperty(Flag, "OPENCODE_CLIENT", { enumerable: true, configurable: false, }) + +Object.defineProperty(Flag, "OPENCODE_EXPERIMENTAL_SANDBOX", { + get() { + return truthy("OPENCODE_EXPERIMENTAL_SANDBOX") + }, + enumerable: true, + configurable: false, +}) diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 33fd209cccf5..731ebf11372a 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -191,7 +191,7 @@ export namespace LSP { root: existing?.root ?? (async () => Instance.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/launch.ts b/packages/opencode/src/lsp/launch.ts index b7dca446f567..d3673b58e587 100644 --- a/packages/opencode/src/lsp/launch.ts +++ b/packages/opencode/src/lsp/launch.ts @@ -3,13 +3,15 @@ import { Process } from "../util/process" 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) { +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 proc = Process.spawn([cmd, ...args], { ...(cfg ?? {}), + cwd, stdin: "pipe", stdout: "pipe", stderr: "pipe", diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index f50c858e912f..6e74319c3555 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -85,7 +85,7 @@ export namespace LSPServer { return } return { - process: spawn(deno, ["lsp"], { + process: await spawn(deno, ["lsp"], { cwd: root, }), } @@ -105,7 +105,6 @@ export namespace LSPServer { 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 ( @@ -115,7 +114,7 @@ export namespace LSPServer { args.push("--ignore-node-modules") } - const proc = spawn(bin, args, { + const proc = await spawn(bin, args, { cwd: root, env: { ...process.env, @@ -146,7 +145,7 @@ export namespace LSPServer { binary = resolved } args.push("--stdio") - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -205,7 +204,7 @@ export namespace LSPServer { 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, @@ -259,13 +258,13 @@ export namespace LSPServer { } 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, }), } @@ -280,7 +279,7 @@ export namespace LSPServer { } if (serverBin) { return { - process: spawn(serverBin, [], { + process: await spawn(serverBin, [], { cwd: root, }), } @@ -340,7 +339,7 @@ export namespace LSPServer { args = ["lsp-proxy", "--stdio"] } - const proc = spawn(bin, args, { + const proc = await spawn(bin, args, { cwd: root, env: { ...process.env, @@ -385,7 +384,7 @@ export namespace LSPServer { }) } return { - process: spawn(bin!, { + process: await spawn(bin!, { cwd: root, }), } @@ -423,7 +422,7 @@ export namespace LSPServer { }) } return { - process: spawn(bin!, ["--lsp"], { + process: await spawn(bin!, ["--lsp"], { cwd: root, }), } @@ -483,7 +482,7 @@ export namespace LSPServer { return } - const proc = spawn(binary, ["server"], { + const proc = await spawn(binary, ["server"], { cwd: root, }) @@ -525,7 +524,7 @@ export namespace LSPServer { } } - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -594,7 +593,7 @@ export namespace LSPServer { } return { - process: spawn(binary, { + process: await spawn(binary, { cwd: root, }), } @@ -704,7 +703,7 @@ export namespace LSPServer { } return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -741,7 +740,7 @@ export namespace LSPServer { } return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -778,7 +777,7 @@ export namespace LSPServer { } return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -795,7 +794,7 @@ export namespace LSPServer { const sourcekit = which("sourcekit-lsp") if (sourcekit) { return { - process: spawn(sourcekit, { + process: await spawn(sourcekit, { cwd: root, }), } @@ -812,7 +811,7 @@ export namespace LSPServer { const bin = lspLoc.text.trim() return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -858,7 +857,7 @@ export namespace LSPServer { return } return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -874,7 +873,7 @@ export namespace LSPServer { const fromPath = which("clangd") if (fromPath) { return { - process: spawn(fromPath, args, { + process: await spawn(fromPath, args, { cwd: root, }), } @@ -884,7 +883,7 @@ export namespace LSPServer { 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, }), } @@ -897,7 +896,7 @@ export namespace LSPServer { 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, }), } @@ -1004,7 +1003,7 @@ export namespace LSPServer { log.info(`installed clangd`, { bin }) return { - process: spawn(bin, args, { + process: await spawn(bin, args, { cwd: root, }), } @@ -1025,7 +1024,7 @@ export namespace LSPServer { binary = resolved } args.push("--stdio") - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1059,7 +1058,7 @@ export namespace LSPServer { binary = resolved } args.push("--stdio") - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1172,7 +1171,7 @@ export namespace LSPServer { ) const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-jdtls-data")) return { - process: spawn( + process: await spawn( java, [ "-jar", @@ -1289,7 +1288,7 @@ export namespace LSPServer { return } return { - process: spawn(launcherScript, ["--stdio"], { + process: await spawn(launcherScript, ["--stdio"], { cwd: root, }), } @@ -1310,7 +1309,7 @@ export namespace LSPServer { binary = resolved } args.push("--stdio") - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1456,7 +1455,7 @@ export namespace LSPServer { } return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -1477,7 +1476,7 @@ export namespace LSPServer { binary = resolved } args.push("--stdio") - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1505,7 +1504,7 @@ export namespace LSPServer { return } return { - process: spawn(prisma, ["language-server"], { + process: await spawn(prisma, ["language-server"], { cwd: root, }), } @@ -1523,7 +1522,7 @@ export namespace LSPServer { return } return { - process: spawn(dart, ["language-server", "--lsp"], { + process: await spawn(dart, ["language-server", "--lsp"], { cwd: root, }), } @@ -1541,7 +1540,7 @@ export namespace LSPServer { return } return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -1561,7 +1560,7 @@ export namespace LSPServer { binary = resolved } args.push("start") - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1641,7 +1640,7 @@ export namespace LSPServer { } return { - process: spawn(bin, ["serve"], { + process: await spawn(bin, ["serve"], { cwd: root, }), initialization: { @@ -1735,7 +1734,7 @@ export namespace LSPServer { } return { - process: spawn(bin, { + process: await spawn(bin, { cwd: root, }), } @@ -1756,7 +1755,7 @@ export namespace LSPServer { binary = resolved } args.push("--stdio") - const proc = spawn(binary, args, { + const proc = await spawn(binary, args, { cwd: root, env: { ...process.env, @@ -1779,7 +1778,7 @@ export namespace LSPServer { return } return { - process: spawn(gleam, ["lsp"], { + process: await spawn(gleam, ["lsp"], { cwd: root, }), } @@ -1800,7 +1799,7 @@ export namespace LSPServer { return } return { - process: spawn(bin, ["listen"], { + process: await spawn(bin, ["listen"], { cwd: root, }), } @@ -1828,7 +1827,7 @@ export namespace LSPServer { return } return { - process: spawn(nixd, [], { + process: await spawn(nixd, [], { cwd: root, env: { ...process.env, @@ -1925,7 +1924,7 @@ export namespace LSPServer { } return { - process: spawn(bin, { cwd: root }), + process: await spawn(bin, { cwd: root }), } }, } @@ -1941,7 +1940,7 @@ export namespace LSPServer { return } return { - process: spawn(bin, ["--lsp"], { + process: await spawn(bin, ["--lsp"], { cwd: root, }), } @@ -1959,9 +1958,13 @@ export namespace LSPServer { 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/pty/index.ts b/packages/opencode/src/pty/index.ts index 0321b9800ba5..9f93a1bc2f32 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -9,8 +9,10 @@ import { Log } from "../util/log" import { lazy } from "@opencode-ai/util/lazy" import { Shell } from "@/shell/shell" import { Plugin } from "@/plugin" +import { SandboxSpawn } from "@/sandbox/spawn" import { PtyID } from "./schema" import { Effect, Layer, ServiceMap } from "effect" +import path from "path" export namespace Pty { const log = Log.create({ service: "pty" }) @@ -54,6 +56,17 @@ export namespace Pty { 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,24 @@ export namespace Pty { const s = yield* InstanceState.get(state) 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, + }, + cfg, + ), + ) + const args = argv(command, [...(input.args ?? [])], sandbox.active) const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} }) const env = { ...process.env, @@ -197,9 +222,17 @@ export namespace Pty { } log.info("creating session", { id, cmd: command, args, cwd }) + const cmd = + sandbox.active && sandbox.profile + ? SandboxSpawn.wrap({ + profile: sandbox.profile, + file: command, + args, + }) + : { file: command, args } const { spawn } = yield* Effect.promise(() => pty()) const proc = yield* Effect.sync(() => - spawn(command, args, { + spawn(cmd.file, cmd.args, { name: "xterm-256color", cwd, env, diff --git a/packages/opencode/src/sandbox/policy.ts b/packages/opencode/src/sandbox/policy.ts new file mode 100644 index 000000000000..636d5027da2e --- /dev/null +++ b/packages/opencode/src/sandbox/policy.ts @@ -0,0 +1,106 @@ +import path from "path" + +export namespace SandboxPolicy { + export type Mode = "workspace-write" | "read-only" + + export interface Input { + cwd: string + project_root: string + worktree_root: string + home: string + mode?: Mode + protected_roots?: string[] + extra_read_roots?: string[] + extra_write_roots?: string[] + extra_deny_paths?: string[] + opencode_roots?: string[] + allow_network?: boolean + } + + export interface Output { + profile: string + read: string[] + write: string[] + deny: string[] + } + + const read = [ + "/bin", + "/sbin", + "/usr", + "/opt/homebrew", + "/System", + "/Library", + "/dev", + "/tmp", + "/private/tmp", + "/private/etc", + ] + const temp = ["/tmp", "/private/tmp"] + const secret = [".ssh", ".gnupg", ".aws", ".azure", path.join(".config", "gcloud"), ".netrc", ".npmrc"] + + function uniq(input: string[]) { + return [...new Set(input.filter(Boolean))].toSorted((a, b) => a.localeCompare(b)) + } + + function quote(input: string) { + return input.replaceAll("\\", "\\\\").replaceAll('"', '\\"') + } + + function allow(action: string, roots: string[]) { + if (roots.length === 0) return [] + return [`(allow ${action}`, ...roots.map((item) => ` (subpath "${quote(item)}")`), ")"] + } + + function deny(roots: string[]) { + return roots.flatMap((item) => [ + `(deny file-read* (subpath "${quote(item)}"))`, + `(deny file-write* (subpath "${quote(item)}"))`, + ]) + } + + function denyWrite(roots: string[]) { + return roots.map((item) => `(deny file-write* (subpath "${quote(item)}"))`) + } + + export function build(input: Input): Output { + const denyRoots = uniq([ + ...secret.map((item) => path.join(input.home, item)), + ...(input.opencode_roots ?? []), + ...(input.extra_deny_paths ?? []), + ]) + const protectedRoots = uniq(input.protected_roots ?? []) + const readRoots = uniq([ + input.cwd, + input.project_root, + input.worktree_root, + ...read, + ...(input.extra_read_roots ?? []), + ]) + const writeRoots = + input.mode === "read-only" + ? uniq([...temp, ...(input.extra_write_roots ?? [])]) + : uniq([input.cwd, input.project_root, input.worktree_root, ...(input.extra_write_roots ?? [])]) + const profile = [ + "(version 1)", + "(deny default)", + '(import "system.sb")', + "(allow process-exec)", + "(allow process-fork)", + "(allow signal (target same-sandbox))", + "(allow process-info* (target same-sandbox))", + '(allow file-write-data (require-all (path "/dev/null") (vnode-type CHARACTER-DEVICE)))', + ...allow("file-read*", readRoots), + ...allow("file-write*", writeRoots), + ...deny(denyRoots), + ...denyWrite(protectedRoots), + ...(input.allow_network ? ["(allow network*)"] : []), + ].join("\n") + return { + profile, + read: readRoots, + write: writeRoots, + deny: denyRoots, + } + } +} diff --git a/packages/opencode/src/sandbox/preset.ts b/packages/opencode/src/sandbox/preset.ts new file mode 100644 index 000000000000..80c90f45f3d3 --- /dev/null +++ b/packages/opencode/src/sandbox/preset.ts @@ -0,0 +1,108 @@ +import { Protected } from "@/file/protected" +import { SandboxPolicy } from "./policy" + +export namespace SandboxPreset { + export type Action = "ask" | "allow" | "deny" + + export type Permission = Record> + + export interface Def { + mode: SandboxPolicy.Mode + network: boolean + protected_roots: string[] + permission: Permission + extra_read_roots: string[] + extra_write_roots: string[] + } + + export interface PartialDef { + mode?: SandboxPolicy.Mode + network?: boolean + protected_roots?: string[] + permission?: Permission + extra_read_roots?: string[] + extra_write_roots?: string[] + } + + export interface Input extends PartialDef { + preset?: string + presets?: Record + } + + const make = (input: { + mode?: SandboxPolicy.Mode + network?: boolean + protected_roots?: string[] + permission?: Permission + extra_read_roots?: string[] + extra_write_roots?: string[] + }): Def => ({ + mode: input.mode ?? "workspace-write", + network: input.network ?? false, + protected_roots: [...(input.protected_roots ?? Protected.workspace())], + permission: { ...(input.permission ?? {}) }, + extra_read_roots: [...(input.extra_read_roots ?? [])], + extra_write_roots: [...(input.extra_write_roots ?? [])], + }) + + const builtin: Record = { + default: make({ + mode: "workspace-write", + network: false, + }), + strict: make({ + mode: "read-only", + network: false, + permission: { + bash: "ask", + edit: "ask", + }, + }), + network: make({ + mode: "workspace-write", + network: true, + }), + } + + export function names() { + return Object.keys(builtin) + } + + export function builtins(): Record { + return Object.fromEntries(Object.entries(builtin).map(([key, value]) => [key, make(value)])) + } + + function merge(base: Def, overrides?: PartialDef): Def { + if (!overrides) return make(base) + return { + mode: overrides.mode ?? base.mode, + network: overrides.network ?? base.network, + protected_roots: overrides.protected_roots ? [...overrides.protected_roots] : [...base.protected_roots], + permission: overrides.permission ? { ...overrides.permission } : { ...base.permission }, + extra_read_roots: overrides.extra_read_roots ? [...overrides.extra_read_roots] : [...base.extra_read_roots], + extra_write_roots: overrides.extra_write_roots ? [...overrides.extra_write_roots] : [...base.extra_write_roots], + } + } + + export function resolve(name: string, input?: { presets?: Record; overrides?: PartialDef }) { + const base = builtin[name] ?? (input?.presets ? input.presets[name] : undefined) + if (!base) throw new Error(`Unknown sandbox preset "${name}"`) + return merge(make(base), input?.overrides) + } + + export function active(input?: Input) { + return resolve(input?.preset ?? "default", { + presets: input?.presets, + overrides: input + ? { + mode: input.mode, + network: input.network, + protected_roots: input.protected_roots, + permission: input.permission, + extra_read_roots: input.extra_read_roots, + extra_write_roots: input.extra_write_roots, + } + : undefined, + }) + } +} diff --git a/packages/opencode/src/sandbox/spawn.ts b/packages/opencode/src/sandbox/spawn.ts new file mode 100644 index 000000000000..d072a719a939 --- /dev/null +++ b/packages/opencode/src/sandbox/spawn.ts @@ -0,0 +1,450 @@ +import { Config } from "@/config/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 os from "os" +import path from "path" +import { SandboxPolicy } from "./policy" +import { SandboxPreset } from "./preset" + +const log = Log.create({ service: "sandbox" }) +const bin = "/usr/bin/sandbox-exec" + +export namespace SandboxSpawn { + export type Mode = SandboxPolicy.Mode + export type RetryReason = "sandbox_denial" | "possible_network_sandbox_denial" + export type UnsandboxedReason = RetryReason | "explicit_request" + + export interface Directive { + command: string + detail?: string + } + + export interface Diag { + requested: boolean + active: boolean + reason: "disabled" | "unsupported_platform" | "sandbox_exec_missing" | "unsafe_root" | "enabled" + wrapper: string + cwd: string + mode: Mode + read_roots: string[] + write_roots: string[] + unsafe_roots: string[] + allow_network: boolean + } + + export interface Settings { + requested: boolean + preset?: string + mode?: Mode + network?: boolean + protected_roots?: string[] + presets: Record + extra_read_roots?: string[] + extra_write_roots?: string[] + extra_deny_paths: string[] + excluded_commands: string[] + allow_unsandboxed_retry: boolean + fail_if_unavailable: boolean + } + + export interface ResolveInput { + cwd: string + project_root: string + worktree_root: string + preset?: string + mode?: Mode + allow_network?: boolean + } + + export interface PlanInput extends ResolveInput { + requested: boolean + platform: NodeJS.Platform + available: boolean + home: string + mode?: Mode + fail_if_unavailable?: boolean + protected_roots?: string[] + opencode_roots?: string[] + extra_read_roots?: string[] + extra_write_roots?: string[] + extra_deny_paths?: string[] + } + + export interface Output { + active: boolean + profile?: string + diag: Diag + } + + export interface WrapInput { + profile: string + file: string + args: string[] + } + + export class Error extends globalThis.Error { + readonly diag: Diag + + constructor(diag: Diag) { + super(`macOS sandbox is enabled but unavailable: ${diag.reason}`) + this.name = "SandboxSpawnError" + this.diag = diag + } + } + + export class CommandError extends globalThis.Error { + readonly command: string + readonly rule: string + + constructor(command: string, rule: string) { + super(`Command \"${command}\" is blocked by excluded_commands entry \"${rule}\"`) + this.name = "SandboxCommandError" + this.command = command + this.rule = rule + } + } + + export interface Match { + command: string + rule: string + } + + function uniq(input: string[]) { + return [...new Set(input.filter(Boolean))].toSorted((a, b) => a.localeCompare(b)) + } + + function name(input: string) { + return process.platform === "win32" ? path.win32.basename(input, ".exe") : path.basename(input) + } + + function parts(input: string[]) { + if (input.length === 0) return [] + const head = name(input[0]) || input[0] + return [head, ...input.slice(1)] + } + + function prefix(input: string[]) { + return BashArity.prefix(parts(input)).join(" ") + } + + function trim(input: string) { + return input.replace(/^['"]|['"]$/g, "") + } + + function assign(input: string) { + return /^[A-Za-z_][A-Za-z0-9_]*=/.test(input) + } + + function shell(input: string) { + const out: string[][] = [] + let next: string[] = [] + for (const item of input.match( + /&&|\|\||(?])&(?![0-9])|[|;\n]|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s|;&\n]+/g, + ) ?? []) { + if (["&&", "||", "|", ";", "&", "\n"].includes(item)) { + if (next.length > 0) out.push(next) + next = [] + continue + } + next.push(trim(item)) + } + if (next.length > 0) out.push(next) + return out + } + + export function directive(input: string): Directive { + const lines = input.split("\n") + const idx = lines.findIndex((item) => item.trim().length > 0) + if (idx < 0) return { command: input } + const line = lines[idx] + const match = line && /^\s*#\s*opencode:\s*unsandboxed(?:\s+(.*))?\s*$/.exec(line) + if (!match) return { command: input } + return { + command: lines.filter((_, i) => i !== idx).join("\n"), + detail: match[1]?.trim() || undefined, + } + } + + function list(input: string[]): string[][] { + const next = [...input] + while (assign(next[0] ?? "")) next.shift() + if (next.length === 0) return [] + + const head = name(next[0]).toLowerCase() + if (head === "env") { + const rest = next.slice(1) + while (rest[0]?.startsWith("-")) rest.shift() + while (assign(rest[0] ?? "")) rest.shift() + return list(rest) + } + + if (["sh", "bash", "zsh", "fish", "nu"].includes(head)) { + const idx = next.findIndex((item) => item === "-c" || item === "/c" || item === "-Command") + if (idx >= 0 && next[idx + 1]) { + return shell(next[idx + 1]).flatMap(list) + } + } + + return [next] + } + + function scan(input: string[], home: string) { + return uniq(input).reduce( + (acc, item) => { + if (item === "/") { + acc.bad.push(item) + return acc + } + if (item === home || Filesystem.contains(item, home)) { + acc.bad.push(item) + return acc + } + acc.good.push(item) + return acc + }, + { good: [] as string[], bad: [] as string[] }, + ) + } + + function base(input: PlanInput, reason: Diag["reason"]) { + return { + requested: input.requested, + active: false, + reason, + wrapper: bin, + cwd: input.cwd, + mode: input.mode ?? "workspace-write", + read_roots: [], + write_roots: [], + unsafe_roots: [], + allow_network: input.allow_network === true, + } satisfies Diag + } + + export function settings(): Promise { + return Config.get().then((cfg) => { + const env = process.env["OPENCODE_EXPERIMENTAL_SANDBOX"] + const raw = cfg.experimental?.sandbox + return { + requested: env === undefined ? raw?.enabled === true : Flag.OPENCODE_EXPERIMENTAL_SANDBOX, + preset: raw?.preset, + mode: raw?.mode, + network: raw?.network, + protected_roots: raw?.protected_roots, + presets: raw?.presets ?? {}, + extra_read_roots: raw?.extra_read_roots, + extra_write_roots: raw?.extra_write_roots, + extra_deny_paths: raw?.extra_deny_paths ?? [], + excluded_commands: raw?.excluded_commands ?? [], + allow_unsandboxed_retry: raw?.allow_unsandboxed_retry === true, + fail_if_unavailable: raw?.fail_if_unavailable === true, + } satisfies Settings + }) + } + + export function excluded(input: string[], blocked: string[]): Match | undefined { + for (const candidate of list(input)) { + const command = prefix(candidate) + if (!command) continue + for (const item of blocked) { + const rule = prefix(item.trim().split(/\s+/).filter(Boolean)) + if (!rule) continue + if (command === rule || command.startsWith(`${rule} `)) { + return { command, rule } + } + } + } + } + + export function excludedText(input: string, blocked: string[]) { + for (const item of shell(input)) { + const match = excluded(item, blocked) + if (match) return match + } + } + + function usesText(input: string, target: string) { + return shell(input) + .flatMap(list) + .some((item) => name(item[0]).toLowerCase() === target) + } + + export function retryReason(input: { + active: boolean + code: number + stderr: string + allow_network?: boolean + command?: string + }): RetryReason | undefined { + if (!input.active || input.code === 0) return + if (input.stderr.includes("sandbox-exec: sandbox_apply: Operation not permitted")) return "sandbox_denial" + if (input.stderr.includes("sandbox-exec: execvp()")) return "sandbox_denial" + if (input.stderr.includes("forbidden-sandbox-reinit")) return "sandbox_denial" + if (input.stderr.includes("Sandbox:") && input.stderr.includes("deny(1)")) return "sandbox_denial" + if (input.stderr.includes("Operation not permitted")) return "sandbox_denial" + if ( + input.allow_network === false && + input.command && + usesText(input.command, "curl") && + ((input.code === 6 && input.stderr.includes("Could not resolve host")) || + (input.code === 7 && + ["Failed to connect", "Couldn't connect", "Could not connect"].some((item) => input.stderr.includes(item)))) + ) { + return "possible_network_sandbox_denial" + } + } + + export function shouldRetry(input: { + active: boolean + code: number + stderr: string + allow_network?: boolean + command?: string + }) { + return Boolean(retryReason(input)) + } + + export function unwrap(input: { file: string; args: string[] }) { + if (input.file !== bin) return input + if (input.args[0] !== "-p") return input + const file = input.args[2] + if (!file) return input + return { + file, + args: input.args.slice(3), + } + } + + export function plan(input: PlanInput): Output { + if (!input.requested) { + return { active: false, diag: base(input, "disabled") } + } + + if (input.platform !== "darwin") { + const diag = base(input, "unsupported_platform") + if (input.fail_if_unavailable) throw new Error(diag) + return { active: false, diag } + } + + if (!input.available) { + const diag = base(input, "sandbox_exec_missing") + if (input.fail_if_unavailable) throw new Error(diag) + return { active: false, diag } + } + + const read = scan( + [...(input.extra_read_roots ?? []), input.cwd, input.project_root, input.worktree_root], + input.home, + ) + const write = scan( + input.mode === "read-only" + ? [...(input.extra_write_roots ?? [])] + : [...(input.extra_write_roots ?? []), input.cwd, input.project_root, input.worktree_root], + input.home, + ) + const bad = uniq([...read.bad, ...write.bad]) + + if (bad.length > 0) { + throw new Error({ + ...base(input, "unsafe_root"), + unsafe_roots: bad, + }) + } + + const policy = SandboxPolicy.build({ + cwd: input.cwd, + project_root: input.project_root, + worktree_root: input.worktree_root, + home: input.home, + extra_read_roots: read.good, + extra_write_roots: write.good, + extra_deny_paths: input.extra_deny_paths, + protected_roots: input.protected_roots, + opencode_roots: input.opencode_roots, + mode: input.mode, + allow_network: input.allow_network, + }) + + const diag = { + requested: true, + active: true, + reason: "enabled", + wrapper: bin, + cwd: input.cwd, + mode: input.mode ?? "workspace-write", + read_roots: policy.read, + write_roots: policy.write, + unsafe_roots: [], + allow_network: input.allow_network === true, + } satisfies Diag + + return { + active: true, + profile: policy.profile, + diag, + } + } + + export function wrap(input: WrapInput) { + return { + file: bin, + args: ["-p", input.profile, input.file, ...input.args], + } + } + + export async function resolve(input: ResolveInput, cfg?: Settings): Promise { + const raw = cfg ?? (await settings()) + const preset = + raw.requested || raw.preset || input.preset + ? SandboxPreset.active({ + preset: input.preset ?? raw.preset, + presets: raw.presets, + mode: input.mode ?? raw.mode, + network: input.allow_network ?? raw.network, + protected_roots: raw.protected_roots, + extra_read_roots: raw.extra_read_roots, + extra_write_roots: raw.extra_write_roots, + }) + : undefined + const home = Filesystem.resolve(Global.Path.home) + const tmp = Filesystem.resolve(os.tmpdir()) + const temp = Filesystem.contains(tmp, home) ? [] : [tmp] + const mode = preset?.mode ?? input.mode ?? raw.mode ?? "workspace-write" + const allowNetwork = input.allow_network ?? preset?.network ?? raw.network ?? false + const readRoots = (preset?.extra_read_roots ?? raw.extra_read_roots ?? []).map(Filesystem.resolve) + const writeRoots = (preset?.extra_write_roots ?? raw.extra_write_roots ?? []).map(Filesystem.resolve) + const protectedRoots = await Protected.resolve( + Filesystem.resolve(input.worktree_root), + preset?.protected_roots ?? [], + ) + const out = plan({ + requested: raw.requested, + platform: process.platform, + available: Boolean(Filesystem.stat(bin)?.size), + cwd: Filesystem.resolve(input.cwd), + project_root: Filesystem.resolve(input.project_root), + worktree_root: Filesystem.resolve(input.worktree_root), + home, + mode, + fail_if_unavailable: raw.fail_if_unavailable, + protected_roots: protectedRoots, + opencode_roots: [Global.Path.data, Global.Path.config, Global.Path.state, Global.Path.cache].map( + Filesystem.resolve, + ), + extra_read_roots: [...readRoots, ...temp], + extra_write_roots: mode === "read-only" ? writeRoots : [...writeRoots, ...temp], + extra_deny_paths: raw.extra_deny_paths.map(Filesystem.resolve), + allow_network: allowNetwork, + }) + + if (out.active) log.debug("sandbox active", out.diag) + else if (out.diag.requested) log.info("sandbox inactive", out.diag) + else log.debug("sandbox disabled", out.diag) + + return out + } +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e9bd5bcd5605..32ce87fc0fc6 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -48,6 +48,8 @@ import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { TaskTool } from "@/tool/task" +import { SandboxSpawn } from "@/sandbox/spawn" +import { commandFamilies } from "@/tool/bash" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -807,66 +809,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the } yield* sessions.updatePart(part) - const sh = Shell.preferred() - const shellName = ( - process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh) - ).toLowerCase() - const invocations: Record = { - nu: { args: ["-c", input.command] }, - fish: { args: ["-c", input.command] }, - zsh: { - args: [ - "-l", - "-c", - ` - __oc_cwd=$PWD - [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true - [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true - cd "$__oc_cwd" - eval ${JSON.stringify(input.command)} - `, - ], - }, - bash: { - args: [ - "-l", - "-c", - ` - __oc_cwd=$PWD - shopt -s expand_aliases - [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true - cd "$__oc_cwd" - eval ${JSON.stringify(input.command)} - `, - ], - }, - cmd: { args: ["/c", input.command] }, - powershell: { args: ["-NoProfile", "-Command", input.command] }, - pwsh: { args: ["-NoProfile", "-Command", input.command] }, - "": { args: ["-c", input.command] }, - } - - const args = (invocations[shellName] ?? invocations[""]).args - const cwd = ctx.directory - const shellEnv = yield* plugin.trigger( - "shell.env", - { cwd, sessionID: input.sessionID, callID: part.callID }, - { env: {} }, - ) - - const cmd = ChildProcess.make(sh, args, { - cwd, - extendEnv: true, - env: { ...shellEnv.env, TERM: "dumb" }, - stdin: "ignore", - forceKillAfter: "3 seconds", - }) - let output = "" let aborted = false + let done = false const finish = Effect.uninterruptible( Effect.gen(function* () { + if (done) return + done = true if (aborted) { output += "\n\n" + ["", "User aborted the command", ""].join("\n") } @@ -888,35 +838,278 @@ 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 Effect.runFork(sessions.updatePart(part)) + 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", + ` + __oc_cwd=$PWD + [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true + [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true + cd "$__oc_cwd" + eval ${JSON.stringify(command)} + `, + ], + }, + bash: { + args: [ + "-l", + "-c", + ` + __oc_cwd=$PWD + shopt -s expand_aliases + [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true + cd "$__oc_cwd" + eval ${JSON.stringify(command)} + `, + ], + }, + cmd: { args: ["/c", command] }, + powershell: { args: ["-NoProfile", "-Command", command] }, + pwsh: { args: ["-NoProfile", "-Command", command] }, + "": { args: ["-c", command] }, + } + const clean: Record = { + nu: { args: ["-c", command] }, + fish: { args: ["-c", command] }, + zsh: { args: ["-f", "-c", command] }, + bash: { args: ["--noprofile", "--norc", "-c", command] }, + cmd: { args: ["/c", command] }, + powershell: { args: ["-NoProfile", "-Command", command] }, + pwsh: { args: ["-NoProfile", "-Command", command] }, + "": { args: ["-c", command] }, + } + + const cwd = ctx.directory + const shellEnv = yield* plugin.trigger( + "shell.env", + { cwd, sessionID: input.sessionID, callID: part.callID }, + { env: {} }, + ) + const root = ctx.worktree === "/" ? ctx.directory : ctx.worktree + const sandbox = yield* Effect.promise(() => + SandboxSpawn.resolve( + { + cwd, + project_root: ctx.directory, + worktree_root: root, + }, + cfg, + ), + ) + const cleanArgs = clean[shellName]?.args ?? clean[""].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 }) + : raw + const env = { ...shellEnv.env, TERM: "dumb" } + + const run = 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 Effect.runFork(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 Effect.runFork(sessions.updatePart(part)) + } + }), + ), + ) + const abort = Effect.callback((resume) => { + if (signal.aborted) return resume(Effect.void) + const done = () => resume(Effect.void) + signal.addEventListener("abort", done, { once: true }) + return Effect.sync(() => signal.removeEventListener("abort", done)) + }) + const next = yield* Effect.raceAll([ + handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))), + abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: 1 }))), + ]) + + if (next.kind === "abort") { + aborted = true + yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.uninterruptible, Effect.orDie) + } + + return next.code + }).pipe(Effect.scoped, Effect.exit) + + if (Exit.isFailure(exit)) { + if (Cause.hasInterruptsOnly(exit.cause)) return { code: 1, stderr } + return yield* Effect.failCause(exit.cause) } - }), - ) - yield* handle.exitCode - }).pipe( - Effect.scoped, - Effect.onInterrupt(() => - Effect.sync(() => { - aborted = true - }), - ), - Effect.orDie, - Effect.ensuring(finish), - Effect.exit, - ) - if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) { - return yield* Effect.failCause(exit.cause) - } + return { code: exit.value, stderr } + }) + + let proactive = false + let rejected = false + let asked = false + const unsandboxed = cfg.allow_unsandboxed_retry ? yield* Effect.promise(() => commandFamilies(command)) : [] + if (command !== input.command && cfg.allow_unsandboxed_retry && sandbox.active) { + asked = true + const exit = yield* permission + .ask({ + permission: "bash:unsandboxed", + patterns: unsandboxed, + always: unsandboxed, + metadata: { + reason: "explicit_request" satisfies SandboxSpawn.UnsandboxedReason, + detail: request.detail, + command, + }, + sessionID: input.sessionID, + tool: { + messageID: msg.id, + callID: part.callID, + }, + ruleset: Permission.merge(agent.permission, session.permission ?? []), + }) + .pipe(Effect.exit) + if (Exit.isSuccess(exit)) { + proactive = true + } else { + rejected = true + log.info("proactive unsandboxed request rejected", { + error: Cause.squash(exit.cause), + sessionID: input.sessionID, + }) + } + } + + let retried = false + let reason: SandboxSpawn.RetryReason | undefined + let result: { code: number; stderr: string } + const first = yield* run(proactive ? raw : call).pipe(Effect.exit) + if (Exit.isFailure(first)) { + const error = Cause.squash(first.cause) + if (rejected && !proactive && sandbox.active) { + const message = error instanceof Error ? error.message : String(error) + throw new Error( + `Explicit unsandboxed request was rejected; sandboxed fallback failed before command start: ${message}`, + error instanceof Error ? { cause: error } : undefined, + ) + } + return yield* Effect.failCause(first.cause) + } + result = first.value + + if (!proactive) { + reason = SandboxSpawn.retryReason({ + active: sandbox.active, + code: result.code, + stderr: result.stderr, + allow_network: sandbox.diag.allow_network, + command, + }) + } - return { info: msg, parts: [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* run(raw) + } else { + log.info("unsandboxed retry rejected", { + error: Cause.squash(exit.cause), + sessionID: input.sessionID, + }) + } + } + + if (rejected) { + output += + "\n\n" + + ["", "Explicit unsandboxed request was rejected; command ran in sandbox", ""].join( + "\n", + ) + } + + if (retried) { + output += + "\n\n" + + [ + "", + reason === "possible_network_sandbox_denial" + ? "Retried command without sandbox after a possible network-related sandbox failure" + : "Retried command without sandbox after sandbox denial", + "", + ].join("\n") + } + + yield* finish + return { info: msg, parts: [part] } + } catch (error) { + output = error instanceof Error ? error.message : String(error) + log.error("session shell failed", { error, sessionID: input.sessionID }) + yield* finish + return { info: msg, parts: [part] } + } + }).pipe(Effect.onInterrupt(() => finish)) }) const getModel = Effect.fn("SessionPrompt.getModel")(function* ( @@ -1577,7 +1770,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the function* (input: ShellInput) { const s = yield* InstanceState.get(state) const runner = getRunner(s.runners, input.sessionID) - return yield* runner.startShell((signal) => shellImpl(input, signal)) + return yield* runner.startShell((signal) => shellImpl(input, signal).pipe(Effect.orDie)) }, ) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 365fda329604..6130c30e555a 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -20,6 +20,7 @@ import { Plugin } from "@/plugin" import { Cause, Effect, Exit, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { SandboxSpawn } from "@/sandbox/spawn" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -50,22 +51,6 @@ const FILES = new Set([ const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) -const Parameters = z.object({ - command: z.string().describe("The command to execute"), - timeout: z.number().describe("Optional timeout in milliseconds").optional(), - workdir: z - .string() - .describe( - `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, - ) - .optional(), - description: z - .string() - .describe( - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - ), -}) - type Part = { type: string text: string @@ -79,6 +64,14 @@ type Scan = { export const log = Log.create({ service: "bash-tool" }) +function args(shell: string, command: string) { + const name = (process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell)).toLowerCase() + if (name === "powershell" || name === "pwsh") return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command] + if (name === "zsh") return ["-f", "-c", command] + if (name === "bash") return ["--noprofile", "--norc", "-c", command] + return ["-c", command] +} + const resolveWasm = (asset: string) => { if (asset.startsWith("file://")) return fileURLToPath(asset) if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset @@ -238,7 +231,7 @@ function pathArgs(list: Part[], ps: boolean) { return out } -async function collect(root: Node, cwd: string, ps: boolean, shell: string): Promise { +async function collect(root: Node, cwd: string, ps: boolean, shell: string, deny: string[]): Promise { const scan: Scan = { dirs: new Set(), patterns: new Set(), @@ -248,6 +241,10 @@ async function collect(root: Node, cwd: string, ps: boolean, shell: string): Pro 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)) { @@ -311,18 +308,8 @@ async function shellEnv(ctx: Tool.Context, cwd: string) { } } -function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { - if (process.platform === "win32" && PS.has(name)) { - return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { - cwd, - env, - stdin: "ignore", - detached: false, - }) - } - - return ChildProcess.make(command, [], { - shell, +function raw(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { + return ChildProcess.make(shell, args(shell, command), { cwd, env, stdin: "ignore", @@ -330,22 +317,84 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod }) } +async function cmd( + shell: string, + name: string, + command: string, + cwd: string, + env: NodeJS.ProcessEnv, + cfg: SandboxSpawn.Settings, +) { + const root = Instance.worktree === "/" ? Instance.directory : Instance.worktree + const sandbox = await SandboxSpawn.resolve( + { + cwd, + project_root: Instance.directory, + worktree_root: root, + }, + cfg, + ) + const plain = raw(shell, command, cwd, env) + + if (sandbox.active && sandbox.profile) { + const wrap = SandboxSpawn.wrap({ + profile: sandbox.profile, + file: shell, + args: args(shell, command), + }) + return { + proc: ChildProcess.make(wrap.file, wrap.args, { + cwd, + env, + stdin: "ignore", + detached: process.platform !== "win32", + }), + plain, + sandbox, + } + } + + if (process.platform === "win32" && PS.has(name)) { + return { + proc: ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { + cwd, + env, + stdin: "ignore", + detached: false, + }), + plain, + sandbox, + } + } + + return { + proc: ChildProcess.make(command, [], { + shell, + cwd, + env, + stdin: "ignore", + detached: process.platform !== "win32", + }), + plain, + sandbox, + } +} + async function run( input: { shell: string name: string command: string + source: string + detail?: string cwd: string env: NodeJS.ProcessEnv timeout: number description: string + cfg: SandboxSpawn.Settings }, ctx: Tool.Context, ) { - let output = "" - let expired = false - let aborted = false - ctx.metadata({ metadata: { output: "", @@ -353,74 +402,191 @@ async function run( }, }) - const exit = await CrossSpawnSpawner.runPromiseExit((spawner) => - Effect.gen(function* () { - const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env)) - - yield* Effect.forkScoped( - Stream.runForEach(Stream.decodeText(handle.all), (chunk) => - Effect.sync(() => { - output += chunk - ctx.metadata({ - metadata: { - output: preview(output), - description: input.description, - }, - }) - }), - ), - ) + const launch = await cmd(input.shell, input.name, input.command, input.cwd, input.env, input.cfg) + + const exec = async (proc: ReturnType) => { + let output = "" + let stderr = "" + let expired = false + let aborted = false + + const exit = await CrossSpawnSpawner.runPromiseExit((spawner) => + Effect.gen(function* () { + const handle = yield* spawner.spawn(proc) + + yield* Effect.forkScoped( + Stream.runForEach(Stream.decodeText(handle.stdout), (chunk) => + Effect.sync(() => { + output += chunk + ctx.metadata({ + metadata: { + output: preview(output), + description: input.description, + }, + }) + }), + ), + ) + yield* Effect.forkScoped( + Stream.runForEach(Stream.decodeText(handle.stderr), (chunk) => + Effect.sync(() => { + stderr += chunk + output += chunk + ctx.metadata({ + metadata: { + output: preview(output), + description: input.description, + }, + }) + }), + ), + ) + + 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") { + expired = true + yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + } + + return exit.kind === "exit" ? exit.code : null + }).pipe(Effect.scoped, Effect.orDie), + ) - 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 code: number | null = null + if (Exit.isSuccess(exit)) { + code = exit.value + } else if (!Cause.hasInterruptsOnly(exit.cause)) { + throw Cause.squash(exit.cause) + } - const timeout = Effect.sleep(`${input.timeout + 100} millis`) + return { + output, + stderr, + timedOut: expired, + aborted, + code, + } + } - 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 }))), - ]) + let retried = false + let proactive = false + let rejected = false + let asked = false + const unsandboxed = input.cfg.allow_unsandboxed_retry ? await commandFamilies(input.command) : [] + + if (input.command !== input.source && input.cfg.allow_unsandboxed_retry && launch.sandbox.active) { + asked = true + try { + await ctx.ask({ + permission: "bash:unsandboxed", + patterns: unsandboxed, + always: unsandboxed, + metadata: { + reason: "explicit_request" satisfies SandboxSpawn.UnsandboxedReason, + detail: input.detail, + command: input.command, + }, + }) + proactive = true + } catch (error) { + rejected = true + log.info("proactive unsandboxed request rejected", { error }) + } + } - if (exit.kind === "abort") { - aborted = true - yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) - } - if (exit.kind === "timeout") { - expired = true - yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) - } + let reason: SandboxSpawn.RetryReason | undefined + let result + try { + result = await 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 + } - return exit.kind === "exit" ? exit.code : null - }).pipe(Effect.scoped, Effect.orDie), - ) + 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, + }) + } - let code: number | null = null - if (Exit.isSuccess(exit)) { - code = exit.value - } else if (!Cause.hasInterruptsOnly(exit.cause)) { - throw Cause.squash(exit.cause) + if (input.cfg.allow_unsandboxed_retry && !asked && !result.timedOut && !result.aborted && reason) { + asked = true + try { + await ctx.ask({ + permission: "bash:unsandboxed", + patterns: unsandboxed, + always: unsandboxed, + metadata: { + reason, + command: input.command, + }, + }) + retried = true + ctx.metadata({ + metadata: { + output: "", + description: input.description, + }, + }) + result = await exec(launch.plain) + } catch (error) { + log.info("unsandboxed retry rejected", { error }) + } } const meta: string[] = [] - if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`) - if (aborted) meta.push("User aborted the command") + 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 (result.aborted) meta.push("User aborted the command") if (meta.length > 0) { - output += "\n\n\n" + meta.join("\n") + "\n" + result.output += "\n\n\n" + meta.join("\n") + "\n" } return { title: input.description, metadata: { - output: preview(output), - exit: code, + output: preview(result.output), + exit: result.code, description: input.description, }, - output, + output: result.output, } } @@ -451,10 +617,38 @@ const parser = lazy(async () => { return { bash, ps } }) +export async function commandFamilies(cmd: string): Promise { + const root = await parse(cmd, false).catch(() => undefined) + if (!root) return [cmd] + const result = new Set() + for (const node of root.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", async () => { const shell = Shell.acceptable() const name = Shell.name(shell) + const cfg = await SandboxSpawn.settings() 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." @@ -462,22 +656,43 @@ export const BashTool = Tool.define("bash", async () => { log.info("bash tool using shell", { shell }) return { - description: DESCRIPTION.replaceAll("${directory}", Instance.directory) - .replaceAll("${os}", process.platform) + description: DESCRIPTION.replaceAll("${os}", process.platform) .replaceAll("${shell}", name) .replaceAll("${chaining}", chain) .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) - .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), - parameters: Parameters, + .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)) + .replaceAll( + "${unsandboxed}", + cfg.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: z.object({ + command: z.string().describe("The command to execute"), + timeout: z.number().describe("Optional timeout in milliseconds").optional(), + workdir: z + .string() + .describe( + `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`, + ) + .optional(), + description: z + .string() + .describe( + "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + ), + }), async execute(params, ctx) { + const request = SandboxSpawn.directive(params.command) + const command = request.command const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } const timeout = params.timeout ?? DEFAULT_TIMEOUT const ps = PS.has(name) - const root = await parse(params.command, ps) - const scan = await collect(root, cwd, ps, shell) + const root = await parse(command, ps) + const scan = await collect(root, cwd, ps, shell, cfg.excluded_commands) if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) await ask(ctx, scan) @@ -485,11 +700,14 @@ export const BashTool = Tool.define("bash", async () => { { shell, name, - command: params.command, + command, + source: params.command, + detail: request.detail, cwd, env: await shellEnv(ctx, cwd), timeout, description: params.description, + cfg, }, ctx, ) diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index 668cea307ce4..455009745832 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -88,6 +88,8 @@ Important notes: - IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit +${unsandboxed} + # Creating pull requests Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 0ac61aee7172..58e45441a5ec 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -112,6 +112,59 @@ test("loads JSON config file", async () => { }) }) +test("loads experimental sandbox config", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + preset: "strict", + mode: "read-only", + network: false, + protected_roots: [".git", ".opencode", ".env"], + extra_read_roots: ["/tmp/read"], + extra_write_roots: ["/tmp/write"], + extra_deny_paths: ["/tmp/deny"], + excluded_commands: ["rm"], + allow_unsandboxed_retry: false, + fail_if_unavailable: true, + presets: { + ci: { + mode: "workspace-write", + network: true, + protected_roots: [".git", ".opencode"], + extra_read_roots: ["/tmp/ci-read"], + extra_write_roots: ["/tmp/ci-write"], + permission: { + bash: "allow", + }, + }, + }, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.experimental?.sandbox?.enabled).toBe(true) + expect(config.experimental?.sandbox?.preset).toBe("strict") + expect(config.experimental?.sandbox?.mode).toBe("read-only") + expect(config.experimental?.sandbox?.network).toBe(false) + expect(config.experimental?.sandbox?.protected_roots).toEqual([".git", ".opencode", ".env"]) + expect(config.experimental?.sandbox?.extra_read_roots).toEqual(["/tmp/read"]) + expect(config.experimental?.sandbox?.extra_write_roots).toEqual(["/tmp/write"]) + expect(config.experimental?.sandbox?.extra_deny_paths).toEqual(["/tmp/deny"]) + expect(config.experimental?.sandbox?.excluded_commands).toEqual(["rm"]) + expect(config.experimental?.sandbox?.allow_unsandboxed_retry).toBe(false) + expect(config.experimental?.sandbox?.fail_if_unavailable).toBe(true) + expect(config.experimental?.sandbox?.presets?.ci?.network).toBe(true) + expect(config.experimental?.sandbox?.presets?.ci?.permission).toEqual({ bash: "allow" }) + }, + }) +}) + test("loads project config from Git Bash and MSYS2 paths on Windows", async () => { // Git Bash and MSYS2 both use //... paths on Windows. await check((dir) => { 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/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index f7a949c921f8..0cd8445c7de2 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 { Bus } from "../../src/bus" import { Instance } from "../../src/project/instance" import { Pty } from "../../src/pty" @@ -6,6 +8,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) { @@ -23,7 +34,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, @@ -60,7 +71,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, @@ -89,4 +100,106 @@ describe("pty", () => { }, }) }) + + test("preserves pty io through the sandbox wrapper", async () => { + if (process.platform !== "darwin") return + + await using dir = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + }, + }, + }, + }) + + await Instance.provide({ + directory: dir.path, + fn: async () => { + const info = await 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("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: { + 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 new file mode 100644 index 000000000000..a9216cdc3d0b --- /dev/null +++ b/packages/opencode/test/sandbox/policy.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Protected } from "../../src/file/protected" +import { SandboxPolicy } from "../../src/sandbox/policy" +import { tmpdir } from "../fixture/fixture" + +describe("sandbox.policy", () => { + test("builds a deny-by-default profile with explicit roots", () => { + const out = SandboxPolicy.build({ + cwd: "/tmp/project/app", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + extra_read_roots: ["/opt/homebrew"], + extra_write_roots: ["/tmp/project/tmp"], + extra_deny_paths: ["/tmp/blocked"], + }) + + expect(out.profile).toContain("(deny default)") + expect(out.profile).toContain("(allow file-read*") + expect(out.profile).toContain("(allow file-write*") + expect(out.profile).not.toContain("(allow network*)") + expect(out.profile).not.toContain("AF_UNIX") + expect(out.read).toContain("/tmp/project") + expect(out.read).toContain("/opt/homebrew") + expect(out.write).toContain("/tmp/project/tmp") + expect(out.deny).toContain(path.join("/Users/tester", ".ssh")) + expect(out.deny).toContain("/tmp/blocked") + }) + + test("includes /opt/homebrew in default read roots without extra config", () => { + const out = SandboxPolicy.build({ + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + }) + + expect(out.read).toContain("/opt/homebrew") + expect(out.profile).toContain('(subpath "/opt/homebrew")') + }) + + test("adds network rules only when requested", () => { + const out = SandboxPolicy.build({ + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + allow_network: true, + }) + + expect(out.profile).toContain("(allow network*)") + expect(out.profile).not.toContain("AF_UNIX") + expect(out.profile).not.toContain("network-bind") + expect(out.profile).not.toContain("network-outbound") + }) + + test("supports read-only mode without project write roots", () => { + const out = SandboxPolicy.build({ + cwd: "/tmp/project/app", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + mode: "read-only", + extra_write_roots: ["/tmp/project/tmp"], + }) + + expect(out.read).toContain("/tmp/project") + expect(out.write).toEqual(["/private/tmp", "/tmp", "/tmp/project/tmp"]) + expect(out.profile).not.toContain('(allow file-write*\n (subpath "/tmp/project")') + }) + + test("resolves workspace protected roots for a standard repo", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".git"), { recursive: true }) + }, + }) + expect(await Protected.resolve(tmp.path, [".git"])).toEqual([path.join(tmp.path, ".git")]) + }) + + test("resolves both the gitfile and gitdir for a worktree", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, "repo") + const worktree = path.join(tmp.path, "worktree") + const gitdir = path.join(root, ".git", "worktrees", "demo") + await fs.mkdir(gitdir, { recursive: true }) + await fs.mkdir(worktree, { recursive: true }) + await Bun.write(path.join(worktree, ".git"), `gitdir: ../repo/.git/worktrees/demo\n`) + + expect(await Protected.resolve(worktree, [".git"])).toEqual([gitdir, path.join(worktree, ".git")].toSorted()) + }) + + test("emits protected-root write denies after write allows", () => { + const out = SandboxPolicy.build({ + cwd: "/tmp/project/app", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + protected_roots: ["/tmp/project/.git", "/tmp/project/.opencode"], + }) + + const allow = out.profile.indexOf("(allow file-write*") + const git = out.profile.indexOf('(deny file-write* (subpath "/tmp/project/.git"))') + const opencode = out.profile.indexOf('(deny file-write* (subpath "/tmp/project/.opencode"))') + expect(allow).toBeGreaterThanOrEqual(0) + expect(git).toBeGreaterThan(allow) + expect(opencode).toBeGreaterThan(allow) + expect(out.profile).not.toContain('(deny file-read* (subpath "/tmp/project/.git"))') + }) +}) diff --git a/packages/opencode/test/sandbox/preset-permission.test.ts b/packages/opencode/test/sandbox/preset-permission.test.ts new file mode 100644 index 000000000000..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/spawn.test.ts b/packages/opencode/test/sandbox/spawn.test.ts new file mode 100644 index 000000000000..44e7866c3655 --- /dev/null +++ b/packages/opencode/test/sandbox/spawn.test.ts @@ -0,0 +1,285 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { SandboxSpawn } from "../../src/sandbox/spawn" +import { tmpdir } from "../fixture/fixture" + +const home = process.env.HOME +const testHome = process.env.OPENCODE_TEST_HOME + +afterEach(() => { + if (home === undefined) delete process.env.HOME + else process.env.HOME = home + if (testHome === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = testHome + delete process.env.OPENCODE_EXPERIMENTAL_SANDBOX +}) + +describe("sandbox.spawn", () => { + test("wraps darwin commands with sandbox-exec", () => { + const out = SandboxSpawn.plan({ + requested: true, + platform: "darwin", + available: true, + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + }) + const cmd = SandboxSpawn.wrap({ + profile: out.profile!, + file: "/bin/zsh", + args: ["-f", "-c", "pwd"], + }) + + expect(out.active).toBe(true) + expect(out.diag.reason).toBe("enabled") + expect(cmd.file).toBe("/usr/bin/sandbox-exec") + expect(cmd.args[0]).toBe("-p") + expect(cmd.args[2]).toBe("/bin/zsh") + }) + + test("keeps non-darwin behavior unchanged", () => { + const out = SandboxSpawn.plan({ + requested: true, + platform: "linux", + available: true, + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + }) + + expect(out.active).toBe(false) + expect(out.diag.reason).toBe("unsupported_platform") + }) + + test("matches excluded command prefixes", () => { + expect(SandboxSpawn.excluded(["rm", "-rf", "/tmp/test"], ["rm"]))?.toEqual({ + command: "rm", + rule: "rm", + }) + expect(SandboxSpawn.excluded(["git", "status"], ["git"]))?.toEqual({ + command: "git status", + rule: "git", + }) + expect(SandboxSpawn.excluded(["printf", "ok"], ["rm"]))?.toBeUndefined() + }) + + test("matches excluded commands through wrappers and shell text", () => { + expect(SandboxSpawn.excluded(["env", "FOO=1", "python", "-c", "print(1)"], ["python"]))?.toEqual({ + command: "python -c", + rule: "python", + }) + expect(SandboxSpawn.excluded(["sh", "-c", "curl https://example.com"], ["curl"]))?.toEqual({ + command: "curl", + rule: "curl", + }) + expect(SandboxSpawn.excludedText("FOO=1 curl https://example.com", ["curl"]))?.toEqual({ + command: "curl", + rule: "curl", + }) + expect(SandboxSpawn.excludedText("echo ok\ncurl https://example.com", ["curl"]))?.toEqual({ + command: "curl", + rule: "curl", + }) + expect(SandboxSpawn.excludedText("echo ok & curl https://example.com", ["curl"]))?.toEqual({ + command: "curl", + rule: "curl", + }) + }) + + test("rejects broad home roots", () => { + expect(() => + SandboxSpawn.plan({ + requested: true, + platform: "darwin", + available: true, + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + extra_read_roots: ["/Users/tester"], + }), + ).toThrow("unsafe_root") + }) + + test("hard-fails when sandbox availability is required", () => { + expect(() => + SandboxSpawn.plan({ + requested: true, + platform: "linux", + available: true, + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + fail_if_unavailable: true, + }), + ).toThrow("unsupported_platform") + + expect(() => + SandboxSpawn.plan({ + requested: true, + platform: "darwin", + available: false, + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + fail_if_unavailable: true, + }), + ).toThrow("sandbox_exec_missing") + }) + + test("falls back when hard-fail is disabled", () => { + const platform = SandboxSpawn.plan({ + requested: true, + platform: "linux", + available: true, + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + }) + const missing = SandboxSpawn.plan({ + requested: true, + platform: "darwin", + available: false, + cwd: "/tmp/project", + project_root: "/tmp/project", + worktree_root: "/tmp/project", + home: "/Users/tester", + }) + + expect(platform.active).toBe(false) + expect(platform.diag.reason).toBe("unsupported_platform") + expect(missing.active).toBe(false) + expect(missing.diag.reason).toBe("sandbox_exec_missing") + }) + + test("detects likely sandbox denials conservatively", () => { + expect( + SandboxSpawn.retryReason({ + active: true, + code: 1, + stderr: "sandbox-exec: sandbox_apply: Operation not permitted", + }), + ).toBe("sandbox_denial") + expect( + SandboxSpawn.shouldRetry({ + active: true, + code: 1, + stderr: "sandbox-exec: sandbox_apply: Operation not permitted", + }), + ).toBe(true) + expect( + SandboxSpawn.shouldRetry({ + active: true, + code: 1, + stderr: "Sandbox: bash(1) deny(1) file-read-data /Users/tester/.ssh/secret", + }), + ).toBe(true) + expect( + SandboxSpawn.shouldRetry({ + active: true, + code: 1, + stderr: "Operation not permitted", + }), + ).toBe(true) + expect( + SandboxSpawn.shouldRetry({ + active: false, + code: 1, + stderr: "sandbox-exec: sandbox_apply: Operation not permitted", + }), + ).toBe(false) + expect( + SandboxSpawn.shouldRetry({ + active: true, + code: 1, + stderr: "permission denied", + }), + ).toBe(false) + }) + + test("classifies likely curl network failures when sandbox networking is disabled", () => { + expect( + SandboxSpawn.retryReason({ + active: true, + code: 6, + stderr: "curl: (6) Could not resolve host: example.com", + allow_network: false, + command: "FOO=1 curl -I https://example.com", + }), + ).toBe("possible_network_sandbox_denial") + expect( + SandboxSpawn.retryReason({ + active: true, + code: 7, + stderr: "curl: (7) Failed to connect to example.com port 443", + allow_network: false, + command: 'sh -c "curl https://example.com"', + }), + ).toBe("possible_network_sandbox_denial") + expect( + SandboxSpawn.retryReason({ + active: true, + code: 6, + stderr: "curl: (6) Could not resolve host: example.com", + allow_network: true, + command: "curl https://example.com", + }), + ).toBeUndefined() + expect( + SandboxSpawn.retryReason({ + active: true, + code: 6, + stderr: "curl: (6) Could not resolve host: example.com", + allow_network: false, + command: "python script.py", + }), + ).toBeUndefined() + }) + + test("extracts explicit unsandboxed directives from the first non-empty line", () => { + expect(SandboxSpawn.directive("# opencode:unsandboxed needs network\ncurl https://example.com")).toEqual({ + command: "curl https://example.com", + detail: "needs network", + }) + expect(SandboxSpawn.directive("\n # opencode:unsandboxed\ncat foo.txt")).toEqual({ + command: "\ncat foo.txt", + detail: undefined, + }) + expect(SandboxSpawn.directive("echo hi\n# opencode:unsandboxed later")).toEqual({ + command: "echo hi\n# opencode:unsandboxed later", + }) + }) + + test("respects the env override at runtime", async () => { + await using home = await tmpdir() + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: false, + }, + }, + }, + }) + process.env.OPENCODE_EXPERIMENTAL_SANDBOX = "true" + process.env.OPENCODE_TEST_HOME = home.path + process.env.HOME = home.path + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const out = await SandboxSpawn.resolve({ + cwd: tmp.path, + project_root: tmp.path, + worktree_root: tmp.path, + }) + expect(out.diag.requested).toBe(true) + }, + }) + }) +}) diff --git a/packages/opencode/test/session/prompt-sandbox.test.ts b/packages/opencode/test/session/prompt-sandbox.test.ts new file mode 100644 index 000000000000..1f7136bad10b --- /dev/null +++ b/packages/opencode/test/session/prompt-sandbox.test.ts @@ -0,0 +1,539 @@ +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 { SandboxSpawn } from "../../src/sandbox/spawn" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { tmpdir } from "../fixture/fixture" + +const env = { + HOME: process.env.HOME, + OPENCODE_TEST_HOME: process.env.OPENCODE_TEST_HOME, + SHELL: process.env.SHELL, +} + +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 + if (env.OPENCODE_TEST_HOME === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = env.OPENCODE_TEST_HOME + if (env.SHELL === undefined) delete process.env.SHELL + else process.env.SHELL = env.SHELL +}) + +describe("session.prompt sandbox", () => { + test("keeps shell startup deterministic in sandbox mode", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".zshenv"), "export OPENCODE_ZSHENV_HIT=1\n") + }, + }) + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + }, + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await 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 Bun.write(path.join(dir, ".zshenv"), "export OPENCODE_ZSHENV_HIT=1\n") + }, + }) + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + }, + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await 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) + }, + }) + }) + + test("blocks excluded commands before spawning", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + excluded_commands: ["curl"], + }, + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await 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({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + permission: { + "bash:unsandboxed": "allow", + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await 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") + expect(part.state.output).not.toContain("1\n") + await Session.remove(session.id) + }, + }) + }) + + test("runs unsandboxed on the first attempt after an explicit request", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + await Bun.write(path.join(dir, ".zshenv"), "export OPENCODE_ZSHENV_HIT=1\n") + }, + }) + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + permission: { + "bash:unsandboxed": "allow", + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await 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") + expect(part.state.output).not.toContain("1\n") + 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({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + permission: { + "bash:unsandboxed": "ask", + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await 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("unsandboxed always-allow reuses generalized pattern across command variants", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "foo"), "foo-content\n") + await Bun.write(path.join(dir, ".ssh", "bar"), "bar-content\n") + }, + }) + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + permission: { + "bash:unsandboxed": "ask", + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await 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({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + permission: { + "bash:unsandboxed": "ask", + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await 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 wrap = spyOn(SandboxSpawn, "wrap").mockReturnValue({ + file: "/definitely/missing-sandbox-exec", + args: [], + }) + try { + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + permission: { + "bash:unsandboxed": "ask", + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await 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 { + wrap.mockRestore() + } + }) +}) diff --git a/packages/opencode/test/tool/bash-sandbox.test.ts b/packages/opencode/test/tool/bash-sandbox.test.ts new file mode 100644 index 000000000000..a2699ab5bd5e --- /dev/null +++ b/packages/opencode/test/tool/bash-sandbox.test.ts @@ -0,0 +1,642 @@ +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 { 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" + +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 () => {}, +} + +const makeCtx = (ask: Tool.Context["ask"] = async () => {}) => ({ + ...ctx, + ask, +}) + +afterEach(() => { + if (env.HOME === undefined) delete process.env.HOME + else process.env.HOME = env.HOME + if (env.OPENCODE_TEST_HOME === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = env.OPENCODE_TEST_HOME + if (env.SHELL === undefined) delete process.env.SHELL + else process.env.SHELL = env.SHELL +}) + +describe("tool.bash sandbox", () => { + test("allows in-project writes and skips zsh startup files in sandbox mode", async () => { + await using home = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".zshenv"), "export OPENCODE_ZSHENV_HIT=1\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const out = await bash.execute( + { + command: "printf '%s\n' \"${OPENCODE_ZSHENV_HIT:-missing}\" && printf 'ok' > hit.txt && cat hit.txt", + description: "Writes inside sandbox", + }, + ctx, + ) + expect(out.metadata.exit).toBe(0) + expect(out.output).toContain("missing") + expect(out.output).toContain("ok") + }, + }) + }) + + test("denies reads from sensitive home paths", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + await Bun.write(path.join(dir, ".zshenv"), "export OPENCODE_ZSHENV_HIT=1\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seen: string[] = [] + const bash = await BashTool.init() + const out = await bash.execute( + { + command: 'cat "$HOME/.ssh/secret"', + description: "Reads blocked home file", + }, + makeCtx(async (req) => { + seen.push(req.permission) + }), + ) + expect(out.output).not.toContain("secret\n") + expect(out.output).toContain("Operation not permitted") + expect(seen).not.toContain("bash:unsandboxed") + }, + }) + }) + + test("denies in-project writes in read-only mode", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir() + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + mode: "read-only", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const out = await bash.execute( + { + command: "printf 'ok' > hit.txt", + description: "Writes in read-only sandbox", + }, + ctx, + ) + expect(out.output).toContain("operation not permitted") + expect(await fs.stat(path.join(tmp.path, "hit.txt")).catch(() => undefined)).toBeUndefined() + }, + }) + }) + + test("allows tmp writes in read-only mode", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir() + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + mode: "read-only", + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const file = path.join("/tmp", `opencode-sandbox-${Date.now()}.txt`) + const bash = await BashTool.init() + const out = await bash.execute( + { + command: `printf 'ok' > ${JSON.stringify(file)} && cat ${JSON.stringify(file)} && rm ${JSON.stringify(file)}`, + description: "Writes tmp file in read-only sandbox", + }, + ctx, + ) + expect(out.output).toContain("ok") + }, + }) + }) + + test("blocks excluded commands before execution", async () => { + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + excluded_commands: ["rm"], + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seen: string[] = [] + const bash = await BashTool.init() + await expect( + bash.execute( + { + command: "rm -rf /tmp/test", + description: "Blocked command", + }, + makeCtx(async (req) => { + seen.push(req.permission) + }), + ), + ).rejects.toThrow("rm") + expect(seen).toEqual([]) + }, + }) + }) + + test("retries unsandboxed when allowed and approved", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seen: string[] = [] + const bash = await BashTool.init() + const out = await bash.execute( + { + command: 'cat "$HOME/.ssh/secret"', + description: "Retries without sandbox", + }, + makeCtx(async (req) => { + seen.push(req.permission) + }), + ) + expect(seen).toContain("bash:unsandboxed") + expect(out.output).toContain("secret\n") + expect(out.output).toContain("Retried command without sandbox") + expect(out.output).not.toContain("1\n") + }, + }) + }) + + test("runs unsandboxed on the first attempt after an explicit request", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + await Bun.write(path.join(dir, ".zshenv"), "export OPENCODE_ZSHENV_HIT=1\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seen: string[] = [] + const bash = await BashTool.init() + const out = await bash.execute( + { + command: '# opencode:unsandboxed needs secret access\ncat "$HOME/.ssh/secret"', + description: "Requests unsandboxed first attempt", + }, + makeCtx(async (req) => { + seen.push(req.permission) + }), + ) + expect(seen).toContain("bash:unsandboxed") + expect(out.output).toContain("secret\n") + expect(out.output).not.toContain("Retried command without sandbox") + expect(out.output).not.toContain("1\n") + }, + }) + }) + + test("keeps the original denial when unsandboxed retry is rejected", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seen: string[] = [] + const bash = await BashTool.init() + const out = await bash.execute( + { + command: 'cat "$HOME/.ssh/secret"', + description: "Rejects unsandboxed retry", + }, + makeCtx(async (req) => { + seen.push(req.permission) + if (req.permission === "bash:unsandboxed") throw new Error("reject") + }), + ) + expect(seen).toContain("bash:unsandboxed") + expect(out.output).not.toContain("secret\n") + expect(out.output).toContain("Operation not permitted") + }, + }) + }) + + test("falls back to sandboxed execution when an explicit request is rejected", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const seen: string[] = [] + const bash = await BashTool.init() + const out = await bash.execute( + { + command: '# opencode:unsandboxed needs secret access\ncat "$HOME/.ssh/secret"', + description: "Rejects proactive unsandboxed request", + }, + makeCtx(async (req) => { + seen.push(req.permission) + if (req.permission === "bash:unsandboxed") throw new Error("reject") + }), + ) + expect(seen.filter((item) => item === "bash:unsandboxed")).toEqual(["bash:unsandboxed"]) + expect(out.output).not.toContain("secret\n") + expect(out.output).toContain("Operation not permitted") + expect(out.output).toContain("Explicit unsandboxed request was rejected; command ran in sandbox") + }, + }) + }) + + test("reports sandboxed fallback launch failures after explicit rejection", async () => { + if (process.platform !== "darwin") return + const wrap = spyOn(SandboxSpawn, "wrap").mockReturnValue({ + file: "/definitely/missing-sandbox-exec", + args: [], + }) + try { + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + }, + }) + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + await expect( + bash.execute( + { + command: "# opencode:unsandboxed needs network\nwget google.com", + description: "Rejects proactive unsandboxed request before spawn", + }, + makeCtx(async (req) => { + if (req.permission === "bash:unsandboxed") throw new Error("reject") + }), + ), + ).rejects.toThrow("Explicit unsandboxed request was rejected; sandboxed fallback failed before command start") + }, + }) + } finally { + wrap.mockRestore() + } + }) + + test("preserves timeout and abort through the sandbox wrapper", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir() + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const slow = await bash.execute( + { + command: "sleep 2", + timeout: 50, + description: "Times out in sandbox", + }, + ctx, + ) + expect(slow.output).toContain("terminated command after exceeding timeout") + + const abort = new AbortController() + const run = bash.execute( + { + command: "sleep 5", + description: "Aborts in sandbox", + }, + { ...ctx, abort: abort.signal }, + ) + setTimeout(() => abort.abort(), 50) + const out = await run + expect(out.output).toContain("User aborted the command") + }, + }) + }) + + test("commandFamilies returns generalized command-family patterns", async () => { + expect(await commandFamilies("cat foo.txt")).toEqual(["cat *"]) + expect(await commandFamilies("git push origin main")).toEqual(["git push *"]) + expect(await commandFamilies("FOO=1 npm install react")).toEqual(["npm install *"]) + const multi = await commandFamilies("cat foo.txt\ngit status") + expect(multi).toContain("cat *") + expect(multi).toContain("git status *") + expect(multi).toHaveLength(2) + }) + + test("unsandboxed retry uses generalized patterns instead of raw command", async () => { + if (process.platform !== "darwin") return + await using home = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".ssh"), { recursive: true }) + await Bun.write(path.join(dir, ".ssh", "secret"), "secret\n") + }, + }) + await using tmp = await tmpdir({ + config: { + experimental: { + sandbox: { + enabled: true, + allow_unsandboxed_retry: true, + }, + }, + }, + }) + process.env.HOME = home.path + process.env.OPENCODE_TEST_HOME = home.path + process.env.SHELL = "/bin/zsh" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const reqs: Array<{ permission: string; patterns: 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 *") + }, + }) + }) +}) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 02bd80ac9c3c..855205cb79f2 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1826,7 +1826,12 @@ ToolRegistry.register({ const sawPending = pending() const text = createMemo(() => { const cmd = props.input.command ?? props.metadata.command ?? "" - const out = stripAnsi(props.output || props.metadata.output || "") + let out = props.output || props.metadata.output || "" + out = out + .replace(/[\s\S]*?(?:<\/bash_metadata>|$)/g, "") + .replace(/[\s\S]*?(?:<\/metadata>|$)/g, "") + .trim() + out = stripAnsi(out) return `$ ${cmd}${out ? "\n\n" + out : ""}` }) const [copied, setCopied] = createSignal(false) diff --git a/packages/web/src/content/docs/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 4391514b4304..c01bc6ef3c86 100644 --- a/packages/web/src/content/docs/ar/permissions.mdx +++ b/packages/web/src/content/docs/ar/permissions.mdx @@ -135,6 +135,7 @@ description: تحكّم في الإجراءات التي تتطلب موافقة - `grep` — البحث في المحتوى (يطابق نمط regex) - `list` — سرد الملفات في دليل (يطابق مسار الدليل) - `bash` — تشغيل أوامر shell (يطابق الأوامر المُحلَّلة مثل `git status --porcelain`) +- `bash:unsandboxed` — إعادة تشغيل أمر shell خارج البيئة المعزولة بعد الرفض أو بعد طلب صريح لتشغيله بدون عزل - `task` — تشغيل وكلاء فرعيين (يطابق نوع الوكيل الفرعي) - `skill` — تحميل مهارة (يطابق اسم المهارة) - `lsp` — تشغيل استعلامات LSP (حاليًا دون قواعد دقيقة) @@ -234,3 +235,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 d820778b4045..16128fa7d6c5 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 b6a194ad2804..6eda0d906550 100644 --- a/packages/web/src/content/docs/bs/permissions.mdx +++ b/packages/web/src/content/docs/bs/permissions.mdx @@ -130,6 +130,7 @@ Dozvole OpenCode su označene imenom alata, plus nekoliko sigurnosnih mjera: - `grep` — pretraga sadržaja (podudara se sa regularnim izrazom) - `list` — lista fajlova u direktorijumu (podudara se sa putanjom direktorijuma) - `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,3 +228,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 d0ae9a4460a3..2f4eb0ba92dc 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/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/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 72ebff606c58..091594b71910 100644 --- a/packages/web/src/content/docs/da/permissions.mdx +++ b/packages/web/src/content/docs/da/permissions.mdx @@ -135,6 +135,7 @@ OpenCode tilladelser indtastes efter værktøjsnavn plus et par sikkerhedsafskæ - `grep` — indholdssøgning (matcher regex-mønsteret) - `list` — viser filer i en mappe (matcher mappestien) - `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,3 +235,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 a610e8cc3922..746802aebc4c 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 ba7c80204067..cd6004276c05 100644 --- a/packages/web/src/content/docs/de/permissions.mdx +++ b/packages/web/src/content/docs/de/permissions.mdx @@ -135,6 +135,7 @@ OpenCode-Berechtigungen basieren auf Tool-Namen sowie einigen Sicherheitsvorkehr - `grep` – Inhaltssuche (entspricht dem Regex-Muster) - `list` – Auflistung der Dateien in einem Verzeichnis (entspricht dem Verzeichnispfad) - `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,3 +235,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 b33163df8524..dd0aa68aad62 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 603b3bdb3f5d..bd85501baac4 100644 --- a/packages/web/src/content/docs/es/permissions.mdx +++ b/packages/web/src/content/docs/es/permissions.mdx @@ -135,6 +135,7 @@ Los permisos OpenCode están codificados por el nombre de la herramienta, ademá - `grep` — búsqueda de contenido (coincide con el patrón de expresiones regulares) - `list` — enumerar archivos en un directorio (coincide con la ruta del directorio) - `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,3 +235,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 f3a050c03bc6..9c68b83a7a23 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 176fa34ad258..66924e7fcdbe 100644 --- a/packages/web/src/content/docs/fr/permissions.mdx +++ b/packages/web/src/content/docs/fr/permissions.mdx @@ -135,6 +135,7 @@ Les autorisations OpenCode sont classées par nom d'outil, plus quelques garde-f - `grep` — recherche de contenu (correspond au modèle regex) - `list` — listant les fichiers dans un répertoire (correspond au chemin du répertoire) - `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,3 +235,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 62579c2bf828..141e7f5e2e46 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 3f255c89ddac..978e27201813 100644 --- a/packages/web/src/content/docs/it/permissions.mdx +++ b/packages/web/src/content/docs/it/permissions.mdx @@ -135,6 +135,7 @@ I permessi di OpenCode sono indicizzati per nome dello strumento, piu' un paio d - `grep` — ricerca nel contenuto (corrisponde al pattern regex) - `list` — elenco file in una directory (corrisponde al path della directory) - `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,3 +235,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 50609fd616f9..bc206db296bb 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 5f5df6675c0d..77b2e3a459cd 100644 --- a/packages/web/src/content/docs/ja/permissions.mdx +++ b/packages/web/src/content/docs/ja/permissions.mdx @@ -135,6 +135,7 @@ OpenCode の権限は、ツール名に加えて、いくつかの安全対策 - `grep` — コンテンツ検索 (正規表現パターンと一致) - `list` — ディレクトリ内のファイルのリスト (ディレクトリパスと一致) - `bash` — シェルコマンドの実行 (`git status --porcelain` などの解析されたコマンドと一致します) +- `bash:unsandboxed` — サンドボックス拒否後、または明示的なサンドボックス外リクエスト後にサンドボックス外でシェルコマンドを再実行します - `task` — サブエージェントの起動 (サブエージェントのタイプと一致) - `skill` — スキルをロードしています(スキル名と一致します) - `lsp` — LSP クエリの実行 (現在は非細分性) @@ -281,3 +282,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 0e0f8fe951f2..fd8c49eaf8a9 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 ec129f45c08b..8c32708ae350 100644 --- a/packages/web/src/content/docs/ko/permissions.mdx +++ b/packages/web/src/content/docs/ko/permissions.mdx @@ -135,6 +135,7 @@ opencode 권한은 도구 이름에 의해 키 입력되며, 두 개의 안전 - `grep` - 콘텐츠 검색 ( regex 패턴 매칭) - `list` - 디렉토리의 목록 파일 (폴더 경로 매칭) - `bash` - shell 명령 실행 (`git status --porcelain`와 같은 팟 명령) +- `bash:unsandboxed` — 샌드박스 거부 후 또는 명시적 비샌드박스 요청 후 셸 명령을 샌드박스 밖에서 다시 실행 - `task` - 에이전트 실행 (작업 에이전트 유형) - `skill` - 기술을 로딩 (기술 이름을 매칭) - `lsp` - LSP 쿼리 실행 (현재 비 과립) @@ -234,3 +235,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 33976b66ff18..ddec2d416d18 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 6437555a2fab..c5d03e3223f4 100644 --- a/packages/web/src/content/docs/nb/permissions.mdx +++ b/packages/web/src/content/docs/nb/permissions.mdx @@ -135,6 +135,7 @@ OpenCode-tillatelser tastes inn etter verktøynavn, pluss et par sikkerhetsvakte - `grep` — innholdssøk (samsvarer med regex-mønsteret) - `list` — viser filer i en katalog (tilsvarer katalogbanen) - `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,3 +235,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 be80a0e2ba4e..1ee9c93bf646 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/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index a470fddd76a5..0d3e8cfdd4f8 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -135,6 +135,7 @@ OpenCode permissions are keyed by tool name, plus a couple of safety guards: - `grep` — content search (matches the regex pattern) - `list` — listing files in a directory (matches the directory path) - `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) @@ -235,3 +236,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/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 6a7840ac72e8..2db33dc1878f 100644 --- a/packages/web/src/content/docs/pl/permissions.mdx +++ b/packages/web/src/content/docs/pl/permissions.mdx @@ -135,6 +135,7 @@ Uprawnienia opencode są określane na podstawie nazwy narzędzia i kilku zabezp - `grep` — wyszukiwanie treści (pasuje do wzorca regularnego) - `list` — wyświetlanie listy plików w katalogu (pasuje do katalogu) - `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,3 +235,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 649c744e044d..da89ec32582f 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 c3850c00ca5a..41ee8afe5404 100644 --- a/packages/web/src/content/docs/pt-br/permissions.mdx +++ b/packages/web/src/content/docs/pt-br/permissions.mdx @@ -135,6 +135,7 @@ As permissões do opencode são indexadas pelo nome da ferramenta, além de algu - `grep` — busca de conteúdo (corresponde ao padrão regex) - `list` — listagem de arquivos em um diretório (corresponde ao caminho do diretório) - `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,3 +235,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 d762fdf14542..eb6f4dd8a50a 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 70f3a804a22f..a3c6d3d7dcc5 100644 --- a/packages/web/src/content/docs/ru/permissions.mdx +++ b/packages/web/src/content/docs/ru/permissions.mdx @@ -135,6 +135,7 @@ opencode использует конфигурацию `permission`, чтобы - `grep` — поиск по контенту (соответствует шаблону регулярного выражения) - `list` — список файлов в каталоге (соответствует пути к каталогу) - `bash` — запуск shell-команд (соответствует проанализированным командам, например `git status --porcelain`) +- `bash:unsandboxed` — повторный запуск shell-команды вне песочницы после отказа или явного запроса без песочницы - `task` — запуск субагентов (соответствует типу субагента) - `skill` — загрузка навыка (соответствует названию навыка) - `lsp` — выполнение запросов LSP (в настоящее время не детализированных) @@ -234,3 +235,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 def6663fc16c..16daf71eb17a 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 adf381dee39a..c796810bd7d8 100644 --- a/packages/web/src/content/docs/th/permissions.mdx +++ b/packages/web/src/content/docs/th/permissions.mdx @@ -135,6 +135,7 @@ OpenCode ใช้การกำหนดค่า `permission` เพื่อ - `grep` — การค้นหาเนื้อหา (ตรงกับรูปแบบ regex) - `list` — แสดงรายการไฟล์ในไดเร็กทอรี (ตรงกับเส้นทางไดเร็กทอรี) - `bash` — การรันคำสั่ง shell (ตรงกับคำสั่งที่แยกวิเคราะห์เช่น `git status --porcelain`) +- `bash:unsandboxed` — รันคำสั่ง shell ซ้ำนอก sandbox หลังจากถูกปฏิเสธหรือหลังจากมีคำขอ unsandboxed อย่างชัดเจน - `task` — การเปิดตัวตัวแทนย่อย (ตรงกับประเภทตัวแทนย่อย) - `skill` — กำลังโหลดทักษะ (ตรงกับชื่อทักษะ) - `lsp` — กำลังเรียกใช้คำสั่ง LSP (ปัจจุบันยังไม่ละเอียด) @@ -234,3 +235,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 17dbd9fdb344..08d8ef239e49 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/tools.mdx b/packages/web/src/content/docs/tools.mdx index abd486aeb6f3..bd56fb7e8583 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 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 f608ce7e0dce..8396bae86812 100644 --- a/packages/web/src/content/docs/tr/permissions.mdx +++ b/packages/web/src/content/docs/tr/permissions.mdx @@ -135,6 +135,7 @@ opencode izinleri araç adına ve birkaç güvenlik önlemine göre anahtarlanı - `grep` — içerik arama (regex modeliyle eşleşir) - `list` — bir dizideki dosyaları listeleme (dizin yoluyla 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,3 +235,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 e65ffec3a22d..25ee62c170b7 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 24104e2a2636..deb245280001 100644 --- a/packages/web/src/content/docs/zh-cn/permissions.mdx +++ b/packages/web/src/content/docs/zh-cn/permissions.mdx @@ -135,6 +135,7 @@ OpenCode 的权限以工具名称为键,外加几个安全防护项: - `grep` — 内容搜索(匹配正则表达式模式) - `list` — 列出目录中的文件(匹配目录路径) - `bash` — 运行 shell 命令(匹配解析后的命令,如 `git status --porcelain`) +- `bash:unsandboxed` — 在沙箱拒绝后或在明确的非沙箱请求后,在沙箱外重新运行 shell 命令 - `task` — 启动子代理(匹配子代理类型) - `skill` — 加载技能(匹配技能名称) - `lsp` — 运行 LSP 查询(当前不支持细粒度配置) @@ -234,3 +235,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 4f68a9cf3510..3bec0b9ab7db 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 05b522e9c76d..77f14855ba45 100644 --- a/packages/web/src/content/docs/zh-tw/permissions.mdx +++ b/packages/web/src/content/docs/zh-tw/permissions.mdx @@ -135,6 +135,7 @@ OpenCode 的權限以工具名稱為鍵,外加幾個安全防護項: - `grep` — 內容搜尋(比對正規表示式模式) - `list` — 列出目錄中的檔案(比對目錄路徑) - `bash` — 執行 shell 指令(比對解析後的指令,如 `git status --porcelain`) +- `bash:unsandboxed` — 在沙箱拒絕後或在明確的非沙箱請求後,在沙箱外重新執行 shell 指令 - `task` — 啟動子代理(比對子代理類型) - `skill` — 載入技能(比對技能名稱) - `lsp` — 執行 LSP 查詢(目前不支援細粒度設定) @@ -234,3 +235,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 80e27ea0cc57..de9fa6893acf 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 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/**"]