Skip to content
5 changes: 5 additions & 0 deletions .changeset/secure-sandbox-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Prevent confined sessions and delegated agents from weakening their sandbox policy through configuration changes or unauthenticated server control.
27 changes: 27 additions & 0 deletions packages/core/test/kilocode/linux-sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,33 @@
}
})

linux("prevents renaming an ancestor of a denied state subtree", async () => {
const root = await fixture()
const parent = path.join(root.project, "state")
const store = path.join(parent, "policy")
const sibling = path.join(parent, "sibling.txt")
const moved = path.join(root.project, "moved")
await fs.mkdir(store, { recursive: true })
const policy = denied(profile([root.project]), [
{ path: store, kind: "subtree" },
{ path: parent, kind: "literal" },
{ path: root.project, kind: "literal" },
])
const script = [
'const fs = require("node:fs")',
`fs.writeFileSync(${JSON.stringify(sibling)}, "allowed")`,
`try { fs.renameSync(${JSON.stringify(parent)}, ${JSON.stringify(moved)}); process.exit(2) } catch {}`,
].join("\n")

try {
expect(Number(await Effect.runPromise(spawn(script, root.project, policy)))).toBe(0)

Check failure on line 463 in packages/core/test/kilocode/linux-sandbox.test.ts

View workflow job for this annotation

GitHub Actions / unit (linux)

error: expect(received).toBe(expected)

Expected: 0 Received: 2 at <anonymous> (/home/runner/_work/kilocode/kilocode/packages/core/test/kilocode/linux-sandbox.test.ts:463:82)
expect(await fs.readFile(sibling, "utf8")).toBe("allowed")
expect((await fs.stat(store)).isDirectory()).toBe(true)
} finally {
await fs.rm(root.root, { recursive: true, force: true })
}
})

linux("supports writable literal files without opening writable siblings", async () => {
const root = await fixture()
const allowed = path.join(root.project, "allowed.txt")
Expand Down
6 changes: 5 additions & 1 deletion packages/kilo-sandbox/src/bubblewrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ function scan(root: string, names: ReadonlySet<string>, found: Set<string>) {
}

function protectedPaths(profile: Profile, allow: ReadonlyArray<PathRule>) {
const found = new Set(profile.filesystem.denyWrite.filter((rule) => existsSync(rule.path)).map((rule) => rule.path))
const found = new Set(
profile.filesystem.denyWrite
.filter((rule) => existsSync(rule.path) && (rule.kind === "subtree" || !statSync(rule.path).isDirectory()))
.map((rule) => rule.path),
)
if (profile.filesystem.denyNames.length === 0) return [...found]

const names = new Set(profile.filesystem.denyNames)
Expand Down
27 changes: 27 additions & 0 deletions packages/kilo-sandbox/test/backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,33 @@ describe("sandbox launch preparation", () => {
}
})

test("does not make literal deny ancestors recursively read-only on Linux", () => {
const root = mkdtempSync(path.join(os.tmpdir(), "kilo-bubblewrap-literal-"))
const parent = path.join(root, "state")
const store = path.join(parent, "policy")
mkdirSync(store, { recursive: true })
const profile: Profile = {
...makeProfile("allow"),
filesystem: {
allowWrite: [{ path: root, kind: "subtree" }],
denyWrite: [
{ path: parent, kind: "literal" },
{ path: store, kind: "subtree" },
],
denyNames: [],
},
}

try {
const result = generateBubblewrap(profile, { ...launch, cwd: root }, "/opt/kilo/bwrap")
const protectedPaths = result.args.flatMap((arg, index) => (arg === "--ro-bind" ? [result.args[index + 1]] : []))
expect(protectedPaths).toContain(store)
expect(protectedPaths).not.toContain(parent)
} finally {
rmSync(root, { recursive: true, force: true })
}
})

test("isolates the Linux network namespace in deny mode", () => {
const root = mkdtempSync(path.join(os.tmpdir(), "kilo-bubblewrap-network-"))
const input = makeProfile("deny")
Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/src/config/variable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from "path"
import os from "os"
import { Filesystem } from "@/util/filesystem"
import { InvalidError } from "./error"
import { ConfigVariableGuard } from "@/kilocode/config/variable" // kilocode_change

type ParseSource =
| {
Expand Down Expand Up @@ -36,6 +37,7 @@ export async function substitute(input: SubstituteInput) {
const missing = input.missing ?? "error"
const escape = input.escapeJson ?? true // kilocode_change
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
if (!ConfigVariableGuard.env(varName)) return "" // kilocode_change
return (input.env?.[varName] ?? process.env[varName]) || ""
})

Expand Down Expand Up @@ -66,8 +68,9 @@ export async function substitute(input: SubstituteInput) {
}

const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
// kilocode_change start - validate and read one opened file to prevent credential substitution races
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
await ConfigVariableGuard.read(resolvedPath, Filesystem.readText).catch((error: NodeJS.ErrnoException) => {
if (missing === "empty") return ""

const errMsg = `bad file reference: "${token}"`
Expand All @@ -83,6 +86,7 @@ export async function substitute(input: SubstituteInput) {
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
})
).trim()
// kilocode_change end

out += escape ? JSON.stringify(fileContent).slice(1, -1) : fileContent // kilocode_change
cursor = index + token.length
Expand Down
23 changes: 23 additions & 0 deletions packages/opencode/src/kilocode/config/variable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import fs from "node:fs/promises"
import { realpathSync } from "node:fs"

export namespace ConfigVariableGuard {
const secret = new Set(["KILO_SERVER_PASSWORD", "KILO_SERVER_USERNAME"])

export function env(name: string) {
return !secret.has(name.toUpperCase())
}

export async function read(path: string, load: (path: string) => Promise<string>) {
if (process.platform !== "linux") return load(path)
const file = await fs.open(path, "r")
try {
const target = `/proc/self/fd/${file.fd}`
const resolved = realpathSync.native(target)
if (/^\/proc\/.*\/environ$/.test(resolved)) throw new Error("blocked process environment reference")
return await load(target)
} finally {
await file.close()
}
}
}
Loading
Loading