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

Prevent sandboxed file tools from escaping project write roots through concurrent symlink replacement on macOS.
40 changes: 3 additions & 37 deletions packages/core/src/filesystem.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node"
import { assertWrite, decorateFileSystem } from "@kilocode/sandbox" // kilocode_change
import { decorateFileSystem, ensureDirectory } from "@kilocode/sandbox" // kilocode_change
import { dirname, isAbsolute, join, relative, resolve as pathResolve, sep } from "path" // kilocode_change - harden containment checks
import { realpathSync } from "fs"
import * as NFS from "fs/promises"
Expand All @@ -9,28 +9,6 @@ import type { PlatformError } from "effect/PlatformError"
import { Glob } from "./util/glob"
import { serviceUse } from "./effect/service-use"

// kilocode_change start - Windows-resilient mkdir -p.
// fs.mkdir(dir, { recursive: true }) should be idempotent, but on Windows
// with NTFS reparse points (OneDrive), directory junctions, or WSL-served
// paths, libuv can still throw EEXIST. This wrapper catches that specific
// error so callers get the promised directory-exists semantics.
//
// https://github.com/Kilo-Org/kilocode/issues/9618
// https://github.com/Kilo-Org/kilocode/issues/9755
function isEexist(err: unknown): boolean {
return typeof err === "object" && err !== null && "code" in err && (err as NodeJS.ErrnoException).code === "EEXIST"
}

async function mkdirSafe(dir: string): Promise<void> {
try {
await NFS.mkdir(dir, { recursive: true })
} catch (err: unknown) {
if (isEexist(err)) return
throw err
}
}
// kilocode_change end

export namespace AppFileSystem {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
method: Schema.String,
Expand Down Expand Up @@ -117,13 +95,7 @@ export namespace AppFileSystem {
})

const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
// kilocode_change start - enforce the active sandbox and tolerate Windows EEXIST
yield* assertWrite(path)
yield* Effect.tryPromise({
try: () => mkdirSafe(path),
catch: (cause) => new FileSystemError({ method: "ensureDir", cause }),
})
// kilocode_change end
yield* ensureDirectory(fs, path) // kilocode_change - mutate through the sandbox-confined filesystem
})

const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
Expand All @@ -138,13 +110,7 @@ export namespace AppFileSystem {
(e) => e.reason._tag === "NotFound",
() =>
Effect.gen(function* () {
// kilocode_change start - enforce the active sandbox and tolerate Windows EEXIST
yield* assertWrite(dirname(path))
yield* Effect.tryPromise({
try: () => mkdirSafe(dirname(path)),
catch: (cause) => new FileSystemError({ method: "writeWithDirs:mkdir", cause }),
})
// kilocode_change end
yield* ensureDirectory(fs, dirname(path)) // kilocode_change - sandbox-confined mkdir
yield* write
}),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,18 @@ class KiloBackendCliManager(
val platform = platform()
val exe = if (SystemInfo.isWindows) "kilo.exe" else "kilo"
val target = File(PathManager.getSystemPath(), "kilo/bin/$exe")
val worker = File(target.parentFile, "kilo-sandbox-mutation-worker.js")

if (forceExtract) {
log.info("Force re-extracting CLI resources under ${target.parentFile.absolutePath}")
if (target.exists()) target.delete()
if (worker.exists()) worker.delete()
forceExtract = false
}

extractResource("cli/$platform/$exe", target, executable = true)
if (worker.exists()) worker.delete()
Comment thread
marius-kilocode marked this conversation as resolved.
extractResource("cli/$platform/kilo-sandbox-mutation-worker.js", worker, executable = false)
return target
}

Expand Down
18 changes: 12 additions & 6 deletions packages/kilo-jetbrains/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
* 1. Builds CLI binaries (or uses prebuilt ones from dist/).
* Local: builds only current platform (--single).
* Production: builds all platforms.
* 2. Copies them into backend/build/generated/cli/cli/{os}/kilo[.exe]
* so they end up inside the backend jar at /cli/{os}/kilo.
* 2. Copies them and the Kilo sandbox mutation worker into backend/build/generated/cli/cli/{os}/
* so they end up inside the backend jar at /cli/{os}/.
* 3. Invokes Gradle to build the plugin.
*/

Expand Down Expand Up @@ -53,13 +53,17 @@ function distBinPath(os: string, exe: string): string {
return join(distDir, `@kilocode/cli-${os}`, "bin", exe)
}

function hasArtifacts(os: string, exe: string): boolean {
return existsSync(distBinPath(os, exe)) && existsSync(distBinPath(os, "kilo-sandbox-mutation-worker.js"))
}

function hasDist(): boolean {
if (production) {
return platforms.every((p) => existsSync(distBinPath(p.os, p.exe)))
return platforms.every((p) => hasArtifacts(p.os, p.exe))
}
const tag = localPlatformTag()
const local = platforms.find((p) => p.os === tag)
return local ? existsSync(distBinPath(local.os, local.exe)) : false
return local ? hasArtifacts(local.os, local.exe) : false
}

async function prepareCli() {
Expand All @@ -85,7 +89,8 @@ async function prepareCli() {
let copied = 0
for (const p of platforms) {
const src = distBinPath(p.os, p.exe)
if (!existsSync(src)) {
const worker = distBinPath(p.os, "kilo-sandbox-mutation-worker.js")
if (!existsSync(src) || !existsSync(worker)) {
missing.push(p.os)
continue
}
Expand All @@ -94,9 +99,10 @@ async function prepareCli() {
mkdirSync(dir, { recursive: true })
const dest = join(dir, p.exe)
cpSync(src, dest)
cpSync(worker, join(dir, "kilo-sandbox-mutation-worker.js"))
chmodSync(dest, 0o755)
copied++
log(`Copied ${relative(root, src)} -> ${relative(root, dest)}`)
log(`Copied ${relative(root, src)} and Kilo sandbox mutation worker -> ${relative(root, dir)}`)
}

if (copied === 0) {
Expand Down
21 changes: 15 additions & 6 deletions packages/kilo-sandbox/src/backend.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Effect, PlatformError, Scope } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { current } from "./context"
import type { Profile } from "./profile"
import { assertProcessNetwork, networkEnvironment } from "./network"
import type { Profile } from "./profile"
import { seatbelt } from "./seatbelt"

export interface Launch {
Expand Down Expand Up @@ -65,25 +65,34 @@ export function prepare(launch: Launch) {
})
}

function unsupported(command: string) {
function unsupported(command: string, method: string) {
return PlatformError.systemError({
_tag: "PermissionDenied",
module: "Sandbox",
method: "prepareCommand",
method,
pathOrDescriptor: command,
description: backend.support.reason ?? "The process sandbox backend is unavailable",
})
}

export function confine(profile: Profile, launch: Launch) {
return Effect.gen(function* () {
const next = { ...launch, environment: environment(profile, launch) }
yield* assertProcessNetwork(profile, launch.command)
if (!backend.support.available) return yield* Effect.fail(unsupported(launch.command, "confine"))
return yield* backend.prepare(profile, next)
})
}

export function prepareCommand(
command: ChildProcess.StandardCommand,
cwd: string | undefined,
env: Readonly<Record<string, string | undefined>> | undefined,
) {
return Effect.gen(function* () {
if (!(yield* current)) return command
if (!backend.support.available) return yield* Effect.fail(unsupported(command.command))
const launch = yield* prepare({
const profile = yield* current
if (!profile) return command
const launch = yield* confine(profile, {
command: command.command,
args: command.args,
cwd,
Expand Down
Loading
Loading