diff --git a/.changeset/persist-cli-sandbox-toggle.md b/.changeset/persist-cli-sandbox-toggle.md
new file mode 100644
index 00000000000..542c23e7aa1
--- /dev/null
+++ b/.changeset/persist-cli-sandbox-toggle.md
@@ -0,0 +1,5 @@
+---
+"@kilocode/cli": minor
+---
+
+Persist the `/sandbox` toggle across new CLI sessions per project directory, mirroring the VS Code extension's sandbox button. New sessions now inherit the last toggled state instead of resetting to the config default each time.
diff --git a/packages/opencode/src/kilocode/sandbox/policy.ts b/packages/opencode/src/kilocode/sandbox/policy.ts
index 5add0d231ab..46a333c4d5c 100644
--- a/packages/opencode/src/kilocode/sandbox/policy.ts
+++ b/packages/opencode/src/kilocode/sandbox/policy.ts
@@ -11,6 +11,7 @@ import type { InstanceContext } from "@/project/instance-context"
import type { SessionID } from "@/session/schema"
import { Changed } from "./event"
import * as Network from "./network"
+import { SandboxPreference } from "./preference"
import * as SandboxState from "./state"
import { SandboxStore } from "./store"
@@ -28,6 +29,25 @@ function secure(snapshot: Snapshot): Snapshot {
return { ...snapshot, enabled: true, mode: "deny" }
}
+function initial(
+ chosen: boolean | undefined,
+ pref: boolean | undefined,
+ cfgDefault: boolean,
+ mode: Snapshot["mode"],
+): Snapshot {
+ if (chosen !== undefined) return { enabled: chosen, mode, version: 0 }
+ if (pref !== undefined) return { enabled: pref, mode, version: 0 }
+ return secure({ enabled: cfgDefault, mode, version: 0 })
+}
+
+const resolveInitial = Effect.fn("SandboxPolicy.resolveInitial")(function* (directory: string, sessionID: SessionID) {
+ 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)
+})
+
function locked(sessionID: SessionID, effect: Effect.Effect) {
return Effect.acquireUseRelease(
Effect.sync(() => {
@@ -99,7 +119,7 @@ export function profile(ctx: InstanceContext, mode: Profile["network"]["mode"] =
return {
filesystem: {
allowWrite: writable,
- denyWrite: [root(SandboxStore.root)],
+ denyWrite: [root(SandboxStore.root), root(SandboxPreference.root)],
denyNames: [".git"],
temporaryDirectory: Global.Path.tmp,
},
@@ -137,15 +157,11 @@ const snapshot = Effect.fn("SandboxPolicy.snapshot")(function* (sessionID: Sessi
Effect.gen(function* () {
const existing = yield* read(directory, sessionID)
if (existing) return { directory, state: existing }
- const cfg = yield* (yield* Config.Service).get()
// 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({
- enabled: chosen?.enabled ?? cfg.experimental?.sandbox ?? false,
- mode: cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny",
- version: 0,
- })
+ // session moved or created with an explicit choice keeps that choice instead of resetting. The
+ // persisted per-directory preference (last toggled state) is the next precedence, so new sessions
+ // inherit the last /sandbox choice. secure-by-default only applies when neither is present.
+ const next = yield* resolveInitial(directory, sessionID)
yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next))
snapshots.set(key(directory, sessionID), next)
return { directory, state: next }
@@ -179,14 +195,7 @@ function change(sessionID: SessionID, guard: Effect.Effect)
Effect.gen(function* () {
yield* guard
const stored = yield* read(directory, sessionID)
- const cfg = stored ? undefined : yield* (yield* Config.Service).get()
- const current =
- stored ??
- secure({
- enabled: cfg?.experimental?.sandbox ?? false,
- mode: cfg?.experimental?.sandbox_restrict_network === false ? "allow" : "deny",
- version: 0,
- })
+ const current = stored ?? (yield* resolveInitial(directory, sessionID))
const support = backendSupport({ mode: current.mode, allowedHosts: [] })
const status = {
directory,
@@ -199,6 +208,12 @@ function change(sessionID: SessionID, guard: Effect.Effect)
const next: Snapshot = { ...current, enabled: !status.enabled, version: status.version + 1 }
yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next))
snapshots.set(key(directory, sessionID), next)
+ // The per-session SandboxStore is the authoritative state; the per-directory
+ // preference only seeds future sessions. A preference write failure must not
+ // fail the toggle or desync the in-memory cache from the persisted snapshot.
+ yield* Effect.promise(() => SandboxPreference.write(directory, next.enabled)).pipe(
+ Effect.catch(() => Effect.void),
+ )
const value = { ...status, enabled: next.enabled, version: next.version }
yield* (yield* Bus.Service).publish(Changed, { sessionID, ...value })
return value
diff --git a/packages/opencode/src/kilocode/sandbox/preference.ts b/packages/opencode/src/kilocode/sandbox/preference.ts
new file mode 100644
index 00000000000..454d84c7d88
--- /dev/null
+++ b/packages/opencode/src/kilocode/sandbox/preference.ts
@@ -0,0 +1,35 @@
+import { createHash, randomUUID } from "node:crypto"
+import fs from "node:fs/promises"
+import { realpathSync } from "node:fs"
+import path from "node:path"
+import { Global } from "@opencode-ai/core/global"
+
+export namespace SandboxPreference {
+ export const root = path.join(realpathSync.native(path.dirname(Global.Path.state)), "kilo-sandbox-preference")
+
+ function file(directory: string) {
+ return path.join(root, createHash("sha256").update(directory).digest("hex") + ".json")
+ }
+
+ export async function read(directory: string): Promise {
+ const target = file(directory)
+ const text = await fs.readFile(target, "utf8").catch((err: NodeJS.ErrnoException) => {
+ if (err.code === "ENOENT") return undefined
+ throw err
+ })
+ if (text === undefined) return undefined
+ const value: unknown = JSON.parse(text)
+ return typeof value === "boolean" ? value : undefined
+ }
+
+ export async function write(directory: string, enabled: boolean) {
+ const target = file(directory)
+ const temp = path.join(root, `.${randomUUID()}.tmp`)
+ await fs.mkdir(root, { recursive: true, mode: 0o700 })
+ await fs.writeFile(temp, JSON.stringify(enabled), { encoding: "utf8", flag: "wx", mode: 0o600 })
+ await fs.rename(temp, target).catch(async (err) => {
+ await fs.rm(temp, { force: true })
+ throw err
+ })
+ }
+}
diff --git a/packages/opencode/test/kilocode/sandbox/policy.test.ts b/packages/opencode/test/kilocode/sandbox/policy.test.ts
index 0a8100b87bd..03523526999 100644
--- a/packages/opencode/test/kilocode/sandbox/policy.test.ts
+++ b/packages/opencode/test/kilocode/sandbox/policy.test.ts
@@ -5,6 +5,7 @@ import { Global } from "@opencode-ai/core/global"
import { assertWrite, run as runSandbox } from "@kilocode/sandbox"
import { Effect, Exit } from "effect"
import { profile } from "@/kilocode/sandbox/policy"
+import { SandboxPreference } from "@/kilocode/sandbox/preference"
import { SandboxStore } from "@/kilocode/sandbox/store"
import type { InstanceContext } from "@/project/instance-context"
import { ProjectID } from "@/project/schema"
@@ -178,13 +179,22 @@ describe("sandbox policy", () => {
const dirs = tmp.extra
const ctx = context(dirs.a, dirs.a, dirs)
const policy = profile(ctx)
- const write = await Effect.runPromise(runSandbox(policy, assertWrite(SandboxStore.root)).pipe(Effect.exit))
+ const [storeWrite, prefWrite] = await Effect.runPromise(
+ Effect.all([
+ runSandbox(policy, assertWrite(SandboxStore.root)).pipe(Effect.exit),
+ runSandbox(policy, assertWrite(SandboxPreference.root)).pipe(Effect.exit),
+ ]),
+ )
expect(new Set(roots(ctx))).toEqual(expected(dirs.a))
expect(policy.filesystem.temporaryDirectory).toBe(Global.Path.tmp)
- expect(policy.filesystem.denyWrite).toEqual([{ path: SandboxStore.root, kind: "subtree" }])
+ expect(policy.filesystem.denyWrite).toEqual([
+ { path: SandboxStore.root, kind: "subtree" },
+ { path: SandboxPreference.root, kind: "subtree" },
+ ])
expect(policy.environment.deny).toEqual(["KILO_SERVER_PASSWORD", "KILO_SERVER_USERNAME"])
- expect(Exit.isFailure(write)).toBe(true)
+ expect(Exit.isFailure(storeWrite)).toBe(true)
+ expect(Exit.isFailure(prefWrite)).toBe(true)
})
test("uses deny-by-default and configurable network profiles", async () => {
diff --git a/packages/opencode/test/kilocode/sandbox/state.test.ts b/packages/opencode/test/kilocode/sandbox/state.test.ts
index c861b01d5b8..bb043140e54 100644
--- a/packages/opencode/test/kilocode/sandbox/state.test.ts
+++ b/packages/opencode/test/kilocode/sandbox/state.test.ts
@@ -212,32 +212,52 @@ it.instance(
)
it.instance(
- "overrides config off for only one session",
+ "persists a toggle so new sessions inherit the last choice",
() =>
Effect.gen(function* () {
- const first = SessionID.make("ses_sandbox_override_off")
- const second = SessionID.make("ses_sandbox_config_stays_on")
+ const first = SessionID.make("ses_sandbox_persist_off")
+ const second = SessionID.make("ses_sandbox_persist_inherit")
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.status(second)).enabled).toBe(false)
+ expect(yield* execute(second, sandboxed)).toBe(false)
}),
{ config: { experimental: { sandbox: true } } },
)
-it.instance("trusted toggles disable only one authless session", () =>
+it.instance("persists an authless toggle to later sessions", () =>
Effect.gen(function* () {
- const first = SessionID.make("ses_sandbox_override_on")
- const second = SessionID.make("ses_sandbox_default_remains_off")
+ const first = SessionID.make("ses_sandbox_authless_persist")
+ const second = SessionID.make("ses_sandbox_authless_inherit")
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.status(second)).enabled).toBe(false)
+ expect(yield* execute(second, sandboxed)).toBe(false)
}),
)
+it.instance(
+ "remembers a later toggle back on for new sessions",
+ () =>
+ Effect.gen(function* () {
+ const first = SessionID.make("ses_sandbox_roundtrip_a")
+ const second = SessionID.make("ses_sandbox_roundtrip_b")
+ const third = SessionID.make("ses_sandbox_roundtrip_c")
+ if (!(yield* SandboxPolicy.status(first)).available) return
+
+ yield* SandboxPolicy.toggle(first)
+ expect((yield* SandboxPolicy.status(second)).enabled).toBe(false)
+ yield* SandboxPolicy.toggle(second)
+ expect((yield* SandboxPolicy.status(third)).enabled).toBe(true)
+ expect(yield* execute(third, sandboxed)).toBe(true)
+ }),
+ { config: { experimental: { sandbox: true } } },
+)
+
it.instance("isolates concurrent session overrides and clears them", () =>
Effect.gen(function* () {
const first = SessionID.make("ses_sandbox_first")
@@ -247,6 +267,9 @@ it.instance("isolates concurrent session overrides and clears them", () =>
expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(false)
return
}
+ // Seed second with its own stored snapshot before any toggle, so its state
+ // stays independent of the per-directory preference that toggles now persist.
+ expect((yield* SandboxPolicy.status(second)).enabled).toBe(true)
expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(false)
expect((yield* SandboxPolicy.status(second)).enabled).toBe(true)
@@ -254,6 +277,8 @@ it.instance("isolates concurrent session overrides and clears them", () =>
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)
+ // retire clears first's stored snapshot; it re-seeds from the persisted
+ // per-directory preference, which holds the last toggle (second -> true).
expect((yield* SandboxPolicy.status(first)).enabled).toBe(true)
expect((yield* SandboxPolicy.status(second)).enabled).toBe(true)
}),