Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/persist-cli-sandbox-toggle.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 32 additions & 17 deletions packages/opencode/src/kilocode/sandbox/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -179,14 +195,7 @@ function change<E, R>(sessionID: SessionID, guard: Effect.Effect<unknown, E, R>)
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,
Expand All @@ -199,6 +208,12 @@ function change<E, R>(sessionID: SessionID, guard: Effect.Effect<unknown, E, R>)
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
Expand Down
35 changes: 35 additions & 0 deletions packages/opencode/src/kilocode/sandbox/preference.ts
Original file line number Diff line number Diff line change
@@ -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<boolean | undefined> {
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
})
}
}
16 changes: 13 additions & 3 deletions packages/opencode/test/kilocode/sandbox/policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 () => {
Expand Down
41 changes: 33 additions & 8 deletions packages/opencode/test/kilocode/sandbox/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -247,13 +267,18 @@ 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)
expect((yield* SandboxPolicy.toggle(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)
// 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)
}),
Expand Down
Loading