Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/isolate-linux-network.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@kilocode/cli": minor
"kilo-code": minor
---

Enforce the sandbox network restriction for agent commands on Linux, including TCP, UDP, IPv4, IPv6, and descendant processes.
275 changes: 273 additions & 2 deletions packages/core/test/kilocode/linux-sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test"
import { spawnSync } from "node:child_process"
import { createSocket } from "node:dgram"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
Expand All @@ -9,15 +10,20 @@ import { backendSupport, run, type Profile } from "@kilocode/sandbox"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"

const linux = process.platform === "linux" ? test : test.skip
const linuxIPv6 = process.platform === "linux" && supportsIPv6() ? test : test.skip

function profile(allow: ReadonlyArray<string>, denyNames: ReadonlyArray<string> = []): Profile {
function profile(
allow: ReadonlyArray<string>,
denyNames: ReadonlyArray<string> = [],
mode: Profile["network"]["mode"] = "allow",
): Profile {
return {
filesystem: {
allowWrite: allow.map((path) => ({ path, kind: "subtree" })),
denyWrite: [],
denyNames,
},
network: { mode: "allow", allowedHosts: [] },
network: { mode, allowedHosts: [] },
environment: { deny: [], set: {} },
}
}
Expand Down Expand Up @@ -48,6 +54,81 @@ async function fixture() {
return { root, project, outside }
}

function requireNetwork() {
const support = backendSupport({ mode: "deny", allowedHosts: [] })
expect(support.available, support.reason).toBe(true)
}

function tcp(hostname = "127.0.0.1") {
let accepted = 0
const listener = Bun.listen({
hostname,
port: 0,
socket: {
open(socket) {
accepted++
socket.write("sandbox-tcp-ok")
socket.end()
},
data() {},
},
})
return { listener, accepted: () => accepted }
}

function supportsIPv6() {
if (process.platform !== "linux") return false
try {
const probe = tcp("::1")
probe.listener.stop(true)
return true
} catch {
return false
}
}

async function udp() {
let received = 0
const socket = createSocket("udp4")
socket.on("message", (_message, peer) => {
received++
socket.send("sandbox-udp-ok", peer.port, peer.address)
})
await new Promise<void>((resolve, reject) => {
socket.once("error", reject)
socket.bind(0, "127.0.0.1", () => {
socket.off("error", reject)
resolve()
})
})
const address = socket.address()
if (typeof address === "string") throw new Error("UDP server did not expose an IP address")
return { socket, port: address.port, received: () => received }
}

function tcpClient(port: number, expected: boolean, hostname = "127.0.0.1") {
return [
'const net = require("node:net")',
`const socket = net.connect({ host: ${JSON.stringify(hostname)}, port: ${port} })`,
`const expected = ${expected}`,
'socket.on("data", (data) => process.exit(expected && data.toString() === "sandbox-tcp-ok" ? 0 : 2))',
'socket.on("error", () => process.exit(expected ? 3 : 0))',
"setTimeout(() => process.exit(expected ? 4 : 0), 1000)",
].join("\n")
}

function udpClient(port: number, expected: boolean) {
return [
'const dgram = require("node:dgram")',
'const socket = dgram.createSocket("udp4")',
`const expected = ${expected}`,
'socket.on("message", (data) => process.exit(expected && data.toString() === "sandbox-udp-ok" ? 0 : 2))',
'socket.on("error", () => process.exit(expected ? 3 : 0))',
`socket.send("probe", ${port}, "127.0.0.1", (error) => { if (error) process.exit(expected ? 4 : 0) })`,
"setTimeout(() => process.exit(expected ? 5 : 0), 500)",
].join("\n")
}

linux("confines writes from spawned processes to the profile allowlist", async () => {
const support = backendSupport()
expect(support.available, support.reason).toBe(true)
Expand Down Expand Up @@ -76,6 +157,154 @@ linux("confines writes from spawned processes to the profile allowlist", async (
}
})

linux("allows host loopback TCP in network allow mode and blocks it in deny mode", async () => {
requireNetwork()
const root = await fixture()
const allowed = tcp()
const blocked = tcp()

try {
const allow = profile([root.project], [], "allow")
const deny = profile([root.project], [], "deny")
expect(Number(await Effect.runPromise(spawn(tcpClient(allowed.listener.port, true), root.project, allow)))).toBe(0)
expect(Number(await Effect.runPromise(spawn(tcpClient(blocked.listener.port, false), root.project, deny)))).toBe(0)
expect(allowed.accepted()).toBe(1)
expect(blocked.accepted()).toBe(0)
} finally {
allowed.listener.stop(true)
blocked.listener.stop(true)
await fs.rm(root.root, { recursive: true, force: true })
}
})

linux("blocks UDP datagrams in network deny mode", async () => {
requireNetwork()
const root = await fixture()
const allowed = await udp()
const blocked = await udp()

try {
const allow = profile([root.project], [], "allow")
const deny = profile([root.project], [], "deny")
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
expect(Number(await Effect.runPromise(spawn(udpClient(allowed.port, true), root.project, allow)))).toBe(0)
expect(Number(await Effect.runPromise(spawn(udpClient(blocked.port, false), root.project, deny)))).toBe(0)
expect(allowed.received()).toBe(1)
expect(blocked.received()).toBe(0)
} finally {
allowed.socket.close()
blocked.socket.close()
await fs.rm(root.root, { recursive: true, force: true })
}
})

linux("blocks localhost connections in network deny mode", async () => {
requireNetwork()
const root = await fixture()
const listener = tcp()
const deny = profile([root.project], [], "deny")

try {
expect(
Number(await Effect.runPromise(spawn(tcpClient(listener.listener.port, false, "localhost"), root.project, deny))),
).toBe(0)
expect(listener.accepted()).toBe(0)
} finally {
listener.listener.stop(true)
await fs.rm(root.root, { recursive: true, force: true })
}
})

linuxIPv6("blocks IPv6 loopback connections in network deny mode", async () => {
requireNetwork()
const root = await fixture()
const listener = tcp("::1")
const deny = profile([root.project], [], "deny")

try {
expect(
Number(await Effect.runPromise(spawn(tcpClient(listener.listener.port, false, "::1"), root.project, deny))),
).toBe(0)
expect(listener.accepted()).toBe(0)
} finally {
listener.listener.stop(true)
await fs.rm(root.root, { recursive: true, force: true })
}
})

linux("keeps loopback available between processes inside the denied network namespace", async () => {
requireNetwork()
const root = await fixture()
const child = [
'const net = require("node:net")',
"const port = Number(process.argv[1])",
'const socket = net.connect({ host: "127.0.0.1", port })',
'socket.on("connect", () => socket.write("sandbox-internal-ok"))',
'socket.on("data", (data) => process.exit(data.toString() === "sandbox-internal-ok" ? 0 : 2))',
'socket.on("error", () => process.exit(3))',
].join("\n")
const script = [
'const child = require("node:child_process")',
'const net = require("node:net")',
'const server = net.createServer((socket) => socket.on("data", (data) => socket.end(data)))',
'server.listen(0, "127.0.0.1", () => {',
` const proc = child.spawn(process.execPath, ["-e", ${JSON.stringify(child)}, String(server.address().port)])`,
' proc.on("exit", (code) => process.exit(code ?? 4))',
"})",
].join("\n")

try {
expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project], [], "deny"))))).toBe(0)
} finally {
await fs.rm(root.root, { recursive: true, force: true })
}
})

