Merge pull request #12049 from Kilo-Org/configure-network-sandbox-settings

feat: promote sandbox configuration
This commit is contained in:
Marius
2026-07-09 14:10:03 +02:00
committed by GitHub
57 changed files with 551 additions and 272 deletions
+3 -16
View File
@@ -54,6 +54,7 @@ import { primaryPaths } from "../kilocode/primary-worktree"
import { Git } from "@/git"
import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins"
import { KilocodeGlobalConfigStamp } from "@/kilocode/config/global-stamp"
import { SandboxConfig } from "@/kilocode/sandbox/config"
import {
IndexingConfig as KiloIndexingConfig,
IndexingSchema as KiloIndexingSchema,
@@ -250,6 +251,7 @@ export const Info = Schema.Struct({
hide_prompt_training_models: Schema.optional(Schema.Boolean).annotate({
description: "Hide Kilo Gateway models that may train on your prompts from model listings",
}),
sandbox: Schema.optional(SandboxConfig.Info),
model: Schema.optional(Schema.NullOr(ConfigModelID)).annotate({
description: "Model to use in the format of provider/model, eg anthropic/claude-2",
}),
@@ -416,18 +418,6 @@ export const Info = Schema.Struct({
description: "Continue the agent loop when a tool call is denied",
}),
// kilocode_change start
sandbox: Schema.optional(Schema.Boolean).annotate({
description:
"Run agent tools inside a sandbox that restricts writes to project and Kilo state directories and can restrict outbound network access",
}),
sandbox_restrict_network: Schema.optional(Schema.Boolean).annotate({
description:
"Restrict outbound network access for model-originated commands and first-party HTTP tools; local MCP servers and plugin hooks are not covered (default: true)",
}),
sandbox_writable_paths: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description:
"Additional filesystem paths the sandbox allows writes to (e.g. ['/tmp', '/var/log']). These are merged with the default writable paths when the sandbox is active.",
}),
swe_pruner: Schema.optional(Schema.Boolean).annotate({
description:
"Enable SWE-Pruner: task-aware pruning of large read, grep, and bash tool outputs guided by a focus question provided by the agent (default: false)",
@@ -785,10 +775,7 @@ export const layer = Layer.effect(
// kilocode_change start
const merge = Effect.fnUntraced(function* (source: string, next: Info, kind?: ConfigPlugin.Scope) {
const scope = kind ?? (yield* pluginScopeForSource(source))
// sandbox_writable_paths is security-sensitive — only global config may set it.
// A project kilo.json must not widen the sandbox beyond the user's intent.
if (scope === "local") delete next.experimental?.sandbox_writable_paths
const scoped = KilocodeConfig.scopeIndexing(next, scope)
const scoped = KilocodeConfig.scopeIndexing(SandboxConfig.scope(next, scope), scope)
result = mergeConfigConcatArrays(result, scoped)
return yield* mergePluginOrigins(source, scoped.plugin, scope)
})
@@ -39,7 +39,7 @@ function View(props: {
}) {
createEffect(
on(
() => props.api.state.config.experimental?.sandbox,
() => props.api.state.config.sandbox?.enabled,
() => void props.load(props.sessionID, true),
),
)
@@ -0,0 +1,40 @@
import { Schema } from "effect"
export namespace SandboxConfig {
export const Network = Schema.Literals(["allow", "deny"])
export type Network = Schema.Schema.Type<typeof Network>
export const Info = Schema.Struct({
enabled: Schema.optional(
Schema.Boolean.annotate({ description: "Enable sandbox confinement for new sessions (default: false)" }),
),
network: Schema.optional(
Network.annotate({ description: "Control outbound network access from sandboxed tools (default: deny)" }),
),
writable_paths: Schema.optional(
Schema.mutable(Schema.Array(Schema.String)).annotate({
description: "Additional filesystem paths that sandboxed tools may write to",
}),
),
}).annotate({ description: "Sandbox configuration for agent tools" })
export type Info = Schema.Schema.Type<typeof Info>
export function resolve(config: { sandbox?: Info }) {
return {
enabled: config.sandbox?.enabled ?? false,
mode: config.sandbox?.network ?? "deny",
}
}
export function scope<T extends { sandbox?: Info }>(config: T, source: "global" | "local"): T {
if (source === "global" || config.sandbox === undefined) return config
const scoped = { ...config }
const sandbox: Info = {
...(config.sandbox.enabled === true ? { enabled: true } : {}),
...(config.sandbox.network === "deny" ? { network: "deny" as const } : {}),
}
if (Object.keys(sandbox).length > 0) scoped.sandbox = sandbox
else delete scoped.sandbox
return scoped
}
}
@@ -13,6 +13,7 @@ import { Changed } from "./event"
import * as Network from "./network"
import { SandboxPreference } from "./preference"
import * as SandboxState from "./state"
import { SandboxConfig } from "./config"
import { SandboxStore } from "./store"
export type Snapshot = SandboxStore.Snapshot
@@ -39,8 +40,8 @@ const resolveInitial = Effect.fn("SandboxPolicy.resolveInitial")(function* (dire
const cfg = yield* (yield* Config.Service).get()
const chosen = yield* SandboxState.read(sessionID)
const pref = yield* Effect.promise(() => SandboxPreference.read(directory))
const mode = cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny"
return initial(chosen?.enabled, pref, cfg.experimental?.sandbox ?? false, mode)
const fallback = SandboxConfig.resolve(cfg)
return initial(chosen?.enabled, pref, fallback.enabled, fallback.mode)
})
function locked<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
@@ -170,10 +171,13 @@ const snapshot = Effect.fn("SandboxPolicy.snapshot")(function* (sessionID: Sessi
export const configuredSupport = Effect.fn("SandboxPolicy.configuredSupport")(function* () {
const cfg = yield* (yield* Config.Service).get()
const mode = cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny"
return backendSupport({ mode, allowedHosts: [] })
return backendSupport({ mode: SandboxConfig.resolve(cfg).mode, allowedHosts: [] })
})
export function fallback(config: Config.Info) {
return SandboxConfig.resolve(config)
}
export const status = Effect.fn("SandboxPolicy.status")(function* (sessionID: SessionID) {
const current = yield* snapshot(sessionID)
const support = backendSupport({ mode: current.state.mode, allowedHosts: [] })
@@ -304,7 +308,7 @@ function execute<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>)
const support = backendSupport({ mode: current.state.mode, allowedHosts: [] })
if (!current.state.enabled || !support.available) return yield* unrestricted(effect)
const cfg = yield* (yield* Config.Service).get()
const raw = cfg.experimental?.sandbox_writable_paths
const raw = cfg.sandbox?.writable_paths
const extraWritable = raw?.map((p) => (p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p))
return yield* runSandbox(profile(yield* InstanceState.context, current.state.mode, extraWritable), effect)
})
+1 -2
View File
@@ -178,8 +178,7 @@ 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 }
const fallback = SandboxPolicy.fallback(cfg)
if (session) {
yield* SandboxPolicy.inherit(ctx.sessionID, session.id, fallback)
const permission = KiloTask.merge(
@@ -242,8 +242,8 @@ describe("kilocode indexing config", () => {
})
})
describe("kilocode sandbox writable paths config", () => {
test("honors sandbox_writable_paths from global config only, ignoring project config", async () => {
describe("kilocode sandbox config", () => {
test("prevents project config from weakening sandbox policy", async () => {
await using globalTmp = await tmpdir()
await using tmp = await tmpdir({ git: true })
@@ -255,18 +255,48 @@ describe("kilocode sandbox writable paths config", () => {
try {
await writeConfig(globalTmp.path, {
$schema: "https://app.kilo.ai/config.json",
experimental: { sandbox_writable_paths: ["/tmp/global"] },
sandbox: { enabled: true, network: "deny", writable_paths: ["/tmp/global"] },
})
// A project kilo.json must not widen the sandbox: its writable paths are dropped at merge time.
await writeConfig(tmp.path, {
experimental: { sandbox_writable_paths: ["/tmp/project"] },
sandbox: { enabled: false, network: "allow", writable_paths: ["/tmp/project"] },
})
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.experimental?.sandbox_writable_paths).toEqual(["/tmp/global"])
expect(config.sandbox).toEqual({ enabled: true, network: "deny", writable_paths: ["/tmp/global"] })
},
})
} finally {
;(Global.Path as { config: string }).config = prev
await clear()
await disposeAllInstances()
}
})
test("allows project config to strengthen sandbox policy", async () => {
await using globalTmp = await tmpdir()
await using tmp = await tmpdir({ git: true })
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
await clear()
await disposeAllInstances()
try {
await writeConfig(globalTmp.path, {
sandbox: { enabled: false, network: "allow", writable_paths: ["/tmp/global"] },
})
await writeConfig(tmp.path, {
sandbox: { enabled: true, network: "deny", writable_paths: ["/tmp/project"] },
})
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.sandbox).toEqual({ enabled: true, network: "deny", writable_paths: ["/tmp/global"] })
},
})
} finally {
@@ -29,10 +29,7 @@ function layer(restrict?: boolean) {
TestConfig.layer({
get: () =>
Effect.succeed({
experimental: {
sandbox: true,
sandbox_restrict_network: restrict,
},
sandbox: { enabled: true, network: restrict === false ? "allow" : "deny" },
}),
}),
)
@@ -3,14 +3,11 @@ import type { Config as ConfigV1 } from "@kilocode/sdk"
import type { Config as ConfigV2 } from "@kilocode/sdk/v2"
const value = {
experimental: {
sandbox: true,
sandbox_restrict_network: false,
},
sandbox: { enabled: true, network: "allow" as const, writable_paths: ["/tmp/output"] },
}
test("both public SDK Config types expose sandbox policy fields", () => {
const legacy = value satisfies ConfigV1
const current = value satisfies ConfigV2
expect(legacy.experimental).toEqual(current.experimental)
expect(legacy.sandbox).toEqual(current.sandbox)
})
@@ -89,7 +89,7 @@ function context(directory: string, main: string, sandboxes: string[]): Instance
}
const config = TestConfig.layer({
get: () => Effect.succeed({ experimental: { sandbox: true } }),
get: () => Effect.succeed({ sandbox: { enabled: true } }),
})
const agents = Layer.mock(Agent.Service)({
get: () => Effect.succeed(agent),
@@ -32,7 +32,7 @@ describe("sandbox session cleanup", () => {
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 dir = yield* tmpdirScoped({ git: true, config: { sandbox: { enabled: true } } })
const source = yield* provideInstance(dir)(sessions.create({ title: "sandbox-source" }))
const status = yield* provideInstance(dir)(SandboxPolicy.status(source.id))
if (!status.available) return
@@ -49,7 +49,7 @@ describe("sandbox session cleanup", () => {
it.live("forks into another directory carry the source confinement", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const dir = yield* tmpdirScoped({ git: true, config: { experimental: { sandbox: true } } })
const dir = yield* tmpdirScoped({ git: true, config: { sandbox: { enabled: true } } })
const worktree = yield* tmpdirScoped({ git: true })
const source = yield* provideInstance(dir)(sessions.create({ title: "sandbox-source" }))
const status = yield* provideInstance(dir)(SandboxPolicy.status(source.id))
@@ -68,7 +68,7 @@ describe("sandbox session cleanup", () => {
Effect.gen(function* () {
const sessions = yield* Session.Service
// Config default is disabled; the create-time toggle asks for enabled.
const dir = yield* tmpdirScoped({ git: true, config: { experimental: { sandbox: false } } })
const dir = yield* tmpdirScoped({ git: true, config: { sandbox: { enabled: false } } })
const session = yield* provideInstance(dir)(
sessions.create({ title: "sandbox-explicit", metadata: { "kilocode.sandbox": { enabled: true, version: 0 } } }),
)
@@ -31,10 +31,7 @@ function configured(restrict: boolean) {
TestConfig.layer({
get: () =>
Effect.succeed({
experimental: {
sandbox: true,
sandbox_restrict_network: restrict,
},
sandbox: { enabled: true, network: restrict ? "deny" : "allow" },
}),
}),
)
@@ -6,7 +6,7 @@ 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 { Flag } from "@opencode-ai/core/flag/flag"
import { assertNetwork, enabled as sandboxed } from "@kilocode/sandbox"
import { assertNetwork, assertWrite, enabled as sandboxed } from "@kilocode/sandbox"
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import * as Network from "@/kilocode/sandbox/network"
@@ -69,9 +69,9 @@ test("restores the session snapshot after a backend restart", async () => {
}
try {
const initial = run({ experimental: { sandbox: true, sandbox_restrict_network: true } })
const initial = run({ sandbox: { enabled: true, network: "deny" } })
expect(initial.state).toEqual({ enabled: true, mode: "deny", version: 0 })
const restored = run({ experimental: { sandbox: false, sandbox_restrict_network: false } })
const restored = run({ sandbox: { enabled: false, network: "allow" } })
expect(restored.state).toEqual(initial.state)
expect(restored.status.enabled).toBe(restored.status.available)
} finally {
@@ -127,7 +127,7 @@ linux("reports configured network namespace availability", async () => {
'import { SessionID } from "@/session/schema"',
"const directory = process.cwd()",
'const context = { directory, worktree: directory, project: { id: "sandbox-status", worktree: directory, vcs: "git", time: { created: 0, updated: 0 }, sandboxes: [] } }',
"const status = (restrict) => SandboxPolicy.status(SessionID.make(`ses_sandbox_status_${restrict}`)).pipe(Effect.provide(Layer.mock(Config.Service, { get: () => Effect.succeed({ experimental: { sandbox: true, sandbox_restrict_network: restrict } }) })), Effect.provideService(InstanceRef, context), Effect.runPromise)",
"const status = (restrict) => SandboxPolicy.status(SessionID.make(`ses_sandbox_status_${restrict}`)).pipe(Effect.provide(Layer.mock(Config.Service, { get: () => Effect.succeed({ sandbox: { enabled: true, network: restrict ? 'deny' : 'allow' } }) })), Effect.provideService(InstanceRef, context), Effect.runPromise)",
"const deny = await status(true)",
"const allow = await status(false)",
'if (deny.available || deny.enabled || !deny.reason?.includes("Linux network sandbox")) process.exit(2)',
@@ -161,10 +161,8 @@ it.instance("snapshots the primary kilo config for the session lifetime", () =>
const file = path.join(test.directory, "kilo.json")
const legacy = path.join(test.directory, "opencode.json")
const config = yield* Config.Service
yield* Effect.promise(() =>
Bun.write(file, JSON.stringify({ experimental: { sandbox: true, sandbox_restrict_network: true } })),
)
yield* config.update({ experimental: { sandbox: true, sandbox_restrict_network: true } })
yield* Effect.promise(() => Bun.write(file, JSON.stringify({ sandbox: { enabled: true, network: "deny" } })))
yield* config.update({ sandbox: { enabled: true, network: "deny" } })
const id = SessionID.make("ses_sandbox_config")
const initial = yield* SandboxPolicy.status(id)
@@ -172,12 +170,10 @@ it.instance("snapshots the primary kilo config for the session lifetime", () =>
expect(initial.version).toBe(0)
if (!initial.available) return
yield* Effect.promise(() =>
Bun.write(file, JSON.stringify({ experimental: { sandbox: false, sandbox_restrict_network: false } })),
)
yield* config.update({ experimental: { sandbox: false, sandbox_restrict_network: false } })
yield* Effect.promise(() => Bun.write(file, JSON.stringify({ sandbox: { enabled: false, network: "allow" } })))
yield* config.update({ sandbox: { enabled: false, network: "allow" } })
expect((yield* config.get()).experimental?.sandbox).toBe(false)
expect((yield* config.get()).sandbox?.enabled).toBeUndefined()
expect(yield* Effect.promise(() => Bun.file(legacy).exists())).toBe(false)
expect((yield* SandboxPolicy.status(id)).enabled).toBe(true)
expect(yield* execute(id, sandboxed)).toBe(true)
@@ -191,7 +187,7 @@ it.instance("snapshots the primary kilo config for the session lifetime", () =>
),
)
it.instance("does not enable authless sessions without the experimental sandbox flag", () =>
it.instance("does not enable authless sessions without sandbox enabled", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const password = Flag.KILO_SERVER_PASSWORD
@@ -215,6 +211,30 @@ it.instance("does not enable authless sessions without the experimental sandbox
),
)
it.instance("applies configured writable paths during tool execution", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const outside = path.join(path.dirname(test.directory), `sandbox-writable-${path.basename(test.directory)}`)
yield* Effect.promise(() => fs.mkdir(outside, { recursive: true }))
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(outside, { recursive: true, force: true })))
const id = SessionID.make("ses_sandbox_writable_config")
const result = yield* Effect.gen(function* () {
const status = yield* SandboxPolicy.status(id)
if (!status.available) return undefined
return yield* execute(id, assertWrite(path.join(outside, "allowed.txt")).pipe(Effect.exit))
}).pipe(
Effect.provide(
Layer.mock(Config.Service, {
get: () => Effect.succeed({ sandbox: { enabled: true, network: "allow", writable_paths: [outside] } }),
}),
),
)
if (result === undefined) return
expect(Exit.isSuccess(result)).toBe(true)
}),
)
it.instance(
"runs sandboxed when config is on and no override exists",
() =>
@@ -224,7 +244,7 @@ it.instance(
expect(status.enabled).toBe(status.available)
expect(yield* execute(id, sandboxed)).toBe(status.available)
}),
{ config: { experimental: { sandbox: true } } },
{ config: { sandbox: { enabled: true } } },
)
it.instance(
@@ -240,7 +260,7 @@ it.instance(
expect((yield* SandboxPolicy.status(second)).enabled).toBe(false)
expect(yield* execute(second, sandboxed)).toBe(false)
}),
{ config: { experimental: { sandbox: true } } },
{ config: { sandbox: { enabled: true } } },
)
it.instance("persists an authless toggle to later sessions", () =>
@@ -271,7 +291,7 @@ it.instance(
expect((yield* SandboxPolicy.status(third)).enabled).toBe(true)
expect(yield* execute(third, sandboxed)).toBe(true)
}),
{ config: { experimental: { sandbox: true } } },
{ config: { sandbox: { enabled: true } } },
)
it.instance("isolates concurrent session overrides and clears them", () =>
@@ -364,7 +384,7 @@ it.instance(
expect((yield* SandboxPolicy.status(child)).enabled).toBe(true)
expect(yield* execute(child, sandboxed)).toBe(true)
}),
{ config: { experimental: { sandbox: true } } },
{ config: { sandbox: { enabled: true } } },
)
it.instance("enforces writes only while the macOS session override is active", () =>
@@ -26,7 +26,7 @@ describe("sandbox TUI", () => {
expect(content).toContain("await ensureSession(api)")
expect(content).toContain("api.client.session.create")
expect(content).toContain('api.route.navigate("session", { sessionID })')
expect(content).toContain("props.api.state.config.experimental?.sandbox")
expect(content).toContain("props.api.state.config.sandbox?.enabled")
expect(content).toContain("void props.load(props.sessionID, true)")
expect(content).toContain('api.event.on("sandbox.status.changed"')
})
@@ -435,7 +435,7 @@ describe("Kilo task nesting", () => {
expect(count).toBeGreaterThan(0)
expect(resumed.permission?.filter((rule) => rule.permission === "bash")).toHaveLength(count ?? 0)
}),
{ config: { experimental: { sandbox: true } } },
{ config: { sandbox: { enabled: true } } },
),
)