From 202ed665275ba094cdefcaf61071721ed1075071 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 10 Jul 2026 14:12:39 +0200 Subject: [PATCH] fix(sandbox): address security review findings --- .changeset/sandbox-network-destinations.md | 2 +- .../core/test/kilocode/linux-sandbox.test.ts | 26 +++++++++++++++++++ .../getting-started/settings/sandboxing.md | 2 +- packages/kilo-sandbox/src/bubblewrap.ts | 12 ++++++--- .../src/kilo-sandbox-network-relay.ts | 23 +++++++++++----- .../src/services/cli-backend/cli-resources.ts | 10 +++---- .../src/components/settings/SandboxingTab.tsx | 1 + packages/opencode/script/build.ts | 13 +++++----- 8 files changed, 67 insertions(+), 22 deletions(-) diff --git a/.changeset/sandbox-network-destinations.md b/.changeset/sandbox-network-destinations.md index 371234d79a3..546c41213f1 100644 --- a/.changeset/sandbox-network-destinations.md +++ b/.changeset/sandbox-network-destinations.md @@ -4,4 +4,4 @@ "@kilocode/sdk": minor --- -Allow sandboxed HTTP and HTTPS proxy traffic to configured DNS hosts and ports while keeping direct outbound sockets blocked. +Support configuring network destinations that sandboxed tools can reach while network access is otherwise restricted. diff --git a/packages/core/test/kilocode/linux-sandbox.test.ts b/packages/core/test/kilocode/linux-sandbox.test.ts index 192a75985bf..3e3e5eed922 100644 --- a/packages/core/test/kilocode/linux-sandbox.test.ts +++ b/packages/core/test/kilocode/linux-sandbox.test.ts @@ -281,6 +281,32 @@ linux("allows only configured HTTP proxy destinations", async () => { } }) +linux("drops proxy setup capabilities and blocks nested user namespaces", async () => { + requireNetwork() + const root = await fixture() + const target = tcp() + const port = target.listener.port + const factory: ProxyFactory = (hosts) => + startProxy(hosts, "linux", async () => ({ address: "127.0.0.1", family: 4 })) + const policy = profile([root.project], [], "proxy", [`allowed.test:${port}`]) + const script = [ + 'const child = require("node:child_process")', + 'const fs = require("node:fs")', + 'const match = fs.readFileSync("/proc/self/status", "utf8").match(/^CapEff:\\s+([0-9a-f]+)$/m)', + "if (!match || (BigInt(`0x${match[1]}`) & (1n << 21n)) !== 0n) process.exit(2)", + 'const nested = child.spawnSync("/usr/bin/unshare", ["--user", "--map-root-user", "true"])', + "process.exit(nested.status === 0 ? 3 : 0)", + ].join("\n") + + try { + const result = await Effect.runPromise(output(process.execPath, ["-e", script], root.project, policy, factory)) + expect(Number(result.code), result.stderr).toBe(0) + } finally { + target.listener.stop(true) + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + linux("blocks arbitrary host Unix sockets in proxy mode", async () => { requireNetwork() const root = await fixture() diff --git a/packages/kilo-docs/pages/getting-started/settings/sandboxing.md b/packages/kilo-docs/pages/getting-started/settings/sandboxing.md index aea6f8a3628..72f1029bc4c 100644 --- a/packages/kilo-docs/pages/getting-started/settings/sandboxing.md +++ b/packages/kilo-docs/pages/getting-started/settings/sandboxing.md @@ -169,7 +169,7 @@ Cloud sessions do not expose the local sandbox control because their tools do no ## Platform support -On macOS and Linux, Kilo reports an error and refuses to run the affected tool if the required confinement or destination proxy cannot be established. It does not fall back to unrestricted execution. +On every platform, Kilo reports an error and refuses to run the affected tool if the configured confinement or destination proxy cannot be established. It does not fall back to unrestricted execution. | Platform | Backend | Notes | |---|---|---| diff --git a/packages/kilo-sandbox/src/bubblewrap.ts b/packages/kilo-sandbox/src/bubblewrap.ts index c07efb847f2..6c28b2e42c5 100644 --- a/packages/kilo-sandbox/src/bubblewrap.ts +++ b/packages/kilo-sandbox/src/bubblewrap.ts @@ -150,7 +150,7 @@ export function generate( if (worker) validate(allow, process.execPath, mounts) const args = [ "--unshare-user", - ...(profile.network.mode === "proxy" ? [] : ["--disable-userns"]), + "--disable-userns", "--unshare-pid", ...(profile.network.mode !== "allow" ? ["--unshare-net"] : []), ...(profile.network.mode === "proxy" ? ["--cap-add", "cap_sys_admin"] : []), @@ -285,7 +285,7 @@ function selection(): Selection { function support(network?: Profile["network"]): Support { const selected = selection() - if (!selected.support.available || network?.mode === "allow" || !selected.executable) return selected.support + if (!selected.support.available || !network || network.mode === "allow" || !selected.executable) return selected.support if (network?.mode === "proxy" && selected.proxy) return selected.proxy if (network?.mode === "deny" && selected.network) return selected.network const failure = probe(selected.executable, true) @@ -297,7 +297,13 @@ function support(network?: Profile["network"]): Support { else if (network?.mode === "proxy") { const worker = relay().path const filter = seccomp() - const missing = !existsSync(worker) ? worker : !filter || !existsSync(filter) ? filter : undefined + const missing = !existsSync(worker) + ? worker + : filter === undefined + ? "unsupported architecture" + : !existsSync(filter) + ? filter + : undefined selected.proxy = missing ? { available: false, reason: `Linux sandbox proxy dependency is unavailable: ${missing ?? "unsupported architecture"}` } : { available: true } diff --git a/packages/kilo-sandbox/src/kilo-sandbox-network-relay.ts b/packages/kilo-sandbox/src/kilo-sandbox-network-relay.ts index 28edd9c25c5..a104ad57da2 100644 --- a/packages/kilo-sandbox/src/kilo-sandbox-network-relay.ts +++ b/packages/kilo-sandbox/src/kilo-sandbox-network-relay.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process" -import { createServer, connect } from "node:net" +import { constants } from "node:os" +import { createServer, connect, type Socket } from "node:net" const split = process.argv.indexOf("--") const socket = process.argv[2] @@ -11,14 +12,27 @@ if (!socket || !seccomp || command.length === 0) { process.exit(2) } +const sockets = new Set() +const track = (socket: Socket) => { + sockets.add(socket) + socket.once("close", () => sockets.delete(socket)) + return socket +} const server = createServer((client) => { + track(client) const upstream = connect({ path: socket }) + track(upstream) client.on("error", () => upstream.destroy()) upstream.on("error", () => client.destroy()) client.pipe(upstream) upstream.pipe(client) }) +function finish(code: number) { + for (const socket of sockets) socket.destroy() + server.close(() => process.exit(code)) +} + server.listen(3128, "127.0.0.1", () => { const environment = { ...process.env } delete environment.BUN_BE_BUN @@ -27,13 +41,10 @@ server.listen(3128, "127.0.0.1", () => { for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) process.on(signal, () => forward(signal)) child.once("error", (cause) => { process.stderr.write(`${cause.message}\n`) - server.close(() => process.exit(126)) + finish(126) }) child.once("exit", (code, signal) => { - server.close(() => { - if (signal) process.kill(process.pid, signal) - process.exit(code ?? 1) - }) + finish(signal ? 128 + constants.signals[signal] : (code ?? 1)) }) }) diff --git a/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts index 0cbac2e352f..c23f56b5d89 100644 --- a/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts +++ b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts @@ -75,11 +75,11 @@ export async function copySandboxResources(source: string, target: string): Prom await fs.promises.cp(licenses, destination, { recursive: true }) for (const file of sandboxNetworkFiles) { - const source = path.join(from, file) - if (!fs.existsSync(source)) continue - const target = path.join(to, file) - await fs.promises.copyFile(source, target) - if (file === "kilo-sandbox-seccomp") await fs.promises.chmod(target, 0o755) + const input = path.join(from, file) + if (!fs.existsSync(input)) continue + const output = path.join(to, file) + await fs.promises.copyFile(input, output) + if (file === "kilo-sandbox-seccomp") await fs.promises.chmod(output, 0o755) } const runtimeLicense = path.join(from, sandboxRuntimeLicense) if (fs.existsSync(runtimeLicense)) { diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx index 5c13da6e3dc..4aa910606a1 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx @@ -16,6 +16,7 @@ const writablePathsDescription = "sandbox-writable-paths-description" function destination(input: string) { const match = /^([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)(?::(\d{1,5}))?$/.exec(input) if (!match) return + if (/^[0-9.]+$/.test(match[1]) || match[1].length > 253) return const port = Number(match[2] ?? "443") if ( port < 1 || diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 596994e9df2..5a63a645322 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -402,14 +402,15 @@ for (const item of targets) { await $`rm -rf ./dist/${name}/bin/tui` // kilocode_change start - if (bwrap) { - const licenses = path.resolve(dir, `dist/${name}/bin/licenses/bubblewrap`) + if (item.os === "linux") { const content = await Promise.all([ Bun.file(path.resolve(dir, "../../LICENSE")).text(), - Bun.file(path.join(licenses, "NOTICE")).text(), - Bun.file(path.join(licenses, "COPYING")).text(), - Bun.file(path.join(licenses, "MUSL-COPYRIGHT")).text(), Bun.file(path.resolve(dir, `dist/${name}/bin/licenses/sandbox-runtime/LICENSE`)).text(), + ...(bwrap + ? ["NOTICE", "COPYING", "MUSL-COPYRIGHT"].map((file) => + Bun.file(path.resolve(dir, `dist/${name}/bin/licenses/bubblewrap/${file}`)).text(), + ) + : []), ]) await Bun.write(`dist/${name}/LICENSE`, content.join("\n\n---\n\n")) } @@ -419,7 +420,7 @@ for (const item of targets) { { name, version: Script.version, - license: bwrap ? "SEE LICENSE IN LICENSE" : pkg.license, // kilocode_change + license: item.os === "linux" ? "SEE LICENSE IN LICENSE" : pkg.license, // kilocode_change preferUnplugged: true, os: [item.os], cpu: [item.arch],