mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
fix(sandbox): prevent config self-disable
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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>()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user