fix(sandbox): address security review findings

This commit is contained in:
marius-kilocode
2026-07-10 14:12:58 +02:00
parent 50aad1715c
commit 202ed66527
8 changed files with 67 additions and 22 deletions
+1 -1
View File
@@ -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.
@@ -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()
@@ -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 |
|---|---|---|
+9 -3
View File
@@ -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 }
@@ -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<Socket>()
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))
})
})
@@ -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)) {
@@ -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 ||
+7 -6
View File
@@ -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],