mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
feat(cli): persist /sandbox toggle across new sessions
The CLI /sandbox toggle was session-scoped: each new session reset to the static config default (or secure()-forced ON in authless mode), while the VS Code extension's sandbox button persisted the last choice. Add a per-directory persisted preference (SandboxPreference) that new sessions resolve after create-time metadata and before the config default, so /sandbox now remembers the last toggled state per project. secure()-by-default still applies when neither an explicit choice nor a preference exists. Add SandboxPreference.root to the sandbox denyWrite list so a sandboxed process cannot plant a false preference to disable confinement for later sessions.
This commit is contained in:
@@ -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.
|
||||
@@ -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<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
|
||||
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<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,
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user