From fe7eff7e27d8819536d1d933bbe6f14189bf197c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 8 Jul 2026 20:48:27 +0200 Subject: [PATCH] feat: promote sandbox configuration --- .changeset/sandbox-settings-page.md | 4 +- .../getting-started/settings/sandboxing.md | 20 ++++--- .../kilo-vscode/src/shared/sandbox-session.ts | 2 +- .../tests/settings-accessibility.spec.ts | 6 ++ .../unit/new-worktree-dialog-sandbox.test.ts | 2 +- .../prompt-input-connection-guard.test.ts | 6 +- .../tests/unit/sandboxing-settings.test.ts | 6 ++ .../agent-manager/NewWorktreeDialog.tsx | 2 +- .../src/components/chat/PromptInput.tsx | 2 +- .../src/components/settings/SandboxingTab.tsx | 47 +++++++++------- .../src/components/settings/settings-io.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ar.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/br.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/bs.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/da.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/de.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/en.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/es.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/fr.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/it.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/ja.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/ko.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/nl.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/no.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/pl.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/ru.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/th.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/tr.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/uk.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/zh.ts | 4 +- .../kilo-vscode/webview-ui/src/i18n/zht.ts | 4 +- .../src/stories/settings.stories.tsx | 2 +- .../webview-ui/src/types/messages/config.ts | 10 +++- packages/opencode/src/config/config.ts | 19 +------ .../opencode/src/kilocode/plugins/sandbox.tsx | 2 +- .../opencode/src/kilocode/sandbox/config.ts | 40 +++++++++++++ .../opencode/src/kilocode/sandbox/policy.ts | 14 +++-- packages/opencode/src/tool/task.ts | 3 +- .../test/kilocode/config/config.test.ts | 42 ++++++++++++-- .../kilocode/sandbox/config-network.test.ts | 5 +- .../test/kilocode/sandbox/sdk-config.test.ts | 7 +-- .../kilocode/sandbox/session-tools.test.ts | 2 +- .../test/kilocode/sandbox/session.test.ts | 6 +- .../kilocode/sandbox/shell-network.test.ts | 5 +- .../test/kilocode/sandbox/state.test.ts | 56 +++++++++++++------ .../test/kilocode/sandbox/tui.test.ts | 2 +- .../test/kilocode/task-nesting.test.ts | 2 +- packages/sdk/js/script/build.ts | 31 ++++++++++ packages/sdk/js/src/gen/types.gen.ts | 25 ++++++--- packages/sdk/js/src/v2/gen/types.gen.ts | 20 ++++++- packages/sdk/openapi.json | 35 ++++++++---- 51 files changed, 333 insertions(+), 173 deletions(-) create mode 100644 packages/opencode/src/kilocode/sandbox/config.ts diff --git a/.changeset/sandbox-settings-page.md b/.changeset/sandbox-settings-page.md index 9d241df655..0ff82ebf30 100644 --- a/.changeset/sandbox-settings-page.md +++ b/.changeset/sandbox-settings-page.md @@ -1,5 +1,7 @@ --- "kilo-code": patch +"@kilocode/cli": minor +"@kilocode/sdk": minor --- -Show sandbox controls in the dedicated Sandboxing settings page for all supported macOS and Linux users while keeping sandboxing disabled by default. +Configure sandboxing through first-class sandbox settings, and show its controls in the dedicated Sandboxing page for all supported macOS and Linux users while keeping it disabled by default. diff --git a/packages/kilo-docs/pages/getting-started/settings/sandboxing.md b/packages/kilo-docs/pages/getting-started/settings/sandboxing.md index ebfd412ef7..a839755c42 100644 --- a/packages/kilo-docs/pages/getting-started/settings/sandboxing.md +++ b/packages/kilo-docs/pages/getting-started/settings/sandboxing.md @@ -10,7 +10,7 @@ The sandbox adds an operating-system boundary around agent tools. It limits wher The sandbox is **disabled by default**. It does not restrict filesystem reads. An agent can still read any file that your user account can read, but it can write only to explicitly allowed locations. {% callout type="warning" %} -Sandboxing is experimental and is not available on Windows. If the macOS or Linux sandbox backend is unavailable, Kilo reports the reason and runs tools without sandbox confinement. The sandbox does not fail closed. +Sandboxing is not available on Windows. If the macOS or Linux sandbox backend is unavailable, Kilo reports the reason and runs tools without sandbox confinement. The sandbox does not fail closed. {% /callout %} ## Enable the sandbox @@ -29,19 +29,21 @@ You can also configure the default in the global `kilo.jsonc` file: ```json { - "experimental": { - "sandbox": true, - "sandbox_restrict_network": true, - "sandbox_writable_paths": ["~/shared-output"] + "sandbox": { + "enabled": true, + "network": "deny", + "writable_paths": ["~/shared-output"] } } ``` | Key | Default | Effect | |---|---|---| -| `experimental.sandbox` | `false` | Use sandbox confinement by default for new sessions. | -| `experimental.sandbox_restrict_network` | `true` | Block outbound network access while filesystem confinement is active. Set this to `false` to allow network access without removing filesystem write restrictions. | -| `experimental.sandbox_writable_paths` | `[]` | Add writable files or directories outside the built-in writable locations. For security, only the global config can set these paths. | +| `sandbox.enabled` | `false` | Use sandbox confinement by default for new sessions. | +| `sandbox.network` | `"deny"` | Control outbound network access while filesystem confinement is active. Set this to `"allow"` to permit network access without removing filesystem write restrictions. | +| `sandbox.writable_paths` | `[]` | Add writable files or directories outside the built-in writable locations. Only global config may set these paths. | + +Project config may tighten sandbox policy by setting `enabled` to `true` or `network` to `"deny"`. It cannot disable a globally enabled sandbox, allow network denied by global config, or add writable paths. This prevents repository-controlled configuration from weakening the user's security boundary. ## When to use sandboxing @@ -97,7 +99,7 @@ Writes are allowed in: - The active project or worktree - Kilo's data, cache, config, state, temporary, binary, log, and repository directories -- Paths listed in `experimental.sandbox_writable_paths` +- Paths listed in `sandbox.writable_paths` Writes are denied everywhere else. The following rules still apply inside writable locations: diff --git a/packages/kilo-vscode/src/shared/sandbox-session.ts b/packages/kilo-vscode/src/shared/sandbox-session.ts index 4b689d3253..1940427e65 100644 --- a/packages/kilo-vscode/src/shared/sandbox-session.ts +++ b/packages/kilo-vscode/src/shared/sandbox-session.ts @@ -18,7 +18,7 @@ export async function sandboxDefault(preference: SandboxPreference | undefined, const explicit = preference?.explicit() if (explicit !== undefined) return explicit const { data } = await client.config.get({ directory }, { throwOnError: true }) - return data.experimental?.sandbox === true + return data.sandbox?.enabled === true } export async function sandboxSessionMetadata( diff --git a/packages/kilo-vscode/tests/settings-accessibility.spec.ts b/packages/kilo-vscode/tests/settings-accessibility.spec.ts index 62c8176aed..75bd0343b0 100644 --- a/packages/kilo-vscode/tests/settings-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/settings-accessibility.spec.ts @@ -70,8 +70,14 @@ test.describe("settings tab accessibility", () => { const network = page.getByRole("switch", { name: "Restrict Network Access" }) await expect(network).toHaveAccessibleDescription(/Local MCP servers and plugin hooks run outside this restriction/) await expect(network).toBeChecked() + await expect(network).toBeDisabled() + const path = page.getByRole("textbox", { name: "Additional Writable Paths" }) + await expect(path).toBeDisabled() + await expect(page.getByRole("button", { name: "Add" })).toBeDisabled() await page.locator('[data-slot="switch-control"]').nth(0).click() await expect(sandbox).toBeChecked() + await expect(network).toBeEnabled() + await expect(path).toBeEnabled() await page.locator('[data-slot="switch-control"]').nth(1).click() await expect(network).not.toBeChecked() await expect(page.locator(".settings-save-bar")).toBeVisible() diff --git a/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts b/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts index 83dd1ef91f..30ecca6c7d 100644 --- a/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts +++ b/packages/kilo-vscode/tests/unit/new-worktree-dialog-sandbox.test.ts @@ -20,7 +20,7 @@ describe("NewWorktreeDialog sandbox toggle", () => { expect(src).toContain("sandbox: sandboxVisible() ? sandboxOverride() : undefined") expect(src).toContain("const sandboxVisible = () => features().sandboxControls") expect(provider).toContain("await this.fetchAndSendSandboxDefault(message.contextDirectory, message.requestID)") - expect(src).not.toContain("createSignal(config().experimental?.sandbox === true)") + expect(src).not.toContain("createSignal(config().sandbox?.enabled === true)") expect(src).not.toContain("visible as isSandboxVisible") }) }) diff --git a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts index 8141a6c54c..c19fcf9621 100644 --- a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts @@ -85,7 +85,7 @@ describe("PromptInput sandbox toggle", () => { expect(src).toContain( 'const sandboxVisible = () => features().sandboxControls && !session.currentSessionID()?.startsWith("cloud:")', ) - expect(src).not.toContain("config().experimental?.sandbox === true") + expect(src).not.toContain("config().sandbox?.enabled === true") expect(src).toContain("") expect(src).toContain("{ action: toggleSandbox, enabled: () => sandboxVisible() && !sandboxDisabled() }") expect(src).toContain('if (!sandboxVisible()) hidden.add("sandbox")') @@ -121,9 +121,7 @@ describe("PromptInput sandbox toggle", () => { }) it("explains filesystem and network state without changing the lock icon", () => { - expect(src).toContain( - "const sandboxNetworkEnabled = () => config().experimental?.sandbox_restrict_network !== false", - ) + expect(src).toContain('const sandboxNetworkEnabled = () => config().sandbox?.network !== "allow"') expect(src).toContain("") expect(src).toContain('tooltipClass="prompt-sandbox-tooltip-content"') expect(button).toContain('') diff --git a/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts b/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts index a8d9af0b9e..153a3570c8 100644 --- a/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts +++ b/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts @@ -19,6 +19,12 @@ describe("Sandboxing settings visibility", () => { expect(visible({ ...features, sandboxControls: true })).toBe(true) }) + test("edits global sandbox config without promoting project policy", async () => { + const src = await Bun.file("webview-ui/src/components/settings/SandboxingTab.tsx").text() + expect(src).toContain("const { globalConfig, updateGlobalConfig } = useConfig()") + expect(src).not.toContain("const { config, updateConfig } = useConfig()") + }) + test("shows sandbox controls outside Windows", () => { setPlatform("darwin") expect(configFeatures().sandboxControls).toBe(true) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index 52639c60a3..5e54247108 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -526,7 +526,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran tooltip={ } tooltipClass="prompt-sandbox-tooltip-content" diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 1593dadf0c..83fea9348f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -183,7 +183,7 @@ export const PromptInput: Component = (props) => { const sandboxAvailable = () => (sandboxID() ? sandbox()?.available : sandboxDefault()?.available) ?? false const sandboxReason = () => (sandboxID() ? sandbox()?.reason : sandboxDefault()?.reason) const sandboxReady = () => (sandboxID() ? sandbox() !== undefined : sandboxDefault() !== undefined) - const sandboxNetworkEnabled = () => config().experimental?.sandbox_restrict_network !== false + const sandboxNetworkEnabled = () => config().sandbox?.network !== "allow" const sandboxRequest = (sessionID?: string) => sandboxRequests()[sessionID ?? ""] const sandboxDisabled = () => !server.isConnected() || !sandboxReady() || !sandboxAvailable() || sandboxRequest(sandboxID()) !== undefined diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx index 7c1497f3ae..36b11756b4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx @@ -13,12 +13,12 @@ const networkDescription = "sandbox-network-description" const writablePathsDescription = "sandbox-writable-paths-description" const SandboxingTab: Component = () => { - const { config, updateConfig } = useConfig() + const { globalConfig, updateGlobalConfig } = useConfig() const language = useLanguage() - const experimental = createMemo(() => config().experimental ?? {}) + const sandbox = createMemo(() => globalConfig().sandbox ?? {}) const [newPath, setNewPath] = createSignal("") - const writablePaths = () => experimental().sandbox_writable_paths ?? [] + const writablePaths = () => sandbox().writable_paths ?? [] const addPath = () => { const value = newPath().trim() @@ -26,8 +26,8 @@ const SandboxingTab: Component = () => { const current = [...writablePaths()] if (!current.includes(value)) { current.push(value) - updateConfig({ - experimental: { ...experimental(), sandbox_writable_paths: current }, + updateGlobalConfig({ + sandbox: { ...sandbox(), writable_paths: current }, }) } setNewPath("") @@ -36,29 +36,29 @@ const SandboxingTab: Component = () => { const removePath = (index: number) => { const current = [...writablePaths()] current.splice(index, 1) - updateConfig({ - experimental: { ...experimental(), sandbox_writable_paths: current }, + updateGlobalConfig({ + sandbox: { ...sandbox(), writable_paths: current }, }) } return ( - updateConfig({ - experimental: { ...experimental(), sandbox: checked }, + updateGlobalConfig({ + sandbox: { ...sandbox(), enabled: checked }, }) } hideLabel > - {language.t("settings.experimental.sandbox.title")} + {language.t("settings.sandboxing.enabled.title")} @@ -68,14 +68,12 @@ const SandboxingTab: Component = () => { descriptionId={networkDescription} > - updateConfig({ - experimental: { - ...experimental(), - sandbox_restrict_network: checked, - }, + updateGlobalConfig({ + sandbox: { ...sandbox(), network: checked ? "deny" : "allow" }, }) } hideLabel @@ -105,6 +103,7 @@ const SandboxingTab: Component = () => {
setNewPath(val)} onKeyDown={(e: KeyboardEvent) => { @@ -114,7 +113,7 @@ const SandboxingTab: Component = () => { label={language.t("settings.sandboxing.writablePaths.title")} />
- @@ -138,7 +137,13 @@ const SandboxingTab: Component = () => { > {path} - removePath(index())} /> + removePath(index())} + /> )} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts b/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts index 4bd6834634..d4b1949d2a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts @@ -38,6 +38,7 @@ export const KNOWN_KEYS: ReadonlyArray = [ "terminal_command_display", "code_edit_display", "hide_prompt_training_models", + "sandbox", "indexing", "experimental", ] diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 4c6ae3049b..2d79f5f7e3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1561,8 +1561,8 @@ export const dict = { "settings.agentBehaviour.workflows.empty": "لم يتم تهيئة أوامر مخصصة. أضف أوامر إلى opencode.json لرؤيتها هنا.", "settings.agentBehaviour.workflows.detail.description": "الوصف", "settings.agentBehaviour.workflows.detail.template": "القالب", - "settings.experimental.sandbox.title": "Sandbox", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": "تشغيل أوامر shell الخاصة بالوكيل داخل sandbox على مستوى نظام التشغيل يقيّد الكتابة على مجلدات حالة المشروع و Kilo", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index c5e1a4f97d..d7b07e2d8f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1603,8 +1603,8 @@ export const dict = { "Nenhum comando personalizado configurado. Adicione comandos ao opencode.json para vê-los aqui.", "settings.agentBehaviour.workflows.detail.description": "Descrição", "settings.agentBehaviour.workflows.detail.template": "Modelo", - "settings.experimental.sandbox.title": "Sandbox", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": "Executar os comandos shell do agente dentro de um sandbox a nível de sistema operacional que restringe escritas aos diretórios de estado do projeto e do Kilo", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 15a9ac1ae1..e607fd977d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1595,8 +1595,8 @@ export const dict = { "Nema konfiguriranih prilagođenih komandi. Dodajte komande u opencode.json da ih vidite ovdje.", "settings.agentBehaviour.workflows.detail.description": "Opis", "settings.agentBehaviour.workflows.detail.template": "Predložak", - "settings.experimental.sandbox.title": "Sandbox", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": "Pokrenite shell komande agenta unutar sandboxa na nivou operativnog sistema koji ograničava pisanje na direktorije stanja projekta i Kilo", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index d2e353921d..191582c2b5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1588,8 +1588,8 @@ export const dict = { "Ingen brugerdefinerede kommandoer konfigureret. Tilføj kommandoer til opencode.json for at se dem her.", "settings.agentBehaviour.workflows.detail.description": "Beskrivelse", "settings.agentBehaviour.workflows.detail.template": "Skabelon", - "settings.experimental.sandbox.title": "Sandbox", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": "Kør shell-kommandoer for agenten i en sandbox på operativsystemniveau, der begrænser skrivning til projekt- og Kilo-tilstandsmapperne", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index b01aec173d..e178d46945 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1622,8 +1622,8 @@ export const dict = { "Keine benutzerdefinierten Befehle konfiguriert. Fügen Sie Befehle zu opencode.json hinzu, um sie hier zu sehen.", "settings.agentBehaviour.workflows.detail.description": "Beschreibung", "settings.agentBehaviour.workflows.detail.template": "Vorlage", - "settings.experimental.sandbox.title": "Sandbox", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": "Shell-Befehle des Agenten in einer Sandbox auf Betriebssystemebene ausführen, die Schreibvorgänge auf die Projekt- und Kilo-Statusverzeichnisse beschränkt", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 6e548a2dd1..244e11254d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1425,8 +1425,8 @@ export const dict = { "Enable experimental tools for reading, editing, and executing VS Code notebooks", "settings.experimental.continueOnDeny.title": "Continue on Deny", "settings.experimental.continueOnDeny.description": "Continue the agent loop when a permission is denied", - "settings.experimental.sandbox.title": "Sandbox", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": "Run agent shell commands inside an OS-level sandbox that restricts writes to the project and Kilo state directories", "settings.sandboxing.title": "Sandboxing", "settings.sandboxing.network.title": "Restrict Network Access", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 77b2bb5d6f..67a66ba5ef 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1611,8 +1611,8 @@ export const dict = { "No hay comandos personalizados configurados. Añada comandos a opencode.json para verlos aquí.", "settings.agentBehaviour.workflows.detail.description": "Descripción", "settings.agentBehaviour.workflows.detail.template": "Plantilla", - "settings.experimental.sandbox.title": "Sandbox", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": "Ejecutar los comandos de shell del agente dentro de un sandbox a nivel de sistema operativo que restringe las escrituras a los directorios de estado del proyecto y de Kilo", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 5c139cc2ec..047a49d186 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1628,8 +1628,8 @@ export const dict = { "Aucune commande personnalisée configurée. Ajoutez des commandes à opencode.json pour les voir ici.", "settings.agentBehaviour.workflows.detail.description": "Description", "settings.agentBehaviour.workflows.detail.template": "Modèle", - "settings.experimental.sandbox.title": "Sandbox", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": "Exécuter les commandes shell de l'agent dans un sandbox au niveau du système d'exploitation qui restreint les écritures aux répertoires d'état du projet et de Kilo", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 5b85cd2233..c4b9bca799 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -1306,8 +1306,8 @@ export const dict = { "Fai clic per limitare le scritture nel file system e l'accesso alla rete.", "prompt.action.sandbox.description.disabledNetworkAllowed": "Fai clic per limitare le scritture nel file system. L'accesso alla rete resta consentito dalle impostazioni della sandbox.", - "settings.experimental.sandbox.title": "Sandbox", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": "Esegui i comandi shell dell'agente all'interno di un sandbox a livello di sistema operativo che limita le scritture alle directory di stato del progetto e di Kilo", "settings.agentBehaviour.skillPaths": "Percorsi cartelle skill", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index ba88ae1233..3b25253a14 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1585,8 +1585,8 @@ export const dict = { "カスタムコマンドが設定されていません。opencode.json にコマンドを追加するとここに表示されます。", "settings.agentBehaviour.workflows.detail.description": "説明", "settings.agentBehaviour.workflows.detail.template": "テンプレート", - "settings.experimental.sandbox.title": "サンドボックス", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "サンドボックス", + "settings.sandboxing.enabled.description": "エージェントのシェルコマンドを、プロジェクトおよびKiloの状態ディレクトリへの書き込みを制限するOSレベルのサンドボックス内で実行", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 99816103ac..54f4a299da 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1573,8 +1573,8 @@ export const dict = { "구성된 사용자 정의 명령이 없습니다. opencode.json에 명령을 추가하면 여기에 표시됩니다.", "settings.agentBehaviour.workflows.detail.description": "설명", "settings.agentBehaviour.workflows.detail.template": "템플릿", - "settings.experimental.sandbox.title": "샌드박스", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "샌드박스", + "settings.sandboxing.enabled.description": "에이전트 셸 명령을 프로젝트 및 Kilo 상태 디렉터리에 대한 쓰기를 제한하는 OS 수준의 샌드박스 내에서 실행", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index e87cc634d7..c109c03e2c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1472,8 +1472,8 @@ export const dict = { "settings.experimental.remote.inactive": "Inactief", "settings.experimental.remote.hint": "Gebruik /remote in de chat om te schakelen", "settings.experimental.toolToggles": "Tool Schakelaars", - "settings.experimental.sandbox.title": "Sandbox", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": "Shell-opdrachten van de agent uitvoeren in een sandbox op besturingssysteemniveau die schrijfbewerkingen beperkt tot de project- en Kilo-statusmappen", "settings.agentBehaviour.defaultAgent.title": "Standaard Agent", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 69d22a69a1..107eb79c25 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1588,8 +1588,8 @@ export const dict = { "Ingen egendefinerte kommandoer konfigurert. Legg til kommandoer i opencode.json for å se dem her.", "settings.agentBehaviour.workflows.detail.description": "Beskrivelse", "settings.agentBehaviour.workflows.detail.template": "Mal", - "settings.experimental.sandbox.title": "Sandbox", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": "Kjør shell-kommandoer for agenten i en sandbox på operativsystemnivå som begrenser skriving til prosjekt- og Kilo-tilstandsmapper", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 082af4a28a..be13a942f8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1592,8 +1592,8 @@ export const dict = { "Brak skonfigurowanych niestandardowych komend. Dodaj komendy do opencode.json, aby je tu zobaczyć.", "settings.agentBehaviour.workflows.detail.description": "Opis", "settings.agentBehaviour.workflows.detail.template": "Szablon", - "settings.experimental.sandbox.title": "Sandbox", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": "Uruchamiaj polecenia shell agenta w sandboxie na poziomie systemu operacyjnego, który ogranicza zapisy do katalogów stanu projektu i Kilo", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 2d5db91d2a..f2cf6903a4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1593,8 +1593,8 @@ export const dict = { "Пользовательские команды не настроены. Добавьте команды в opencode.json, чтобы увидеть их здесь.", "settings.agentBehaviour.workflows.detail.description": "Описание", "settings.agentBehaviour.workflows.detail.template": "Шаблон", - "settings.experimental.sandbox.title": "Песочница", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Песочница", + "settings.sandboxing.enabled.description": "Выполнять команды оболочки агента в песочнице на уровне ОС, которая ограничивает запись в каталоги состояния проекта и Kilo", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 32081195f3..158701ad41 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1570,8 +1570,8 @@ export const dict = { "ไม่มีคำสั่งแบบกำหนดเองที่กำหนดค่าไว้ เพิ่มคำสั่งใน opencode.json เพื่อดูที่นี่", "settings.agentBehaviour.workflows.detail.description": "คำอธิบาย", "settings.agentBehaviour.workflows.detail.template": "เทมเพลต", - "settings.experimental.sandbox.title": "Sandbox", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": "เรียกใช้คำสั่ง shell ของ agent ใน sandbox ระดับระบบปฏิบัติการที่จำกัดการเขียนไปยังโฟลเดอร์สถานะของโปรเจ็กต์และ Kilo", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 18bf4e0177..ca661c228f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1462,8 +1462,8 @@ export const dict = { "settings.experimental.remote.inactive": "Pasif", "settings.experimental.remote.hint": "Geçiş yapmak için sohbette /remote kullanın", "settings.experimental.toolToggles": "Araç Açma/Kapatma", - "settings.experimental.sandbox.title": "Sandbox", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Sandbox", + "settings.sandboxing.enabled.description": "Agent shell komutlarını, proje ve Kilo durum dizinlerine yazmaları kısıtlanan işletim sistemi düzeyinde bir sandbox içinde çalıştırın", "settings.agentBehaviour.defaultAgent.title": "Varsayılan Ajan", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 663cd97242..2042fd8a69 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1460,8 +1460,8 @@ export const dict = { "settings.experimental.remote.inactive": "Неактивний", "settings.experimental.remote.hint": "Використовуйте /remote у чаті для перемикання", "settings.experimental.toolToggles": "Перемикачі інструментів", - "settings.experimental.sandbox.title": "Пісочниця", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "Пісочниця", + "settings.sandboxing.enabled.description": "Виконувати команди оболонки агента в пісочниці на рівні ОС, яка обмежує запис до каталогів стану проєкту та Kilo", "settings.agentBehaviour.defaultAgent.title": "Агент за замовчуванням", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 944905e0fd..36cc23affe 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1533,8 +1533,8 @@ export const dict = { "settings.agentBehaviour.workflows.empty": "未配置自定义命令。将命令添加到 opencode.json 即可在此处看到。", "settings.agentBehaviour.workflows.detail.description": "描述", "settings.agentBehaviour.workflows.detail.template": "模板", - "settings.experimental.sandbox.title": "沙盒", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "沙盒", + "settings.sandboxing.enabled.description": "在操作系统级沙盒中运行代理 shell 命令,将写入限制在项目和 Kilo 状态目录内", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 3e4df69b7e..c8365f0bd1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1499,8 +1499,8 @@ export const dict = { "settings.agentBehaviour.workflows.empty": "未設定自訂命令。將命令新增至 opencode.json 即可在此處看到。", "settings.agentBehaviour.workflows.detail.description": "描述", "settings.agentBehaviour.workflows.detail.template": "範本", - "settings.experimental.sandbox.title": "沙盒", - "settings.experimental.sandbox.description": + "settings.sandboxing.enabled.title": "沙盒", + "settings.sandboxing.enabled.description": "在作業系統層級沙盒中執行代理 shell 指令,將寫入限制在專案和 Kilo 狀態目錄內", "settings.autoApprove.description": diff --git a/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx index 190347c9b4..3f9bfb7592 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx @@ -54,7 +54,7 @@ export const SettingsPanel: Story = { export const SandboxingPanel: Story = { name: "Settings — sandboxing controls", render: () => ( - +
diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index 73c4116606..6e3b980137 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -48,13 +48,16 @@ export interface ExperimentalConfig { primary_tools?: string[] continue_loop_on_deny?: boolean mcp_timeout?: number - sandbox?: boolean - sandbox_restrict_network?: boolean - sandbox_writable_paths?: string[] swe_pruner?: boolean swe_pruner_model?: string } +export interface SandboxConfig { + enabled?: boolean + network?: "allow" | "deny" + writable_paths?: string[] +} + export interface CommitMessageConfig { prompt?: string } @@ -151,6 +154,7 @@ export interface Config { tools?: Record auto_collapse_reasoning?: boolean experimental?: ExperimentalConfig + sandbox?: SandboxConfig indexing?: IndexingConfig } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f9fd0b2234..f63301c480 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -54,6 +54,7 @@ import { primaryPaths } from "../kilocode/primary-worktree" import { Git } from "@/git" import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins" import { KilocodeGlobalConfigStamp } from "@/kilocode/config/global-stamp" +import { SandboxConfig } from "@/kilocode/sandbox/config" import { IndexingConfig as KiloIndexingConfig, IndexingSchema as KiloIndexingSchema, @@ -250,6 +251,7 @@ export const Info = Schema.Struct({ hide_prompt_training_models: Schema.optional(Schema.Boolean).annotate({ description: "Hide Kilo Gateway models that may train on your prompts from model listings", }), + sandbox: Schema.optional(SandboxConfig.Info), model: Schema.optional(Schema.NullOr(ConfigModelID)).annotate({ description: "Model to use in the format of provider/model, eg anthropic/claude-2", }), @@ -416,18 +418,6 @@ export const Info = Schema.Struct({ description: "Continue the agent loop when a tool call is denied", }), // kilocode_change start - sandbox: Schema.optional(Schema.Boolean).annotate({ - description: - "Run agent tools inside a sandbox that restricts writes to project and Kilo state directories and can restrict outbound network access", - }), - sandbox_restrict_network: Schema.optional(Schema.Boolean).annotate({ - description: - "Restrict outbound network access for model-originated commands and first-party HTTP tools; local MCP servers and plugin hooks are not covered (default: true)", - }), - sandbox_writable_paths: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ - description: - "Additional filesystem paths the sandbox allows writes to (e.g. ['/tmp', '/var/log']). These are merged with the default writable paths when the sandbox is active.", - }), swe_pruner: Schema.optional(Schema.Boolean).annotate({ description: "Enable SWE-Pruner: task-aware pruning of large read/grep tool outputs guided by a focus question provided by the agent (default: false)", @@ -785,10 +775,7 @@ export const layer = Layer.effect( // kilocode_change start const merge = Effect.fnUntraced(function* (source: string, next: Info, kind?: ConfigPlugin.Scope) { const scope = kind ?? (yield* pluginScopeForSource(source)) - // sandbox_writable_paths is security-sensitive — only global config may set it. - // A project kilo.json must not widen the sandbox beyond the user's intent. - if (scope === "local") delete next.experimental?.sandbox_writable_paths - const scoped = KilocodeConfig.scopeIndexing(next, scope) + const scoped = KilocodeConfig.scopeIndexing(SandboxConfig.scope(next, scope), scope) result = mergeConfigConcatArrays(result, scoped) return yield* mergePluginOrigins(source, scoped.plugin, scope) }) diff --git a/packages/opencode/src/kilocode/plugins/sandbox.tsx b/packages/opencode/src/kilocode/plugins/sandbox.tsx index bd1075f22d..444de8421e 100644 --- a/packages/opencode/src/kilocode/plugins/sandbox.tsx +++ b/packages/opencode/src/kilocode/plugins/sandbox.tsx @@ -39,7 +39,7 @@ function View(props: { }) { createEffect( on( - () => props.api.state.config.experimental?.sandbox, + () => props.api.state.config.sandbox?.enabled, () => void props.load(props.sessionID, true), ), ) diff --git a/packages/opencode/src/kilocode/sandbox/config.ts b/packages/opencode/src/kilocode/sandbox/config.ts new file mode 100644 index 0000000000..42209d30c2 --- /dev/null +++ b/packages/opencode/src/kilocode/sandbox/config.ts @@ -0,0 +1,40 @@ +import { Schema } from "effect" + +export namespace SandboxConfig { + export const Network = Schema.Literals(["allow", "deny"]) + export type Network = Schema.Schema.Type + + export const Info = Schema.Struct({ + enabled: Schema.optional( + Schema.Boolean.annotate({ description: "Enable sandbox confinement for new sessions (default: false)" }), + ), + network: Schema.optional( + Network.annotate({ description: "Control outbound network access from sandboxed tools (default: deny)" }), + ), + writable_paths: Schema.optional( + Schema.mutable(Schema.Array(Schema.String)).annotate({ + description: "Additional filesystem paths that sandboxed tools may write to", + }), + ), + }).annotate({ description: "Sandbox configuration for agent tools" }) + export type Info = Schema.Schema.Type + + export function resolve(config: { sandbox?: Info }) { + return { + enabled: config.sandbox?.enabled ?? false, + mode: config.sandbox?.network ?? "deny", + } + } + + export function scope(config: T, source: "global" | "local"): T { + if (source === "global" || config.sandbox === undefined) return config + const scoped = { ...config } + const sandbox: Info = { + ...(config.sandbox.enabled === true ? { enabled: true } : {}), + ...(config.sandbox.network === "deny" ? { network: "deny" as const } : {}), + } + if (Object.keys(sandbox).length > 0) scoped.sandbox = sandbox + else delete scoped.sandbox + return scoped + } +} diff --git a/packages/opencode/src/kilocode/sandbox/policy.ts b/packages/opencode/src/kilocode/sandbox/policy.ts index 72fbb6654c..4a4b80e178 100644 --- a/packages/opencode/src/kilocode/sandbox/policy.ts +++ b/packages/opencode/src/kilocode/sandbox/policy.ts @@ -13,6 +13,7 @@ import { Changed } from "./event" import * as Network from "./network" import { SandboxPreference } from "./preference" import * as SandboxState from "./state" +import { SandboxConfig } from "./config" import { SandboxStore } from "./store" export type Snapshot = SandboxStore.Snapshot @@ -39,8 +40,8 @@ const resolveInitial = Effect.fn("SandboxPolicy.resolveInitial")(function* (dire const cfg = yield* (yield* Config.Service).get() const chosen = yield* SandboxState.read(sessionID) const pref = yield* Effect.promise(() => SandboxPreference.read(directory)) - const mode = cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny" - return initial(chosen?.enabled, pref, cfg.experimental?.sandbox ?? false, mode) + const fallback = SandboxConfig.resolve(cfg) + return initial(chosen?.enabled, pref, fallback.enabled, fallback.mode) }) function locked(sessionID: SessionID, effect: Effect.Effect) { return Effect.acquireUseRelease( @@ -170,10 +171,13 @@ const snapshot = Effect.fn("SandboxPolicy.snapshot")(function* (sessionID: Sessi export const configuredSupport = Effect.fn("SandboxPolicy.configuredSupport")(function* () { const cfg = yield* (yield* Config.Service).get() - const mode = cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny" - return backendSupport({ mode, allowedHosts: [] }) + return backendSupport({ mode: SandboxConfig.resolve(cfg).mode, allowedHosts: [] }) }) +export function fallback(config: Config.Info) { + return SandboxConfig.resolve(config) +} + export const status = Effect.fn("SandboxPolicy.status")(function* (sessionID: SessionID) { const current = yield* snapshot(sessionID) const support = backendSupport({ mode: current.state.mode, allowedHosts: [] }) @@ -304,7 +308,7 @@ function execute(sessionID: SessionID, effect: Effect.Effect) const support = backendSupport({ mode: current.state.mode, allowedHosts: [] }) if (!current.state.enabled || !support.available) return yield* unrestricted(effect) const cfg = yield* (yield* Config.Service).get() - const raw = cfg.experimental?.sandbox_writable_paths + const raw = cfg.sandbox?.writable_paths const extraWritable = raw?.map((p) => (p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p)) return yield* runSandbox(profile(yield* InstanceState.context, current.state.mode, extraWritable), effect) }) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 1c24fad80b..f1768b8110 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -178,8 +178,7 @@ export const TaskTool = Tool.define( const rules = KiloTask.inherited({ caller, session: parent, mcp: cfg.mcp }) // kilocode_change end // kilocode_change start - refresh current parent restrictions when resuming an existing task session - const mode: "allow" | "deny" = cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny" - const fallback = { enabled: cfg.experimental?.sandbox ?? false, mode } + const fallback = SandboxPolicy.fallback(cfg) if (session) { yield* SandboxPolicy.inherit(ctx.sessionID, session.id, fallback) const permission = KiloTask.merge( diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index 6aff6b8a7b..89c6b8749a 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -242,8 +242,8 @@ describe("kilocode indexing config", () => { }) }) -describe("kilocode sandbox writable paths config", () => { - test("honors sandbox_writable_paths from global config only, ignoring project config", async () => { +describe("kilocode sandbox config", () => { + test("prevents project config from weakening sandbox policy", async () => { await using globalTmp = await tmpdir() await using tmp = await tmpdir({ git: true }) @@ -255,18 +255,48 @@ describe("kilocode sandbox writable paths config", () => { try { await writeConfig(globalTmp.path, { $schema: "https://app.kilo.ai/config.json", - experimental: { sandbox_writable_paths: ["/tmp/global"] }, + sandbox: { enabled: true, network: "deny", writable_paths: ["/tmp/global"] }, }) - // A project kilo.json must not widen the sandbox: its writable paths are dropped at merge time. await writeConfig(tmp.path, { - experimental: { sandbox_writable_paths: ["/tmp/project"] }, + sandbox: { enabled: false, network: "allow", writable_paths: ["/tmp/project"] }, }) await provideTestInstance({ directory: tmp.path, fn: async () => { const config = await load() - expect(config.experimental?.sandbox_writable_paths).toEqual(["/tmp/global"]) + expect(config.sandbox).toEqual({ enabled: true, network: "deny", writable_paths: ["/tmp/global"] }) + }, + }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("allows project config to strengthen sandbox policy", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear() + await disposeAllInstances() + + try { + await writeConfig(globalTmp.path, { + sandbox: { enabled: false, network: "allow", writable_paths: ["/tmp/global"] }, + }) + await writeConfig(tmp.path, { + sandbox: { enabled: true, network: "deny", writable_paths: ["/tmp/project"] }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.sandbox).toEqual({ enabled: true, network: "deny", writable_paths: ["/tmp/global"] }) }, }) } finally { diff --git a/packages/opencode/test/kilocode/sandbox/config-network.test.ts b/packages/opencode/test/kilocode/sandbox/config-network.test.ts index d4a54b87b8..cc0f9b4d9d 100644 --- a/packages/opencode/test/kilocode/sandbox/config-network.test.ts +++ b/packages/opencode/test/kilocode/sandbox/config-network.test.ts @@ -29,10 +29,7 @@ function layer(restrict?: boolean) { TestConfig.layer({ get: () => Effect.succeed({ - experimental: { - sandbox: true, - sandbox_restrict_network: restrict, - }, + sandbox: { enabled: true, network: restrict === false ? "allow" : "deny" }, }), }), ) diff --git a/packages/opencode/test/kilocode/sandbox/sdk-config.test.ts b/packages/opencode/test/kilocode/sandbox/sdk-config.test.ts index 15af3fe8ae..eb09ead790 100644 --- a/packages/opencode/test/kilocode/sandbox/sdk-config.test.ts +++ b/packages/opencode/test/kilocode/sandbox/sdk-config.test.ts @@ -3,14 +3,11 @@ import type { Config as ConfigV1 } from "@kilocode/sdk" import type { Config as ConfigV2 } from "@kilocode/sdk/v2" const value = { - experimental: { - sandbox: true, - sandbox_restrict_network: false, - }, + sandbox: { enabled: true, network: "allow" as const, writable_paths: ["/tmp/output"] }, } test("both public SDK Config types expose sandbox policy fields", () => { const legacy = value satisfies ConfigV1 const current = value satisfies ConfigV2 - expect(legacy.experimental).toEqual(current.experimental) + expect(legacy.sandbox).toEqual(current.sandbox) }) diff --git a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts index 135b8d2307..ab83730f0e 100644 --- a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts @@ -89,7 +89,7 @@ function context(directory: string, main: string, sandboxes: string[]): Instance } const config = TestConfig.layer({ - get: () => Effect.succeed({ experimental: { sandbox: true } }), + get: () => Effect.succeed({ sandbox: { enabled: true } }), }) const agents = Layer.mock(Agent.Service)({ get: () => Effect.succeed(agent), diff --git a/packages/opencode/test/kilocode/sandbox/session.test.ts b/packages/opencode/test/kilocode/sandbox/session.test.ts index 8cb90f4a82..954ab6972f 100644 --- a/packages/opencode/test/kilocode/sandbox/session.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session.test.ts @@ -32,7 +32,7 @@ describe("sandbox session cleanup", () => { it.live("forks inherit the source session snapshot", () => Effect.gen(function* () { const sessions = yield* Session.Service - const dir = yield* tmpdirScoped({ git: true, config: { experimental: { sandbox: true } } }) + const dir = yield* tmpdirScoped({ git: true, config: { sandbox: { enabled: true } } }) const source = yield* provideInstance(dir)(sessions.create({ title: "sandbox-source" })) const status = yield* provideInstance(dir)(SandboxPolicy.status(source.id)) if (!status.available) return @@ -49,7 +49,7 @@ describe("sandbox session cleanup", () => { it.live("forks into another directory carry the source confinement", () => Effect.gen(function* () { const sessions = yield* Session.Service - const dir = yield* tmpdirScoped({ git: true, config: { experimental: { sandbox: true } } }) + const dir = yield* tmpdirScoped({ git: true, config: { sandbox: { enabled: true } } }) const worktree = yield* tmpdirScoped({ git: true }) const source = yield* provideInstance(dir)(sessions.create({ title: "sandbox-source" })) const status = yield* provideInstance(dir)(SandboxPolicy.status(source.id)) @@ -68,7 +68,7 @@ describe("sandbox session cleanup", () => { Effect.gen(function* () { const sessions = yield* Session.Service // Config default is disabled; the create-time toggle asks for enabled. - const dir = yield* tmpdirScoped({ git: true, config: { experimental: { sandbox: false } } }) + const dir = yield* tmpdirScoped({ git: true, config: { sandbox: { enabled: false } } }) const session = yield* provideInstance(dir)( sessions.create({ title: "sandbox-explicit", metadata: { "kilocode.sandbox": { enabled: true, version: 0 } } }), ) diff --git a/packages/opencode/test/kilocode/sandbox/shell-network.test.ts b/packages/opencode/test/kilocode/sandbox/shell-network.test.ts index f05fbeaa21..a767ba9c0c 100644 --- a/packages/opencode/test/kilocode/sandbox/shell-network.test.ts +++ b/packages/opencode/test/kilocode/sandbox/shell-network.test.ts @@ -31,10 +31,7 @@ function configured(restrict: boolean) { TestConfig.layer({ get: () => Effect.succeed({ - experimental: { - sandbox: true, - sandbox_restrict_network: restrict, - }, + sandbox: { enabled: true, network: restrict ? "deny" : "allow" }, }), }), ) diff --git a/packages/opencode/test/kilocode/sandbox/state.test.ts b/packages/opencode/test/kilocode/sandbox/state.test.ts index 2d41d8a218..9a8906b021 100644 --- a/packages/opencode/test/kilocode/sandbox/state.test.ts +++ b/packages/opencode/test/kilocode/sandbox/state.test.ts @@ -6,7 +6,7 @@ import { Deferred, Effect, Exit, Fiber, Layer } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Flag } from "@opencode-ai/core/flag/flag" -import { assertNetwork, enabled as sandboxed } from "@kilocode/sandbox" +import { assertNetwork, assertWrite, enabled as sandboxed } from "@kilocode/sandbox" import { Bus } from "@/bus" import { Config } from "@/config/config" import * as Network from "@/kilocode/sandbox/network" @@ -69,9 +69,9 @@ test("restores the session snapshot after a backend restart", async () => { } try { - const initial = run({ experimental: { sandbox: true, sandbox_restrict_network: true } }) + const initial = run({ sandbox: { enabled: true, network: "deny" } }) expect(initial.state).toEqual({ enabled: true, mode: "deny", version: 0 }) - const restored = run({ experimental: { sandbox: false, sandbox_restrict_network: false } }) + const restored = run({ sandbox: { enabled: false, network: "allow" } }) expect(restored.state).toEqual(initial.state) expect(restored.status.enabled).toBe(restored.status.available) } finally { @@ -127,7 +127,7 @@ linux("reports configured network namespace availability", async () => { 'import { SessionID } from "@/session/schema"', "const directory = process.cwd()", 'const context = { directory, worktree: directory, project: { id: "sandbox-status", worktree: directory, vcs: "git", time: { created: 0, updated: 0 }, sandboxes: [] } }', - "const status = (restrict) => SandboxPolicy.status(SessionID.make(`ses_sandbox_status_${restrict}`)).pipe(Effect.provide(Layer.mock(Config.Service, { get: () => Effect.succeed({ experimental: { sandbox: true, sandbox_restrict_network: restrict } }) })), Effect.provideService(InstanceRef, context), Effect.runPromise)", + "const status = (restrict) => SandboxPolicy.status(SessionID.make(`ses_sandbox_status_${restrict}`)).pipe(Effect.provide(Layer.mock(Config.Service, { get: () => Effect.succeed({ sandbox: { enabled: true, network: restrict ? 'deny' : 'allow' } }) })), Effect.provideService(InstanceRef, context), Effect.runPromise)", "const deny = await status(true)", "const allow = await status(false)", 'if (deny.available || deny.enabled || !deny.reason?.includes("Linux network sandbox")) process.exit(2)', @@ -161,10 +161,8 @@ it.instance("snapshots the primary kilo config for the session lifetime", () => const file = path.join(test.directory, "kilo.json") const legacy = path.join(test.directory, "opencode.json") const config = yield* Config.Service - yield* Effect.promise(() => - Bun.write(file, JSON.stringify({ experimental: { sandbox: true, sandbox_restrict_network: true } })), - ) - yield* config.update({ experimental: { sandbox: true, sandbox_restrict_network: true } }) + yield* Effect.promise(() => Bun.write(file, JSON.stringify({ sandbox: { enabled: true, network: "deny" } }))) + yield* config.update({ sandbox: { enabled: true, network: "deny" } }) const id = SessionID.make("ses_sandbox_config") const initial = yield* SandboxPolicy.status(id) @@ -172,12 +170,10 @@ it.instance("snapshots the primary kilo config for the session lifetime", () => expect(initial.version).toBe(0) if (!initial.available) return - yield* Effect.promise(() => - Bun.write(file, JSON.stringify({ experimental: { sandbox: false, sandbox_restrict_network: false } })), - ) - yield* config.update({ experimental: { sandbox: false, sandbox_restrict_network: false } }) + yield* Effect.promise(() => Bun.write(file, JSON.stringify({ sandbox: { enabled: false, network: "allow" } }))) + yield* config.update({ sandbox: { enabled: false, network: "allow" } }) - expect((yield* config.get()).experimental?.sandbox).toBe(false) + expect((yield* config.get()).sandbox?.enabled).toBeUndefined() expect(yield* Effect.promise(() => Bun.file(legacy).exists())).toBe(false) expect((yield* SandboxPolicy.status(id)).enabled).toBe(true) expect(yield* execute(id, sandboxed)).toBe(true) @@ -191,7 +187,7 @@ it.instance("snapshots the primary kilo config for the session lifetime", () => ), ) -it.instance("does not enable authless sessions without the experimental sandbox flag", () => +it.instance("does not enable authless sessions without sandbox enabled", () => Effect.acquireUseRelease( Effect.sync(() => { const password = Flag.KILO_SERVER_PASSWORD @@ -215,6 +211,30 @@ it.instance("does not enable authless sessions without the experimental sandbox ), ) +it.instance("applies configured writable paths during tool execution", () => + Effect.gen(function* () { + const test = yield* TestInstance + const outside = path.join(path.dirname(test.directory), `sandbox-writable-${path.basename(test.directory)}`) + yield* Effect.promise(() => fs.mkdir(outside, { recursive: true })) + yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))) + + const id = SessionID.make("ses_sandbox_writable_config") + const result = yield* Effect.gen(function* () { + const status = yield* SandboxPolicy.status(id) + if (!status.available) return undefined + return yield* execute(id, assertWrite(path.join(outside, "allowed.txt")).pipe(Effect.exit)) + }).pipe( + Effect.provide( + Layer.mock(Config.Service, { + get: () => Effect.succeed({ sandbox: { enabled: true, network: "allow", writable_paths: [outside] } }), + }), + ), + ) + if (result === undefined) return + expect(Exit.isSuccess(result)).toBe(true) + }), +) + it.instance( "runs sandboxed when config is on and no override exists", () => @@ -224,7 +244,7 @@ it.instance( expect(status.enabled).toBe(status.available) expect(yield* execute(id, sandboxed)).toBe(status.available) }), - { config: { experimental: { sandbox: true } } }, + { config: { sandbox: { enabled: true } } }, ) it.instance( @@ -240,7 +260,7 @@ it.instance( expect((yield* SandboxPolicy.status(second)).enabled).toBe(false) expect(yield* execute(second, sandboxed)).toBe(false) }), - { config: { experimental: { sandbox: true } } }, + { config: { sandbox: { enabled: true } } }, ) it.instance("persists an authless toggle to later sessions", () => @@ -271,7 +291,7 @@ it.instance( expect((yield* SandboxPolicy.status(third)).enabled).toBe(true) expect(yield* execute(third, sandboxed)).toBe(true) }), - { config: { experimental: { sandbox: true } } }, + { config: { sandbox: { enabled: true } } }, ) it.instance("isolates concurrent session overrides and clears them", () => @@ -364,7 +384,7 @@ it.instance( expect((yield* SandboxPolicy.status(child)).enabled).toBe(true) expect(yield* execute(child, sandboxed)).toBe(true) }), - { config: { experimental: { sandbox: true } } }, + { config: { sandbox: { enabled: true } } }, ) it.instance("enforces writes only while the macOS session override is active", () => diff --git a/packages/opencode/test/kilocode/sandbox/tui.test.ts b/packages/opencode/test/kilocode/sandbox/tui.test.ts index 4834c27b3e..fe65da6f87 100644 --- a/packages/opencode/test/kilocode/sandbox/tui.test.ts +++ b/packages/opencode/test/kilocode/sandbox/tui.test.ts @@ -26,7 +26,7 @@ describe("sandbox TUI", () => { expect(content).toContain("await ensureSession(api)") expect(content).toContain("api.client.session.create") expect(content).toContain('api.route.navigate("session", { sessionID })') - expect(content).toContain("props.api.state.config.experimental?.sandbox") + expect(content).toContain("props.api.state.config.sandbox?.enabled") expect(content).toContain("void props.load(props.sessionID, true)") expect(content).toContain('api.event.on("sandbox.status.changed"') }) diff --git a/packages/opencode/test/kilocode/task-nesting.test.ts b/packages/opencode/test/kilocode/task-nesting.test.ts index ef8e2e497b..cfc2d195b1 100644 --- a/packages/opencode/test/kilocode/task-nesting.test.ts +++ b/packages/opencode/test/kilocode/task-nesting.test.ts @@ -435,7 +435,7 @@ describe("Kilo task nesting", () => { expect(count).toBeGreaterThan(0) expect(resumed.permission?.filter((rule) => rule.permission === "bash")).toHaveLength(count ?? 0) }), - { config: { experimental: { sandbox: true } } }, + { config: { sandbox: { enabled: true } } }, ), ) diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 93d5331a91..eb8e7b5976 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -58,6 +58,37 @@ if (sseTypesPatched === sseTypesSource) { } await Bun.write(sseTypesPath, sseTypesPatched) +// The legacy SDK generator is retired, but this public Config type remains exported. +// Keep Kilo's released sandbox settings aligned with the current generated client. +const legacyTypesPath = "./src/gen/types.gen.ts" +const legacyTypesFile = Bun.file(legacyTypesPath) +const legacySource = await legacyTypesFile.text() +const sandbox = ` /** + * Sandbox configuration for agent tools + */ + sandbox?: { + /** + * Enable sandbox confinement for new sessions (default: false) + */ + enabled?: boolean + /** + * Control outbound network access from sandboxed tools (default: deny) + */ + network?: "allow" | "deny" + /** + * Additional filesystem paths that sandboxed tools may write to + */ + writable_paths?: Array + } +` +const legacyPatched = legacySource.includes(sandbox) + ? legacySource + : legacySource.replace(" experimental?: {\n", sandbox + " experimental?: {\n") +if (!legacyPatched.includes(sandbox)) { + throw new Error(`Legacy Config sandbox patch did not apply (${legacyTypesPath})`) +} +await Bun.write(legacyTypesPath, legacyPatched) + await $`bun prettier --write src/gen` await $`bun prettier --write src/v2` await $`rm -rf dist tsconfig.tsbuildinfo` diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 0ccb02e9b5..8a4ddf1da6 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1343,6 +1343,23 @@ export type Config = { */ url?: string } + /** + * Sandbox configuration for agent tools + */ + sandbox?: { + /** + * Enable sandbox confinement for new sessions (default: false) + */ + enabled?: boolean + /** + * Control outbound network access from sandboxed tools (default: deny) + */ + network?: "allow" | "deny" + /** + * Additional filesystem paths that sandboxed tools may write to + */ + writable_paths?: Array + } experimental?: { hook?: { file_edited?: { @@ -1373,14 +1390,6 @@ export type Config = { * Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag) */ openTelemetry?: boolean - /** - * Run agent tools inside a sandbox that restricts writes to project and Kilo state directories and can restrict outbound network access - */ - sandbox?: boolean - /** - * Restrict outbound network access for model-originated commands and first-party HTTP tools; local MCP servers and plugin hooks are not covered (default: true) - */ - sandbox_restrict_network?: boolean /** * Tools that should only be available to primary agents. */ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a64ee1f7b3..032d32e92d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1577,6 +1577,23 @@ export type Config = { terminal_command_display?: "expanded" | "collapsed" code_edit_display?: "expanded" | "collapsed" hide_prompt_training_models?: boolean + /** + * Sandbox configuration for agent tools + */ + sandbox?: { + /** + * Enable sandbox confinement for new sessions (default: false) + */ + enabled?: boolean + /** + * Control outbound network access from sandboxed tools (default: deny) + */ + network?: "allow" | "deny" + /** + * Additional filesystem paths that sandboxed tools may write to + */ + writable_paths?: Array + } model?: string small_model?: string subagent_model?: string @@ -1693,9 +1710,6 @@ export type Config = { openTelemetry?: boolean primary_tools?: Array continue_loop_on_deny?: boolean - sandbox?: boolean - sandbox_restrict_network?: boolean - sandbox_writable_paths?: Array swe_pruner?: boolean swe_pruner_model?: string mcp_timeout?: number diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 4d20ce87d4..ba8a20a801 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -24916,6 +24916,29 @@ "hide_prompt_training_models": { "type": "boolean" }, + "sandbox": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable sandbox confinement for new sessions (default: false)" + }, + "network": { + "type": "string", + "enum": ["allow", "deny"], + "description": "Control outbound network access from sandboxed tools (default: deny)" + }, + "writable_paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional filesystem paths that sandboxed tools may write to" + } + }, + "additionalProperties": false, + "description": "Sandbox configuration for agent tools" + }, "model": { "type": "string" }, @@ -25266,18 +25289,6 @@ "continue_loop_on_deny": { "type": "boolean" }, - "sandbox": { - "type": "boolean" - }, - "sandbox_restrict_network": { - "type": "boolean" - }, - "sandbox_writable_paths": { - "type": "array", - "items": { - "type": "string" - } - }, "swe_pruner": { "type": "boolean" },