linux("keeps descendants in the denied network namespace", async () => {
requireNetwork()
const root = await fixture()
const blocked = tcp()
const child = tcpClient(blocked.listener.port, false)
const script = [
'const child = require("node:child_process")',
`const result = child.spawnSync(process.execPath, ["-e", ${JSON.stringify(child)}])`,
"process.exit(result.status ?? 3)",
].join("\n")

try {
expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project], [], "deny"))))).toBe(0)
expect(blocked.accepted()).toBe(0)
} finally {
blocked.listener.stop(true)
await fs.rm(root.root, { recursive: true, force: true })
}
})

linux("preserves filesystem confinement in network deny mode", async () => {
requireNetwork()
const root = await fixture()
const allowed = path.join(root.project, "network-deny.txt")
const sentinel = path.join(root.outside, "network-deny.txt")
await fs.writeFile(sentinel, "original")
const script = [
'const fs = require("node:fs")',
`fs.writeFileSync(${JSON.stringify(allowed)}, "allowed")`,
"try {",
` fs.writeFileSync(${JSON.stringify(sentinel)}, "escaped")`,
" process.exit(2)",
"} catch {",
" process.exit(0)",
"}",
].join("\n")

try {
expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project], [], "deny"))))).toBe(0)
expect(await fs.readFile(allowed, "utf8")).toBe("allowed")
expect(await fs.readFile(sentinel, "utf8")).toBe("original")
} finally {
await fs.rm(root.root, { recursive: true, force: true })
}
})

