fix(sandbox): prevent config self-disable

This commit is contained in:
marius-kilocode
2026-06-25 16:26:58 +02:00
parent 9dcc23049f
commit 163aef5675
18 changed files with 604 additions and 97 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Prevent confined sessions and delegated agents from weakening their sandbox policy through configuration changes or unauthenticated server control.
@@ -441,6 +441,33 @@ linux("applies explicit file and subtree denies after a writable parent", async
}
})
linux("prevents renaming an ancestor of a denied state subtree", async () => {
const root = await fixture()
const parent = path.join(root.project, "state")
const store = path.join(parent, "policy")
const sibling = path.join(parent, "sibling.txt")
const moved = path.join(root.project, "moved")
await fs.mkdir(store, { recursive: true })
const policy = denied(profile([root.project]), [
{ path: store, kind: "subtree" },
{ path: parent, kind: "literal" },
{ path: root.project, kind: "literal" },
])
const script = [
'const fs = require("node:fs")',
`fs.writeFileSync(${JSON.stringify(sibling)}, "allowed")`,
`try { fs.renameSync(${JSON.stringify(parent)}, ${JSON.stringify(moved)}); process.exit(2) } catch {}`,
].join("\n")
try {
expect(Number(await Effect.runPromise(spawn(script, root.project, policy)))).toBe(0)
expect(await fs.readFile(sibling, "utf8")).toBe("allowed")
expect((await fs.stat(store)).isDirectory()).toBe(true)
} finally {
await fs.rm(root.root, { recursive: true, force: true })
}
})
linux("supports writable literal files without opening writable siblings", async () => {
const root = await fixture()
const allowed = path.join(root.project, "allowed.txt")
+5 -1
View File
@@ -98,7 +98,11 @@ function scan(root: string, names: ReadonlySet<string>, found: Set<string>) {
}
function protectedPaths(profile: Profile, allow: ReadonlyArray<PathRule>) {
const found = new Set(profile.filesystem.denyWrite.filter((rule) => existsSync(rule.path)).map((rule) => rule.path))
const found = new Set(
profile.filesystem.denyWrite
.filter((rule) => existsSync(rule.path) && (rule.kind === "subtree" || !statSync(rule.path).isDirectory()))
.map((rule) => rule.path),
)
if (profile.filesystem.denyNames.length === 0) return [...found]
const names = new Set(profile.filesystem.denyNames)
@@ -104,6 +104,33 @@ describe("sandbox launch preparation", () => {
}
})
test("does not make literal deny ancestors recursively read-only on Linux", () => {
const root = mkdtempSync(path.join(os.tmpdir(), "kilo-bubblewrap-literal-"))
const parent = path.join(root, "state")
const store = path.join(parent, "policy")
mkdirSync(store, { recursive: true })
const profile: Profile = {
...makeProfile("allow"),
filesystem: {
allowWrite: [{ path: root, kind: "subtree" }],
denyWrite: [
{ path: parent, kind: "literal" },
{ path: store, kind: "subtree" },
],
denyNames: [],
},
}
try {
const result = generateBubblewrap(profile, { ...launch, cwd: root }, "/opt/kilo/bwrap")
const protectedPaths = result.args.flatMap((arg, index) => (arg === "--ro-bind" ? [result.args[index + 1]] : []))
expect(protectedPaths).toContain(store)
expect(protectedPaths).not.toContain(parent)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("isolates the Linux network namespace in deny mode", () => {
const root = mkdtempSync(path.join(os.tmpdir(), "kilo-bubblewrap-network-"))
const input = makeProfile("deny")
+5 -2
View File
@@ -4,6 +4,7 @@ import path from "path"
import os from "os"
import { Filesystem } from "@/util/filesystem"
import { InvalidError } from "./error"
import { ConfigVariableGuard } from "@/kilocode/config/variable" // kilocode_change
type ParseSource =
| {
@@ -36,6 +37,7 @@ export async function substitute(input: SubstituteInput) {
const missing = input.missing ?? "error"
const escape = input.escapeJson ?? true // kilocode_change
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
if (!ConfigVariableGuard.env(varName)) return "" // kilocode_change
return (input.env?.[varName] ?? process.env[varName]) || ""
})
@@ -66,8 +68,9 @@ export async function substitute(input: SubstituteInput) {
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
const fileContent = // kilocode_change - validate and read one opened file to prevent credential substitution races
(
await ConfigVariableGuard.read(resolvedPath, Filesystem.readText).catch((error: NodeJS.ErrnoException) => {
if (missing === "empty") return ""
const errMsg = `bad file reference: "${token}"`
@@ -0,0 +1,23 @@
import fs from "node:fs/promises"
import { realpathSync } from "node:fs"
export namespace ConfigVariableGuard {
const secret = new Set(["KILO_SERVER_PASSWORD", "KILO_SERVER_USERNAME"])
export function env(name: string) {
return !secret.has(name.toUpperCase())
}
export async function read(path: string, load: (path: string) => Promise<string>) {
if (process.platform !== "linux") return load(path)
const file = await fs.open(path, "r")
try {
const target = `/proc/self/fd/${file.fd}`
const resolved = realpathSync.native(target)
if (/^\/proc\/.*\/environ$/.test(resolved)) throw new Error("blocked process environment reference")
return await load(target)
} finally {
await file.close()
}
}
}
+130 -32
View File
@@ -2,6 +2,7 @@ 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"
@@ -10,14 +11,22 @@ import type { InstanceContext } from "@/project/instance-context"
import type { SessionID } from "@/session/schema"
import { Changed } from "./event"
import * as Network from "./network"
import { SandboxStore } from "./store"
const overrides = new Map<string, { enabled: boolean; version: number }>()
type State = SandboxStore.State
const states = new Map<string, State>()
const locks = new Map<SessionID, { semaphore: Semaphore.Semaphore; refs: number }>()
function key(directory: string, sessionID: SessionID) {
return directory + "\0" + sessionID
}
function secure(state: State): State {
if (Flag.KILO_SERVER_PASSWORD) return state
return { ...state, enabled: true, mode: "deny" }
}
function locked<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
@@ -39,6 +48,16 @@ function root(path: string) {
return { path, kind: "subtree" as const }
}
function literal(path: string) {
return { path, kind: "literal" as const }
}
function parents(dir: string): ReturnType<typeof literal>[] {
const parent = path.dirname(dir)
if (parent === dir) return [literal(dir)]
return [...parents(parent), literal(dir)]
}
function marker(dir: string) {
try {
const file = path.join(dir, ".git")
@@ -89,7 +108,7 @@ export function profile(ctx: InstanceContext, mode: Profile["network"]["mode"] =
return {
filesystem: {
allowWrite: writable,
denyWrite: [],
denyWrite: [root(SandboxStore.root), ...parents(path.dirname(SandboxStore.root))],
denyNames: [".git"],
temporaryDirectory: Global.Path.tmp,
},
@@ -98,7 +117,7 @@ export function profile(ctx: InstanceContext, mode: Profile["network"]["mode"] =
allowedHosts: [],
},
environment: {
deny: [],
deny: ["KILO_SERVER_PASSWORD", "KILO_SERVER_USERNAME"],
set: {
TMPDIR: Global.Path.tmp,
TMP: Global.Path.tmp,
@@ -108,35 +127,79 @@ export function profile(ctx: InstanceContext, mode: Profile["network"]["mode"] =
}
}
export const status = Effect.fn("SandboxPolicy.status")(function* (sessionID: SessionID) {
const config = yield* Config.Service
const cfg = yield* config.get()
const read = Effect.fn("SandboxPolicy.read")(function* (directory: string, sessionID: SessionID) {
const id = key(directory, sessionID)
const current = states.get(id)
if (current) return current
const stored = yield* Effect.promise(() => SandboxStore.read(directory, sessionID))
if (stored) states.set(id, stored)
return stored
})
const state = Effect.fn("SandboxPolicy.state")(function* (sessionID: SessionID) {
const directory = yield* InstanceState.directory
const override = overrides.get(key(directory, sessionID))
const enabled = override?.enabled ?? cfg.experimental?.sandbox ?? false
const mode = cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny"
const support = backendSupport({ mode, allowedHosts: [] })
const current = yield* read(directory, sessionID)
if (current) return { directory, state: current }
return yield* locked(
sessionID,
Effect.gen(function* () {
const existing = yield* read(directory, sessionID)
if (existing) return { directory, state: existing }
const cfg = yield* (yield* Config.Service).get()
const next = secure({
enabled: cfg.experimental?.sandbox ?? false,
mode: cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny",
version: 0,
})
yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next))
states.set(key(directory, sessionID), next)
return { directory, state: next }
}),
)
})
export const status = Effect.fn("SandboxPolicy.status")(function* (sessionID: SessionID) {
const current = yield* state(sessionID)
const support = backendSupport({ mode: current.state.mode, allowedHosts: [] })
return {
directory,
enabled: enabled && support.available,
directory: current.directory,
enabled: current.state.enabled && support.available,
available: support.available,
reason: support.reason,
version: override?.version ?? 0,
version: current.state.version,
}
})
function change<E, R>(sessionID: SessionID, guard: Effect.Effect<unknown, E, R>) {
return Effect.gen(function* () {
const directory = yield* InstanceState.directory
const id = key(directory, sessionID)
return yield* locked(
sessionID,
Effect.gen(function* () {
yield* guard
const current = yield* status(sessionID)
if (!current.enabled && !current.available) return current
const value = { ...current, enabled: !current.enabled, version: current.version + 1 }
overrides.set(id, { enabled: value.enabled, version: value.version })
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 support = backendSupport({ mode: current.mode, allowedHosts: [] })
const status = {
directory,
enabled: current.enabled && support.available,
available: support.available,
reason: support.reason,
version: current.version,
}
if (!status.enabled && !status.available) return status
const next: State = { ...current, enabled: !status.enabled, version: status.version + 1 }
yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next))
states.set(key(directory, sessionID), next)
const value = { ...status, enabled: next.enabled, version: next.version }
yield* (yield* Bus.Service).publish(Changed, { sessionID, ...value })
return value
}),
@@ -146,14 +209,46 @@ function change<E, R>(sessionID: SessionID, guard: Effect.Effect<unknown, E, R>)
export const toggle = Effect.fn("SandboxPolicy.toggle")((sessionID: SessionID) => change(sessionID, Effect.void))
export const inherit = Effect.fn("SandboxPolicy.inherit")(function* (
parentID: SessionID,
sessionID: SessionID,
fallback?: Omit<State, "version">,
) {
const directory = yield* InstanceState.directory
yield* locked(
parentID,
Effect.gen(function* () {
const stored = yield* read(directory, parentID)
const parent = stored ?? (fallback && secure({ ...fallback, version: 0 }))
if (!parent) return
if (!stored) {
yield* Effect.promise(() => SandboxStore.write(directory, parentID, parent))
states.set(key(directory, parentID), parent)
}
yield* locked(
sessionID,
Effect.gen(function* () {
const child = yield* read(directory, sessionID)
const next: State = child
? {
enabled: parent.enabled || child.enabled,
mode: parent.mode === "deny" || child.mode === "deny" ? "deny" : "allow",
version: child.version + 1,
}
: { ...parent, version: 0 }
if (child && child.enabled === next.enabled && child.mode === next.mode) return
yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next))
states.set(key(directory, sessionID), next)
}),
)
}),
)
})
export function toggleGuarded<E, R>(sessionID: SessionID, guard: Effect.Effect<unknown, E, R>) {
return change(sessionID, guard)
}
export const clear = Effect.fn("SandboxPolicy.clear")(function* (sessionID: SessionID) {
yield* retire(sessionID, yield* InstanceState.directory, Effect.void)
})
export function retire<A, E, R>(
sessionID: SessionID,
directory: string,
@@ -162,8 +257,10 @@ export function retire<A, E, R>(
return locked(
sessionID,
Effect.gen(function* () {
overrides.delete(key(directory, sessionID))
return yield* effect
const result = yield* effect
yield* Effect.promise(() => SandboxStore.remove(directory, sessionID))
states.delete(key(directory, sessionID))
return result
}),
)
}
@@ -172,22 +269,23 @@ export function dispose<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A,
return locked(
sessionID,
Effect.gen(function* () {
const result = yield* effect
yield* Effect.promise(() => SandboxStore.dispose(sessionID))
const suffix = "\0" + sessionID
for (const id of overrides.keys()) {
if (id.endsWith(suffix)) overrides.delete(id)
for (const id of states.keys()) {
if (id.endsWith(suffix)) states.delete(id)
}
return yield* effect
return result
}),
)
}
function execute<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
if (!(yield* status(sessionID)).enabled) return yield* unrestricted(effect)
const config = yield* Config.Service
const cfg = yield* config.get()
const mode = cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny"
return yield* runSandbox(profile(yield* InstanceState.context, mode), effect)
const current = yield* state(sessionID)
const support = backendSupport({ mode: current.state.mode, allowedHosts: [] })
if (!current.state.enabled || !support.available) return yield* unrestricted(effect)
return yield* runSandbox(profile(yield* InstanceState.context, current.state.mode), effect)
})
}
@@ -0,0 +1,76 @@
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"
import type { Profile } from "@kilocode/sandbox"
import type { SessionID } from "@/session/schema"
export namespace SandboxStore {
export type State = {
enabled: boolean
mode: Extract<Profile["network"]["mode"], "allow" | "deny">
version: number
}
export const root = path.join(realpathSync.native(path.dirname(Global.Path.state)), "kilo-sandbox-policy")
function hash(value: string) {
return createHash("sha256").update(value).digest("hex")
}
function dir(sessionID: SessionID) {
return path.join(root, hash(sessionID))
}
function file(directory: string, sessionID: SessionID) {
return path.join(dir(sessionID), hash(directory) + ".json")
}
function valid(value: unknown): value is State {
if (!value || typeof value !== "object") return false
const state = value as Record<string, unknown>
return (
typeof state.enabled === "boolean" &&
(state.mode === "allow" || state.mode === "deny") &&
Number.isSafeInteger(state.version) &&
Number(state.version) >= 0
)
}
export async function read(directory: string, sessionID: SessionID) {
const target = file(directory, sessionID)
const text = await fs.readFile(target, "utf8").catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") return undefined
throw err
})
if (text === undefined) return
const value: unknown = JSON.parse(text)
if (!valid(value)) throw new Error(`Invalid sandbox policy state at ${target}`)
return value
}
export async function write(directory: string, sessionID: SessionID, state: State) {
const folder = dir(sessionID)
const target = file(directory, sessionID)
const temp = path.join(folder, `.${randomUUID()}.tmp`)
await fs.mkdir(folder, { recursive: true, mode: 0o700 })
await fs.writeFile(temp, JSON.stringify(state), { encoding: "utf8", flag: "wx", mode: 0o600 })
await fs.rename(temp, target).catch(async (err) => {
await fs.rm(temp, { force: true })
throw err
})
}
export async function remove(directory: string, sessionID: SessionID) {
await fs.rm(file(directory, sessionID), { force: true })
await fs.rmdir(dir(sessionID)).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT" || err.code === "ENOTEMPTY") return
throw err
})
}
export async function dispose(sessionID: SessionID) {
await fs.rm(dir(sessionID), { recursive: true, force: true })
}
}
+5 -1
View File
@@ -566,6 +566,7 @@ export const layer: Layer.Layer<
metadata?: typeof Metadata.Type
permission?: Permission.Ruleset
platform?: string // kilocode_change - per-session platform override for telemetry attribution
sourceID?: SessionID // kilocode_change - inherited sandbox policy source
}) {
const ctx = yield* InstanceState.context
const result: Info = {
@@ -591,8 +592,10 @@ export const layer: Layer.Layer<
}
log.info("created", result)
// kilocode_change start - register attribution before session.created subscribers run
// kilocode_change start - initialize inherited state before session.created subscribers run
KiloSession.register({ id: result.id, parentID: result.parentID, platform: input.platform })
const source = input.sourceID ?? result.parentID
if (source) yield* SandboxPolicy.inherit(source, result.id)
// kilocode_change end
yield* sync.run(Event.Created, { sessionID: result.id, info: result })
@@ -772,6 +775,7 @@ export const layer: Layer.Layer<
workspaceID: original.workspaceID,
title,
metadata: structuredClone(original.metadata),
sourceID: input.sessionID, // kilocode_change - forks preserve initialized confinement
})
const msgs = yield* messages({ sessionID: input.sessionID })
const idMap = new Map<string, MessageID>()
+6 -1
View File
@@ -18,6 +18,7 @@ import { errorMessage } from "@/util/error" // kilocode_change
import { Cause, Effect, Exit, Schema, Scope } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { RuntimeFlags } from "@/effect/runtime-flags"
import * as SandboxPolicy from "@/kilocode/sandbox/policy" // kilocode_change
export interface TaskPromptOps {
cancel(sessionID: SessionID): Effect.Effect<void>
@@ -160,7 +161,10 @@ 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 }
if (session) {
yield* SandboxPolicy.inherit(ctx.sessionID, session.id, fallback)
const permission = KiloTask.merge(
session.permission ?? [],
deriveSubagentSessionPermission({
@@ -197,8 +201,9 @@ export const TaskTool = Tool.define(
),
// kilocode_change end
}))
// kilocode_change start - rebuild in-memory ancestry and attribution after process restart
// kilocode_change start - rebuild in-memory ancestry and inherit confinement after creation/resume
KiloSession.register({ id: nextSession.id, parentID: ctx.sessionID, platform })
yield* SandboxPolicy.inherit(ctx.sessionID, nextSession.id, fallback)
// kilocode_change end
const msg = yield* MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe(Effect.orDie)
@@ -0,0 +1,36 @@
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { expect, test } from "bun:test"
import { ConfigVariable } from "@/config/variable"
const source = { type: "virtual" as const, source: "test", dir: process.cwd() }
test("does not substitute server credentials from the environment", async () => {
const result = await ConfigVariable.substitute({
...source,
text: "password={env:KILO_SERVER_PASSWORD};value={env:SAFE_VALUE}",
env: { KILO_SERVER_PASSWORD: "secret", SAFE_VALUE: "allowed" },
})
expect(result).toBe("password=;value=allowed")
})
test.skipIf(process.platform !== "linux")("does not substitute process environment files", async () => {
await expect(
ConfigVariable.substitute({
...source,
text: "{file:/proc/self/environ}",
}),
).rejects.toThrow('bad file reference: "{file:/proc/self/environ}"')
})
test.skipIf(process.platform !== "linux")("does not substitute an environment file through a symlink", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-"))
const link = path.join(dir, "value")
await fs.symlink("/proc/self/environ", link)
try {
await expect(ConfigVariable.substitute({ ...source, text: `{file:${link}}` })).rejects.toThrow("bad file reference")
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
})
@@ -10,7 +10,6 @@ import { SessionID } from "@/session/schema"
import { TestConfig } from "../../fixture/config"
import { testEffect } from "../../lib/effect"
const sessionID = SessionID.make("ses_sandbox_config_network")
const tool = ToolNetwork.builtin({ id: "webfetch" })
const ctx = {
directory: process.cwd(),
@@ -59,6 +58,7 @@ restricted.live("keeps network restriction enabled by default when the sandbox i
const target = server()
return Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const sessionID = SessionID.make("ses_sandbox_config_network_restricted")
const exit = yield* SandboxPolicy.executeTool(sessionID, tool, http.get(target.server.url)).pipe(
Effect.provideService(InstanceRef, ctx),
Effect.exit,
@@ -74,14 +74,22 @@ restricted.live("keeps network restriction enabled by default when the sandbox i
}).pipe(Effect.ensuring(Effect.promise(() => target.server.stop(true))))
})
open.live("allows tool network traffic when network restriction is disabled", () => {
open.live("keeps network denied without authenticated server control", () => {
const target = server()
return Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const response = yield* SandboxPolicy.executeTool(sessionID, tool, http.get(target.server.url)).pipe(
const sessionID = SessionID.make("ses_sandbox_config_network_open")
const status = yield* SandboxPolicy.status(sessionID).pipe(Effect.provideService(InstanceRef, ctx))
const exit = yield* SandboxPolicy.executeTool(sessionID, tool, http.get(target.server.url)).pipe(
Effect.provideService(InstanceRef, ctx),
Effect.exit,
)
expect(yield* response.text).toBe("sandbox-config-ok")
expect(target.requests()).toBe(1)
if (!backendSupport().available) {
expect(Exit.isSuccess(exit)).toBe(true)
return
}
expect(status.enabled).toBe(true)
expect(Exit.isFailure(exit)).toBe(true)
expect(target.requests()).toBe(0)
}).pipe(Effect.ensuring(Effect.promise(() => target.server.stop(true))))
})
@@ -226,6 +226,40 @@ describe.skipIf(process.platform !== "darwin").serial("real macOS sandbox confin
),
)
it.live("protects denied state from ancestor rename without blocking sibling writes", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const proc = yield* AppProcess.Service
const parent = path.join(dir, "state")
const store = path.join(parent, "policy")
const moved = path.join(dir, "moved")
const sibling = path.join(parent, "sibling.txt")
yield* Effect.promise(() => fs.mkdir(store, { recursive: true }))
const policy: Profile = {
...profile(dir),
filesystem: {
...profile(dir).filesystem,
denyWrite: [
{ path: store, kind: "subtree" },
{ path: parent, kind: "literal" },
{ path: dir, kind: "literal" },
],
},
}
const write = yield* sandbox(
policy,
proc.run(ChildProcess.make("/bin/sh", ["-c", `printf allowed > ${JSON.stringify(sibling)}`])),
)
const rename = yield* sandbox(policy, proc.run(ChildProcess.make("/bin/mv", [parent, moved])))
expect(write.exitCode).toBe(0)
expect(rename.exitCode).not.toBe(0)
expect(yield* Effect.promise(() => fs.readFile(sibling, "utf8"))).toBe("allowed")
expect(yield* Effect.promise(() => fs.stat(store).then((entry) => entry.isDirectory()))).toBe(true)
}),
),
)
it.live("keeps permission approval and denial independent from confinement", () =>
provideTmpdirInstance((dir) =>
Effect.acquireUseRelease(
@@ -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 { SandboxStore } from "@/kilocode/sandbox/store"
import type { InstanceContext } from "@/project/instance-context"
import { ProjectID } from "@/project/schema"
import { tmpdir } from "../../fixture/fixture"
@@ -172,13 +173,25 @@ describe("sandbox policy", () => {
expect(Exit.isFailure(right.other)).toBe(true)
})
test("keeps Kilo state and temporary roots writable", async () => {
test("keeps Kilo state writable without exposing sandbox policy state", async () => {
await using tmp = await fixture()
const dirs = tmp.extra
const ctx = context(dirs.a, dirs.a, dirs)
const policy = profile(ctx)
const parent = path.dirname(SandboxStore.root)
const writes = await Effect.runPromise(
runSandbox(
policy,
Effect.all([assertWrite(SandboxStore.root).pipe(Effect.exit), assertWrite(parent).pipe(Effect.exit)]),
),
)
expect(new Set(roots(ctx))).toEqual(expected(dirs.a))
expect(profile(ctx).filesystem.temporaryDirectory).toBe(Global.Path.tmp)
expect(policy.filesystem.temporaryDirectory).toBe(Global.Path.tmp)
expect(policy.filesystem.denyWrite).toContainEqual({ path: SandboxStore.root, kind: "subtree" })
expect(policy.filesystem.denyWrite).toContainEqual({ path: parent, kind: "literal" })
expect(policy.environment.deny).toEqual(["KILO_SERVER_PASSWORD", "KILO_SERVER_USERNAME"])
expect(writes.every(Exit.isFailure)).toBe(true)
})
test("uses deny-by-default and configurable network profiles", async () => {
@@ -6,6 +6,7 @@ import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
import { SandboxStore } from "@/kilocode/sandbox/store"
import { Session } from "@/session/session"
import { Storage } from "@/storage/storage"
import { SyncEvent } from "@/sync"
@@ -28,7 +29,24 @@ const it = testEffect(
)
describe("sandbox session cleanup", () => {
it.live("clears every directory override when removing outside instance context", () =>
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 source = yield* provideInstance(dir)(sessions.create({ title: "sandbox-source" }))
const status = yield* provideInstance(dir)(SandboxPolicy.status(source.id))
if (!status.available) return
const fork = yield* provideInstance(dir)(sessions.fork({ sessionID: source.id }))
expect((yield* provideInstance(dir)(SandboxPolicy.status(fork.id))).enabled).toBe(true)
yield* provideInstance(dir)(SandboxPolicy.toggle(source.id))
expect((yield* provideInstance(dir)(SandboxPolicy.status(source.id))).enabled).toBe(false)
expect((yield* provideInstance(dir)(SandboxPolicy.status(fork.id))).enabled).toBe(true)
}),
)
it.live("clears every directory snapshot when removing outside instance context", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const dir = yield* tmpdirScoped({ git: true })
@@ -42,11 +60,11 @@ describe("sandbox session cleanup", () => {
yield* provideInstance(dir)(SandboxPolicy.toggle(info.id))
yield* provideInstance(worktree)(SandboxPolicy.toggle(info.id))
expect((yield* provideInstance(dir)(SandboxPolicy.status(info.id))).enabled).toBe(true)
expect((yield* provideInstance(worktree)(SandboxPolicy.status(info.id))).enabled).toBe(true)
expect((yield* Effect.promise(() => SandboxStore.read(dir, info.id)))?.enabled).toBe(false)
expect((yield* Effect.promise(() => SandboxStore.read(worktree, info.id)))?.enabled).toBe(false)
yield* session.remove(info.id)
expect((yield* provideInstance(dir)(SandboxPolicy.status(info.id))).enabled).toBe(false)
expect((yield* provideInstance(worktree)(SandboxPolicy.status(info.id))).enabled).toBe(false)
expect(yield* Effect.promise(() => SandboxStore.read(dir, info.id))).toBeUndefined()
expect(yield* Effect.promise(() => SandboxStore.read(worktree, info.id))).toBeUndefined()
}),
)
})
@@ -90,12 +90,15 @@ const execute = Effect.fn("ShellNetworkTest.execute")(function* (
return yield* runSandbox(profile(root, mode), shell.execute({ command: `/usr/bin/nc -v 127.0.0.1 ${port}` }, ctx))
})
const executeConfigured = Effect.fn("ShellNetworkTest.executeConfigured")(function* (port: number) {
const executeConfigured = Effect.fn("ShellNetworkTest.executeConfigured")(function* (
port: number,
sessionID = ctx.sessionID,
) {
const info = yield* ShellTool
const shell = yield* info.init()
const tool = Network.builtin({ id: "bash" })
return yield* SandboxPolicy.executeTool(
ctx.sessionID,
sessionID,
tool,
shell.execute({ command: `/usr/bin/nc -v 127.0.0.1 ${port}` }, ctx),
)
@@ -132,7 +135,7 @@ describe("model shell network integration", () => {
)
test.skipIf(process.platform !== "darwin" && process.platform !== "linux")(
"applies the network restriction setting to spawned shell commands",
"keeps spawned shell network denied without authenticated server control",
async () => {
const effect = Effect.gen(function* () {
const root = yield* tmpdirScoped()
@@ -145,17 +148,17 @@ describe("model shell network integration", () => {
}),
)
const allow = yield* executeConfigured(allowed.listener.port).pipe(
const allow = yield* executeConfigured(allowed.listener.port, SessionID.make("ses_sandbox_network_allow")).pipe(
provideInstance(root),
Effect.provide(configured(false)),
)
const deny = yield* executeConfigured(denied.listener.port).pipe(
const deny = yield* executeConfigured(denied.listener.port, SessionID.make("ses_sandbox_network_deny")).pipe(
provideInstance(root),
Effect.provide(configured(true)),
)
expect(allow.output).toContain("model-shell-network-ok")
expect(allow.metadata.exit).toBe(0)
expect(allowed.accepted()).toBe(1)
expect(allow.output).not.toContain("model-shell-network-ok")
expect(allow.metadata.exit).not.toBe(0)
expect(allowed.accepted()).toBe(0)
expect(deny.output).not.toContain("model-shell-network-ok")
expect(deny.metadata.exit).not.toBe(0)
expect(denied.accepted()).toBe(0)
@@ -1,10 +1,12 @@
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { expect, test } from "bun:test"
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 { enabled as sandboxed } from "@kilocode/sandbox"
import { Flag } from "@opencode-ai/core/flag/flag"
import { assertNetwork, enabled as sandboxed } from "@kilocode/sandbox"
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import * as Network from "@/kilocode/sandbox/network"
@@ -15,12 +17,92 @@ import { testEffect } from "../../lib/effect"
const it = testEffect(Layer.mergeAll(Bus.layer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer))
const linux = process.platform === "linux" ? test : test.skip
const posix = process.platform === "win32" ? test.skip : test
const tool = Network.builtin({ id: "read" })
function execute<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
return SandboxPolicy.executeTool(sessionID, tool, effect)
}
test("restores the session snapshot after a backend restart", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-sandbox-restart-"))
const directory = path.join(root, "project")
await fs.mkdir(directory)
const script = [
'import { Effect, Layer } from "effect"',
'import { Config } from "@/config/config"',
'import { InstanceRef } from "@/effect/instance-ref"',
'import * as SandboxPolicy from "@/kilocode/sandbox/policy"',
'import { SandboxStore } from "@/kilocode/sandbox/store"',
'import { SessionID } from "@/session/schema"',
"const directory = process.env.TEST_DIRECTORY",
'const context = { directory, worktree: directory, project: { id: "sandbox-restart", worktree: directory, vcs: "git", time: { created: 0, updated: 0 }, sandboxes: [] } }',
"const cfg = JSON.parse(process.env.TEST_CONFIG)",
'const id = SessionID.make("ses_sandbox_restart")',
"const status = await SandboxPolicy.status(id).pipe(Effect.provide(Layer.mock(Config.Service, { get: () => Effect.succeed(cfg) })), Effect.provideService(InstanceRef, context), Effect.runPromise)",
"const state = await SandboxStore.read(directory, id)",
"console.log(JSON.stringify({ status, state }))",
].join("\n")
const env = {
...process.env,
KILO_TEST_HOME: path.join(root, "home"),
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
TEST_DIRECTORY: directory,
}
const run = (config: object) => {
const result = Bun.spawnSync([process.execPath, "-e", script], {
cwd: import.meta.dir,
env: { ...env, TEST_CONFIG: JSON.stringify(config) },
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
})
expect(result.exitCode, result.stderr.toString()).toBe(0)
return JSON.parse(result.stdout.toString().trim().split("\n").at(-1)!) as {
status: { enabled: boolean; available: boolean; version: number }
state: { enabled: boolean; mode: string; version: number }
}
}
try {
const initial = run({ experimental: { sandbox: true, sandbox_restrict_network: true } })
expect(initial.state).toEqual({ enabled: true, mode: "deny", version: 0 })
const restored = run({ experimental: { sandbox: false, sandbox_restrict_network: false } })
expect(restored.state).toEqual(initial.state)
expect(restored.status.enabled).toBe(restored.status.available)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
posix("canonicalizes a symlinked policy state root", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-sandbox-state-link-"))
const target = path.join(root, "real-state")
const link = path.join(root, "state")
await fs.mkdir(target)
await fs.symlink(target, link)
const script = 'import { SandboxStore } from "@/kilocode/sandbox/store"; console.log(SandboxStore.root)'
try {
const result = Bun.spawnSync([process.execPath, "-e", script], {
cwd: import.meta.dir,
env: { ...process.env, XDG_STATE_HOME: link },
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
})
expect(result.exitCode, result.stderr.toString()).toBe(0)
expect(result.stdout.toString().trim().split("\n").at(-1)).toBe(
path.join(await fs.realpath(target), "kilo-sandbox-policy"),
)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
linux("reports configured network namespace availability", async () => {
const root = await fs.mkdtemp(path.join(process.env.TMPDIR ?? "/tmp", "kilo-sandbox-status-"))
const helper = path.join(root, "bwrap-no-network")
@@ -57,6 +139,7 @@ linux("reports configured network namespace availability", async () => {
env: { ...process.env, KILO_BWRAP_PATH: helper },
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
})
expect(result.exitCode, result.stderr.toString()).toBe(0)
} finally {
@@ -65,31 +148,52 @@ linux("reports configured network namespace availability", async () => {
})
it.instance(
"uses config as the default without persisting session toggles",
"uses config only to initialize session state",
() =>
Effect.gen(function* () {
const id = SessionID.make("ses_sandbox_config")
const initial = yield* SandboxPolicy.status(id)
expect(initial.enabled).toBe(initial.available)
expect(initial.version).toBe(0)
if (!initial.available) return
Effect.acquireUseRelease(
Effect.sync(() => {
const password = Flag.KILO_SERVER_PASSWORD
Flag.KILO_SERVER_PASSWORD = "sandbox-test"
return password
}),
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const id = SessionID.make("ses_sandbox_config")
const initial = yield* SandboxPolicy.status(id)
expect(initial.enabled).toBe(initial.available)
expect(initial.version).toBe(0)
if (!initial.available) return
const disabled = yield* SandboxPolicy.toggle(id)
expect(disabled.enabled).toBe(false)
expect(disabled.version).toBe(1)
expect((yield* (yield* Config.Service).get()).experimental?.sandbox).toBe(true)
const config = yield* Config.Service
yield* Effect.promise(() =>
Bun.write(
path.join(test.directory, "opencode.json"),
JSON.stringify({ experimental: { sandbox: false, sandbox_restrict_network: false } }),
),
)
yield* config.update({ experimental: { sandbox: false, sandbox_restrict_network: false } })
yield* SandboxPolicy.clear(id)
expect((yield* SandboxPolicy.status(id)).enabled).toBe(true)
}),
expect((yield* config.get()).experimental?.sandbox).toBe(false)
expect((yield* SandboxPolicy.status(id)).enabled).toBe(true)
expect(yield* execute(id, sandboxed)).toBe(true)
expect(Exit.isFailure(yield* execute(id, assertNetwork("https://example.com").pipe(Effect.exit)))).toBe(true)
const next = SessionID.make("ses_sandbox_config_next")
expect((yield* SandboxPolicy.status(next)).enabled).toBe(false)
expect(yield* execute(next, sandboxed)).toBe(false)
}),
(password) => Effect.sync(() => (Flag.KILO_SERVER_PASSWORD = password)),
),
{ config: { experimental: { sandbox: true } } },
)
it.instance("runs unrestricted when config is off and no override exists", () =>
it.instance("keeps authless config-off sessions confined", () =>
Effect.gen(function* () {
const id = SessionID.make("ses_sandbox_default_off")
expect((yield* SandboxPolicy.status(id)).enabled).toBe(false)
expect(yield* execute(id, sandboxed)).toBe(false)
const status = yield* SandboxPolicy.status(id)
expect(status.enabled).toBe(status.available)
expect(yield* execute(id, sandboxed)).toBe(status.available)
}),
)
@@ -120,15 +224,15 @@ it.instance(
{ config: { experimental: { sandbox: true } } },
)
it.instance("overrides config off to sandbox only one session", () =>
it.instance("trusted toggles disable only one authless 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(true)
expect(yield* execute(first, sandboxed)).toBe(true)
expect(yield* execute(second, sandboxed)).toBe(false)
expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(false)
expect(yield* execute(first, sandboxed)).toBe(false)
expect(yield* execute(second, sandboxed)).toBe(true)
}),
)
@@ -142,14 +246,14 @@ it.instance("isolates concurrent session overrides and clears them", () =>
return
}
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.toggle(first)).enabled).toBe(false)
expect((yield* SandboxPolicy.status(second)).enabled).toBe(true)
expect((yield* SandboxPolicy.toggle(second)).enabled).toBe(false)
expect((yield* SandboxPolicy.status(first)).enabled).toBe(true)
yield* SandboxPolicy.clear(first)
expect((yield* SandboxPolicy.toggle(second)).enabled).toBe(true)
expect((yield* SandboxPolicy.status(first)).enabled).toBe(false)
expect((yield* SandboxPolicy.status(second)).enabled).toBe(false)
yield* SandboxPolicy.retire(first, (yield* TestInstance).directory, Effect.void)
expect((yield* SandboxPolicy.status(first)).enabled).toBe(true)
expect((yield* SandboxPolicy.status(second)).enabled).toBe(true)
}),
)
@@ -168,7 +272,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(false)
expect((yield* SandboxPolicy.status(id)).enabled).toBe(true)
}),
)
@@ -191,19 +295,32 @@ it.instance("prevents a queued toggle from restoring a retired override", () =>
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(removal)
expect(Exit.isFailure(yield* Fiber.join(pending))).toBe(true)
expect((yield* SandboxPolicy.status(id)).enabled).toBe(false)
expect((yield* SandboxPolicy.status(id)).enabled).toBe(true)
}),
)
it.instance("uses nested session state instead of inheriting a parent profile", () =>
Effect.gen(function* () {
const parent = SessionID.make("ses_sandbox_parent")
const child = SessionID.make("ses_sandbox_child")
if (!(yield* SandboxPolicy.status(parent)).available) return
yield* SandboxPolicy.toggle(parent)
expect(yield* execute(parent, execute(child, sandboxed))).toBe(false)
expect(yield* execute(child, execute(parent, sandboxed))).toBe(true)
}),
it.instance(
"inherits a parent snapshot for delegated sessions",
() =>
Effect.gen(function* () {
const parent = SessionID.make("ses_sandbox_parent")
const child = SessionID.make("ses_sandbox_child")
const status = yield* SandboxPolicy.status(parent)
if (!status.available) return
yield* SandboxPolicy.inherit(parent, child, { enabled: true, mode: "deny" })
yield* SandboxPolicy.toggle(parent)
expect((yield* SandboxPolicy.status(parent)).enabled).toBe(false)
expect((yield* SandboxPolicy.status(child)).enabled).toBe(true)
yield* SandboxPolicy.toggle(child)
yield* SandboxPolicy.toggle(parent)
expect((yield* SandboxPolicy.status(child)).enabled).toBe(false)
yield* SandboxPolicy.inherit(parent, child)
expect((yield* SandboxPolicy.status(child)).enabled).toBe(true)
expect(yield* execute(child, sandboxed)).toBe(true)
}),
{ config: { experimental: { sandbox: true } } },
)
it.instance("enforces writes only while the macOS session override is active", () =>
@@ -224,7 +341,6 @@ 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)
@@ -17,6 +17,7 @@ import { Provider } from "../../src/provider/provider"
import { Permission } from "../../src/permission"
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
import * as SandboxPolicy from "../../src/kilocode/sandbox/policy"
import { Truncate } from "../../src/tool/truncate"
import { ToolRegistry } from "../../src/tool/registry"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
@@ -310,11 +311,16 @@ describe("Kilo task nesting", () => {
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()
@@ -340,6 +346,7 @@ describe("Kilo task nesting", () => {
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()