mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
fix(sandbox): address security review findings
This commit is contained in:
@@ -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 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user