linux("keeps reads available when no paths are writable", async () => {
const root = await fixture()
const sentinel = path.join(root.project, "sentinel.txt")
Expand Down Expand Up @@ -448,6 +677,48 @@ linux("rejects a Bubblewrap helper inside a writable root", async () => {
}
})

linux("reports network namespace support separately and fails deny mode closed", async () => {
const root = await fixture()
const source = process.env.KILO_BWRAP_PATH ?? "/usr/bin/bwrap"
const helper = path.join(root.outside, "bwrap-no-network")
await fs.writeFile(
helper,
[
"#!/bin/sh",
'for arg in "$@"; do',
' if [ "$arg" = "--unshare-net" ]; then echo "network namespaces blocked" >&2; exit 42; fi',
"done",
`exec ${JSON.stringify(source)} "$@"`,
"",
].join("\n"),
)
await fs.chmod(helper, 0o755)
const script = [
'import { Effect } from "effect"',
'import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"',
'import { backendSupport, run } from "@kilocode/sandbox"',
'import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"',
'const allow = backendSupport({ mode: "allow", allowedHosts: [] })',
'const deny = backendSupport({ mode: "deny", allowedHosts: [] })',
"if (!allow.available) process.exit(2)",
'if (deny.available || !deny.reason?.includes("Linux network sandbox")) process.exit(3)',
'const profile = { filesystem: { allowWrite: [], denyWrite: [], denyNames: [] }, network: { mode: "deny", allowedHosts: [] }, environment: { deny: [], set: {} } }',
'const effect = Effect.scoped(run(profile, ChildProcessSpawner.ChildProcessSpawner.use((spawner) => spawner.spawn(ChildProcess.make(process.execPath, ["-e", "process.exit(0)"])))).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)))',
"try { await Effect.runPromise(effect); process.exit(4) } catch { process.exit(0) }",
].join("\n")

try {
const result = spawnSync(process.execPath, ["-e", script], {
cwd: import.meta.dir,
env: { ...process.env, KILO_BWRAP_PATH: helper },
encoding: "utf8",
})
expect(result.status, result.stderr).toBe(0)
} finally {
await fs.rm(root.root, { recursive: true, force: true })
}
})

linux("fails closed when Bubblewrap is unavailable", () => {
const script = [
'import { Effect } from "effect"',
Expand Down
8 changes: 4 additions & 4 deletions packages/kilo-sandbox/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface Support {
}

export interface Backend {
readonly support: () => Support
readonly support: (network?: Profile["network"]) => Support
readonly prepare: (
profile: Profile,
launch: Launch,
Expand Down Expand Up @@ -83,7 +83,7 @@ export function confine(profile: Profile, launch: Launch) {
return Effect.gen(function* () {
const next = { ...launch, environment: environment(profile, launch) }
yield* assertProcessNetwork(profile, launch.command)
const support = backend.support()
const support = backend.support(profile.network)
if (!support.available) return yield* Effect.fail(unsupported(launch.command, "confine", support))
return yield* backend.prepare(profile, next)
})
Expand Down Expand Up @@ -114,6 +114,6 @@ export function prepareCommand(
})
}

export function backendSupport() {
return backend.support()
export function backendSupport(network?: Profile["network"]) {
return backend.support(network)
}
Loading
Loading