From 067fcf51f87bdb1b229d0c93b08a63f79c6b1eb7 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 2 Jul 2026 21:22:36 +0200 Subject: [PATCH] fix(cli): keep sandbox disabled by default --- .changeset/sandbox-default-flag.md | 5 + .../opencode/src/kilocode/sandbox/policy.ts | 18 ++-- .../kilocode/sandbox/config-network.test.ts | 7 +- .../test/kilocode/sandbox/session.test.ts | 4 +- .../kilocode/sandbox/shell-network.test.ts | 8 +- .../test/kilocode/sandbox/state.test.ts | 55 +++++++---- .../test/kilocode/task-nesting.test.ts | 96 ++++++++++--------- 7 files changed, 106 insertions(+), 87 deletions(-) create mode 100644 .changeset/sandbox-default-flag.md diff --git a/.changeset/sandbox-default-flag.md b/.changeset/sandbox-default-flag.md new file mode 100644 index 00000000000..1d14fdfc96c --- /dev/null +++ b/.changeset/sandbox-default-flag.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep sandboxing disabled by default unless the experimental sandbox setting or an explicit session toggle enables it. diff --git a/packages/opencode/src/kilocode/sandbox/policy.ts b/packages/opencode/src/kilocode/sandbox/policy.ts index 5add0d231ab..0a322878a67 100644 --- a/packages/opencode/src/kilocode/sandbox/policy.ts +++ b/packages/opencode/src/kilocode/sandbox/policy.ts @@ -2,7 +2,6 @@ import { readFileSync, statSync } from "node:fs" import path from "node:path" import { Effect, Semaphore } from "effect" import { Global } from "@opencode-ai/core/global" -import { Flag } from "@opencode-ai/core/flag/flag" import { backendSupport, run as runSandbox, unrestricted, type Profile } from "@kilocode/sandbox" import { Bus } from "@/bus" import { Config } from "@/config/config" @@ -23,11 +22,6 @@ function key(directory: string, sessionID: SessionID) { return directory + "\0" + sessionID } -function secure(snapshot: Snapshot): Snapshot { - if (Flag.KILO_SERVER_PASSWORD) return snapshot - return { ...snapshot, enabled: true, mode: "deny" } -} - function locked(sessionID: SessionID, effect: Effect.Effect) { return Effect.acquireUseRelease( Effect.sync(() => { @@ -141,11 +135,11 @@ const snapshot = Effect.fn("SandboxPolicy.snapshot")(function* (sessionID: Sessi // A session's create-time kilocode.sandbox toggle takes precedence over the config default, so a // session moved or created with an explicit choice keeps that choice instead of resetting. const chosen = yield* SandboxState.read(sessionID) - const next = secure({ + const next: Snapshot = { enabled: chosen?.enabled ?? cfg.experimental?.sandbox ?? false, mode: cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny", version: 0, - }) + } yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next)) snapshots.set(key(directory, sessionID), next) return { directory, state: next } @@ -180,13 +174,13 @@ function change(sessionID: SessionID, guard: Effect.Effect) yield* guard const stored = yield* read(directory, sessionID) const cfg = stored ? undefined : yield* (yield* Config.Service).get() - const current = + const current: Snapshot = stored ?? - secure({ + { enabled: cfg?.experimental?.sandbox ?? false, mode: cfg?.experimental?.sandbox_restrict_network === false ? "allow" : "deny", version: 0, - }) + } const support = backendSupport({ mode: current.mode, allowedHosts: [] }) const status = { directory, @@ -224,7 +218,7 @@ export const inherit = Effect.fn("SandboxPolicy.inherit")(function* ( parentID, Effect.gen(function* () { const stored = yield* read(directory, parentID) - const parent = stored ?? (fallback && secure({ ...fallback, version: 0 })) + const parent: Snapshot | undefined = stored ?? (fallback && { ...fallback, version: 0 }) if (!parent) return // Only persist the parent snapshot when it actually belongs to this directory. A fallback // carries confinement from another directory (e.g. forking into a worktree) and must not be diff --git a/packages/opencode/test/kilocode/sandbox/config-network.test.ts b/packages/opencode/test/kilocode/sandbox/config-network.test.ts index 9a347d2f72a..d4a54b87b86 100644 --- a/packages/opencode/test/kilocode/sandbox/config-network.test.ts +++ b/packages/opencode/test/kilocode/sandbox/config-network.test.ts @@ -74,7 +74,7 @@ restricted.live("keeps network restriction enabled by default when the sandbox i }).pipe(Effect.ensuring(Effect.promise(() => target.server.stop(true)))) }) -open.live("keeps network denied without authenticated server control", () => { +open.live("allows network when restriction is disabled without authenticated server control", () => { const target = server() return Effect.gen(function* () { const http = yield* HttpClient.HttpClient @@ -86,10 +86,11 @@ open.live("keeps network denied without authenticated server control", () => { ) if (!backendSupport().available) { expect(Exit.isSuccess(exit)).toBe(true) + expect(target.requests()).toBe(1) return } expect(status.enabled).toBe(true) - expect(Exit.isFailure(exit)).toBe(true) - expect(target.requests()).toBe(0) + expect(Exit.isSuccess(exit)).toBe(true) + expect(target.requests()).toBe(1) }).pipe(Effect.ensuring(Effect.promise(() => target.server.stop(true)))) }) diff --git a/packages/opencode/test/kilocode/sandbox/session.test.ts b/packages/opencode/test/kilocode/sandbox/session.test.ts index 0f950ab7590..8cb90f4a825 100644 --- a/packages/opencode/test/kilocode/sandbox/session.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session.test.ts @@ -92,8 +92,8 @@ describe("sandbox session cleanup", () => { yield* provideInstance(dir)(SandboxPolicy.toggle(info.id)) yield* provideInstance(worktree)(SandboxPolicy.toggle(info.id)) - expect((yield* Effect.promise(() => SandboxStore.read(dir, info.id)))?.enabled).toBe(false) - expect((yield* Effect.promise(() => SandboxStore.read(worktree, info.id)))?.enabled).toBe(false) + expect((yield* Effect.promise(() => SandboxStore.read(dir, info.id)))?.enabled).toBe(true) + expect((yield* Effect.promise(() => SandboxStore.read(worktree, info.id)))?.enabled).toBe(true) yield* session.remove(info.id) expect(yield* Effect.promise(() => SandboxStore.read(dir, info.id))).toBeUndefined() expect(yield* Effect.promise(() => SandboxStore.read(worktree, info.id))).toBeUndefined() diff --git a/packages/opencode/test/kilocode/sandbox/shell-network.test.ts b/packages/opencode/test/kilocode/sandbox/shell-network.test.ts index b2c6ec22a88..f05fbeaa21b 100644 --- a/packages/opencode/test/kilocode/sandbox/shell-network.test.ts +++ b/packages/opencode/test/kilocode/sandbox/shell-network.test.ts @@ -135,7 +135,7 @@ describe("model shell network integration", () => { ) test.skipIf(process.platform !== "darwin" && process.platform !== "linux")( - "keeps spawned shell network denied without authenticated server control", + "honors configured shell network access without authenticated server control", async () => { const effect = Effect.gen(function* () { const root = yield* tmpdirScoped() @@ -156,9 +156,9 @@ describe("model shell network integration", () => { provideInstance(root), Effect.provide(configured(true)), ) - expect(allow.output).not.toContain("model-shell-network-ok") - expect(allow.metadata.exit).not.toBe(0) - expect(allowed.accepted()).toBe(0) + expect(allow.output).toContain("model-shell-network-ok") + expect(allow.metadata.exit).toBe(0) + expect(allowed.accepted()).toBe(1) expect(deny.output).not.toContain("model-shell-network-ok") expect(deny.metadata.exit).not.toBe(0) expect(denied.accepted()).toBe(0) diff --git a/packages/opencode/test/kilocode/sandbox/state.test.ts b/packages/opencode/test/kilocode/sandbox/state.test.ts index c861b01d5b8..82fdc19fd98 100644 --- a/packages/opencode/test/kilocode/sandbox/state.test.ts +++ b/packages/opencode/test/kilocode/sandbox/state.test.ts @@ -11,6 +11,7 @@ import { Bus } from "@/bus" import { Config } from "@/config/config" import * as Network from "@/kilocode/sandbox/network" import * as SandboxPolicy from "@/kilocode/sandbox/policy" +import { SandboxStore } from "@/kilocode/sandbox/store" import { SessionID } from "@/session/schema" import { TestInstance } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" @@ -190,13 +191,28 @@ it.instance("snapshots the primary kilo config for the session lifetime", () => ), ) -it.instance("keeps authless config-off sessions confined", () => - Effect.gen(function* () { - const id = SessionID.make("ses_sandbox_default_off") - const status = yield* SandboxPolicy.status(id) - expect(status.enabled).toBe(status.available) - expect(yield* execute(id, sandboxed)).toBe(status.available) - }), +it.instance("does not enable authless sessions without the experimental sandbox flag", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const password = Flag.KILO_SERVER_PASSWORD + Flag.KILO_SERVER_PASSWORD = undefined + return password + }), + () => + Effect.gen(function* () { + const test = yield* TestInstance + const id = SessionID.make("ses_sandbox_default_off") + const status = yield* SandboxPolicy.status(id) + const state = yield* Effect.promise(() => SandboxStore.read(test.directory, id)) + + expect(state?.enabled).toBe(false) + expect(state?.mode).toBe("deny") + expect(state?.version).toBe(0) + expect(status.enabled).toBe(false) + expect(yield* execute(id, sandboxed)).toBe(false) + }), + (password) => Effect.sync(() => (Flag.KILO_SERVER_PASSWORD = password)), + ), ) it.instance( @@ -226,15 +242,15 @@ it.instance( { config: { experimental: { sandbox: true } } }, ) -it.instance("trusted toggles disable only one authless session", () => +it.instance("trusted toggles enable only one authless config-off session", () => Effect.gen(function* () { const first = SessionID.make("ses_sandbox_override_on") const second = SessionID.make("ses_sandbox_default_remains_off") if (!(yield* SandboxPolicy.status(first)).available) return - expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(false) - expect(yield* execute(first, sandboxed)).toBe(false) - expect(yield* execute(second, sandboxed)).toBe(true) + expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(true) + expect(yield* execute(first, sandboxed)).toBe(true) + expect(yield* execute(second, sandboxed)).toBe(false) }), ) @@ -248,14 +264,14 @@ it.instance("isolates concurrent session overrides and clears them", () => return } - expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(false) - expect((yield* SandboxPolicy.status(second)).enabled).toBe(true) - expect((yield* SandboxPolicy.toggle(second)).enabled).toBe(false) + expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(true) + expect((yield* SandboxPolicy.status(second)).enabled).toBe(false) expect((yield* SandboxPolicy.toggle(second)).enabled).toBe(true) - expect((yield* SandboxPolicy.status(first)).enabled).toBe(false) - yield* SandboxPolicy.retire(first, (yield* TestInstance).directory, Effect.void) + expect((yield* SandboxPolicy.toggle(second)).enabled).toBe(false) expect((yield* SandboxPolicy.status(first)).enabled).toBe(true) - expect((yield* SandboxPolicy.status(second)).enabled).toBe(true) + yield* SandboxPolicy.retire(first, (yield* TestInstance).directory, Effect.void) + expect((yield* SandboxPolicy.status(first)).enabled).toBe(false) + expect((yield* SandboxPolicy.status(second)).enabled).toBe(false) }), ) @@ -274,7 +290,7 @@ it.instance("serializes concurrent toggles for a session", () => const id = SessionID.make("ses_sandbox_concurrent") if (!(yield* SandboxPolicy.status(id)).available) return yield* Effect.all([SandboxPolicy.toggle(id), SandboxPolicy.toggle(id)], { concurrency: "unbounded" }) - expect((yield* SandboxPolicy.status(id)).enabled).toBe(true) + expect((yield* SandboxPolicy.status(id)).enabled).toBe(false) }), ) @@ -298,7 +314,7 @@ it.instance("prevents a queued toggle from restoring a retired override", () => yield* Fiber.join(removal) expect(Exit.isFailure(yield* Fiber.join(pending))).toBe(true) const status = yield* SandboxPolicy.status(id) - expect(status.enabled).toBe(status.available) + expect(status.enabled).toBe(false) }), ) @@ -344,6 +360,7 @@ it.instance("enforces writes only while the macOS session override is active", ( svc.spawn(ChildProcess.make("/usr/bin/touch", [file])).pipe(Effect.flatMap((child) => child.exitCode)), ) + expect((yield* SandboxPolicy.toggle(id)).enabled).toBe(true) expect(Number(yield* execute(id, run(inside)))).toBe(0) expect(Number(yield* execute(id, run(external)))).not.toBe(0) expect(Number(yield* execute(id, run(git)))).not.toBe(0) diff --git a/packages/opencode/test/kilocode/task-nesting.test.ts b/packages/opencode/test/kilocode/task-nesting.test.ts index 3791cd33faa..ef8e2e497b6 100644 --- a/packages/opencode/test/kilocode/task-nesting.test.ts +++ b/packages/opencode/test/kilocode/task-nesting.test.ts @@ -384,56 +384,58 @@ describe("Kilo task nesting", () => { ) it.live("refreshes inherited restrictions when resuming a task child", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const sessions = yield* Session.Service - const { chat, assistant } = yield* seed() - const support = yield* SandboxPolicy.status(chat.id) - yield* sessions.setPermission({ - sessionID: chat.id, - permission: [{ permission: "bash", pattern: "*", action: "deny" }], - }) - const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) - if (support.available) { - yield* SandboxPolicy.toggle(child.id) - expect((yield* SandboxPolicy.status(child.id)).enabled).toBe(false) - } - const tool = yield* TaskTool - const def = yield* tool.init() + provideTmpdirInstance( + () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const support = yield* SandboxPolicy.status(chat.id) + yield* sessions.setPermission({ + sessionID: chat.id, + permission: [{ permission: "bash", pattern: "*", action: "deny" }], + }) + const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) + if (support.available) { + yield* SandboxPolicy.toggle(child.id) + expect((yield* SandboxPolicy.status(child.id)).enabled).toBe(false) + } + const tool = yield* TaskTool + const def = yield* tool.init() - const exec = () => - def.execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "explore", - task_id: child.id, - }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps: stubOps() }, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, - ) + const exec = () => + def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "explore", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps() }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) - yield* exec() - const first = yield* sessions.get(child.id) - if (support.available) expect((yield* SandboxPolicy.status(child.id)).enabled).toBe(true) - const count = first.permission?.filter((rule) => rule.permission === "bash").length - yield* exec() + yield* exec() + const first = yield* sessions.get(child.id) + if (support.available) expect((yield* SandboxPolicy.status(child.id)).enabled).toBe(true) + const count = first.permission?.filter((rule) => rule.permission === "bash").length + yield* exec() - const resumed = yield* sessions.get(child.id) - expect(resumed.permission).toEqual( - expect.arrayContaining([{ permission: "bash", pattern: "*", action: "deny" }]), - ) - expect(count).toBeGreaterThan(0) - expect(resumed.permission?.filter((rule) => rule.permission === "bash")).toHaveLength(count ?? 0) - }), + const resumed = yield* sessions.get(child.id) + expect(resumed.permission).toEqual( + expect.arrayContaining([{ permission: "bash", pattern: "*", action: "deny" }]), + ) + expect(count).toBeGreaterThan(0) + expect(resumed.permission?.filter((rule) => rule.permission === "bash")).toHaveLength(count ?? 0) + }), + { config: { experimental: { sandbox: true } } }, ), )