diff --git a/.changeset/secure-sandbox-mutations.md b/.changeset/secure-sandbox-mutations.md new file mode 100644 index 00000000000..63eb8fdb087 --- /dev/null +++ b/.changeset/secure-sandbox-mutations.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent sandboxed file tools from escaping project write roots through concurrent symlink replacement on macOS. diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 33711002f3d..758e60e78ee 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -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" @@ -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 { - 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", { method: Schema.String, @@ -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* ( @@ -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 }), ), diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt index fffa73461d0..0cbaf9659d5 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt @@ -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() + extractResource("cli/$platform/kilo-sandbox-mutation-worker.js", worker, executable = false) return target } diff --git a/packages/kilo-jetbrains/script/build.ts b/packages/kilo-jetbrains/script/build.ts index 5652d4d6711..666040cc6cb 100644 --- a/packages/kilo-jetbrains/script/build.ts +++ b/packages/kilo-jetbrains/script/build.ts @@ -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. */ @@ -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() { @@ -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 } @@ -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) { diff --git a/packages/kilo-sandbox/src/backend.ts b/packages/kilo-sandbox/src/backend.ts index 74c8564235d..d9c316b5dd3 100644 --- a/packages/kilo-sandbox/src/backend.ts +++ b/packages/kilo-sandbox/src/backend.ts @@ -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 { @@ -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> | 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, diff --git a/packages/kilo-sandbox/src/filesystem.ts b/packages/kilo-sandbox/src/filesystem.ts index 1850b177f42..57c50f30b22 100644 --- a/packages/kilo-sandbox/src/filesystem.ts +++ b/packages/kilo-sandbox/src/filesystem.ts @@ -1,6 +1,10 @@ +import { dirname } from "node:path" import { tmpdir } from "node:os" -import { Effect, FileSystem, Layer, Sink } from "effect" +import { Effect, FileSystem, Layer, PlatformError, Scope, Sink } from "effect" import { assertEntry, assertPath, current } from "./context" +import { currentRunner } from "./mutation" +import { date, type Request } from "./mutation-protocol" +import type { Profile } from "./profile" interface TempOptions { readonly directory?: string | undefined @@ -8,20 +12,94 @@ interface TempOptions { readonly suffix?: string | undefined } -function temp( - method: string, +export function ensureDirectory(fs: FileSystem.FileSystem, path: string) { + return fs.makeDirectory(path, { recursive: true }).pipe( + Effect.catchIf( + (err) => err.reason._tag === "AlreadyExists", + () => Effect.void, + ), + ) +} + +function execute(profile: Profile, request: Request, effect: Effect.Effect) { + return Effect.gen(function* () { + yield* effect + yield* (yield* currentRunner)(profile, request) + }) +} + +function openDenied(path: string) { + return PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "open", + pathOrDescriptor: path, + description: "Writable file handles are unavailable while the sandbox is enabled", + }) +} + +function tempOptions(profile: Profile, options: TempOptions | undefined) { + const directory = options?.directory ?? profile.filesystem.temporaryDirectory ?? tmpdir() + return { directory, options: { ...options, directory } } +} + +function temporary( + method: "makeTempDirectory" | "makeTempFile", + options: TempOptions | undefined, + create: (options?: TempOptions) => Effect.Effect, +) { + return Effect.gen(function* () { + const profile = yield* current + if (!profile) return yield* create(options) + const temp = tempOptions(profile, options) + yield* assertPath(temp.directory, method) + const result = yield* (yield* currentRunner)(profile, { op: method, options: temp.options }) + if (result !== undefined) return result + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "Sandbox", + method, + pathOrDescriptor: temp.directory, + description: "Filesystem worker returned no temporary path", + }), + ) + }) +} + +function scoped( + method: "makeTempDirectory" | "makeTempFile", options: TempOptions | undefined, - create: (options?: TempOptions) => Effect.Effect, + create: (options?: TempOptions) => Effect.Effect, ) { return Effect.gen(function* () { const profile = yield* current if (!profile) return yield* create(options) - const directory = options?.directory ?? profile.filesystem.temporaryDirectory ?? tmpdir() - yield* assertPath(directory, method) - return yield* create( - options?.directory === undefined && profile.filesystem.temporaryDirectory - ? { ...options, directory: profile.filesystem.temporaryDirectory } - : options, + const temp = tempOptions(profile, options) + yield* assertPath(temp.directory, method) + const runner = yield* currentRunner + return yield* Effect.acquireRelease( + runner(profile, { op: method, options: temp.options }).pipe( + Effect.flatMap((result) => + result === undefined + ? Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "Sandbox", + method, + pathOrDescriptor: temp.directory, + description: "Filesystem worker returned no temporary path", + }), + ) + : Effect.succeed(result), + ), + ), + (path) => + runner(profile, { + op: "remove", + path: method === "makeTempFile" ? dirname(path) : path, + options: { recursive: true }, + }).pipe(Effect.orDie), ) }) } @@ -29,33 +107,135 @@ function temp( export function decorateFileSystem(fs: FileSystem.FileSystem): FileSystem.FileSystem { return FileSystem.FileSystem.of({ ...fs, - chmod: (path, mode) => assertPath(path, "chmod").pipe(Effect.andThen(fs.chmod(path, mode))), - chown: (path, uid, gid) => assertPath(path, "chown").pipe(Effect.andThen(fs.chown(path, uid, gid))), - copy: (from, to, options) => assertPath(to, "copy").pipe(Effect.andThen(fs.copy(from, to, options))), - copyFile: (from, to) => assertPath(to, "copyFile").pipe(Effect.andThen(fs.copyFile(from, to))), + chmod: (path, mode) => + Effect.gen(function* () { + const profile = yield* current + if (!profile) return yield* fs.chmod(path, mode) + return yield* execute(profile, { op: "chmod", path, mode }, assertPath(path, "chmod")) + }), + chown: (path, uid, gid) => + Effect.gen(function* () { + const profile = yield* current + if (!profile) return yield* fs.chown(path, uid, gid) + return yield* execute(profile, { op: "chown", path, uid, gid }, assertPath(path, "chown")) + }), + copy: (from, to, options) => + Effect.gen(function* () { + const profile = yield* current + if (!profile) return yield* fs.copy(from, to, options) + return yield* execute(profile, { op: "copy", from, to, options }, assertPath(to, "copy")) + }), + copyFile: (from, to) => + Effect.gen(function* () { + const profile = yield* current + if (!profile) return yield* fs.copyFile(from, to) + return yield* execute(profile, { op: "copyFile", from, to }, assertPath(to, "copyFile")) + }), link: (from, to) => - assertPath(from, "link").pipe(Effect.andThen(assertPath(to, "link")), Effect.andThen(fs.link(from, to))), + Effect.gen(function* () { + const profile = yield* current + if (!profile) return yield* fs.link(from, to) + const check = assertPath(from, "link").pipe(Effect.andThen(assertPath(to, "link"))) + return yield* execute(profile, { op: "link", from, to }, check) + }), makeDirectory: (path, options) => - assertPath(path, "makeDirectory").pipe(Effect.andThen(fs.makeDirectory(path, options))), - makeTempDirectory: (options) => temp("makeTempDirectory", options, fs.makeTempDirectory), - makeTempDirectoryScoped: (options) => temp("makeTempDirectoryScoped", options, fs.makeTempDirectoryScoped), - makeTempFile: (options) => temp("makeTempFile", options, fs.makeTempFile), - makeTempFileScoped: (options) => temp("makeTempFileScoped", options, fs.makeTempFileScoped), - open: (path, options) => { - if ((options?.flag ?? "r") === "r") return fs.open(path, options) - return assertPath(path, "open").pipe(Effect.andThen(fs.open(path, options))) - }, - remove: (path, options) => assertEntry(path, "remove").pipe(Effect.andThen(fs.remove(path, options))), + Effect.gen(function* () { + const profile = yield* current + if (!profile) return yield* fs.makeDirectory(path, options) + return yield* execute(profile, { op: "makeDirectory", path, options }, assertPath(path, "makeDirectory")) + }), + makeTempDirectory: (options) => temporary("makeTempDirectory", options, fs.makeTempDirectory), + makeTempDirectoryScoped: (options) => scoped("makeTempDirectory", options, fs.makeTempDirectoryScoped), + makeTempFile: (options) => temporary("makeTempFile", options, fs.makeTempFile), + makeTempFileScoped: (options) => scoped("makeTempFile", options, fs.makeTempFileScoped), + open: (path, options) => + Effect.gen(function* () { + const profile = yield* current + if (!profile || (options?.flag ?? "r") === "r") return yield* fs.open(path, options) + yield* assertPath(path, "open") + return yield* Effect.fail(openDenied(path)) + }), + remove: (path, options) => + Effect.gen(function* () { + const profile = yield* current + if (!profile) return yield* fs.remove(path, options) + return yield* execute(profile, { op: "remove", path, options }, assertEntry(path, "remove")) + }), rename: (from, to) => - assertEntry(from, "rename").pipe(Effect.andThen(assertEntry(to, "rename")), Effect.andThen(fs.rename(from, to))), - sink: (path, options) => Sink.unwrap(Effect.map(assertPath(path, "sink"), () => fs.sink(path, options))), - symlink: (from, to) => assertPath(to, "symlink").pipe(Effect.andThen(fs.symlink(from, to))), - truncate: (path, length) => assertPath(path, "truncate").pipe(Effect.andThen(fs.truncate(path, length))), - utimes: (path, atime, mtime) => assertPath(path, "utimes").pipe(Effect.andThen(fs.utimes(path, atime, mtime))), + Effect.gen(function* () { + const profile = yield* current + if (!profile) return yield* fs.rename(from, to) + const check = assertEntry(from, "rename").pipe(Effect.andThen(assertEntry(to, "rename"))) + return yield* execute(profile, { op: "rename", from, to }, check) + }), + sink: (path, options) => + Sink.unwrap( + Effect.gen(function* () { + const profile = yield* current + if (!profile) return fs.sink(path, options) + const runner = yield* currentRunner + const collect = Sink.foldArray( + () => [] as Uint8Array[], + () => true, + (chunks, input: ReadonlyArray) => Effect.sync(() => [...chunks, ...input]), + ) + return Sink.mapEffect(collect, (chunks) => { + const request: Request = { + op: "writeFile", + path, + data: Buffer.concat(chunks).toString("base64"), + options, + } + return assertPath(path, "sink").pipe(Effect.andThen(runner(profile, request)), Effect.asVoid) + }) + }), + ), + symlink: (from, to) => + Effect.gen(function* () { + const profile = yield* current + if (!profile) return yield* fs.symlink(from, to) + return yield* execute(profile, { op: "symlink", from, to }, assertPath(to, "symlink")) + }), + truncate: (path, length) => + Effect.gen(function* () { + const profile = yield* current + if (!profile) return yield* fs.truncate(path, length) + return yield* execute( + profile, + { op: "truncate", path, length: length === undefined ? undefined : Number(length) }, + assertPath(path, "truncate"), + ) + }), + utimes: (path, atime, mtime) => + Effect.gen(function* () { + const profile = yield* current + if (!profile) return yield* fs.utimes(path, atime, mtime) + return yield* execute( + profile, + { op: "utimes", path, atime: date(atime), mtime: date(mtime) }, + assertPath(path, "utimes"), + ) + }), writeFile: (path, data, options) => - assertPath(path, "writeFile").pipe(Effect.andThen(fs.writeFile(path, data, options))), + Effect.gen(function* () { + const profile = yield* current + if (!profile) return yield* fs.writeFile(path, data, options) + return yield* execute( + profile, + { op: "writeFile", path, data: Buffer.from(data).toString("base64"), options }, + assertPath(path, "writeFile"), + ) + }), writeFileString: (path, data, options) => - assertPath(path, "writeFileString").pipe(Effect.andThen(fs.writeFileString(path, data, options))), + Effect.gen(function* () { + const profile = yield* current + if (!profile) return yield* fs.writeFileString(path, data, options) + return yield* execute( + profile, + { op: "writeFileString", path, data, options }, + assertPath(path, "writeFileString"), + ) + }), }) } diff --git a/packages/kilo-sandbox/src/index.ts b/packages/kilo-sandbox/src/index.ts index 1484f494430..4f8649386c0 100644 --- a/packages/kilo-sandbox/src/index.ts +++ b/packages/kilo-sandbox/src/index.ts @@ -1,5 +1,7 @@ export type { Profile } from "./profile" export { assertWrite, enabled, run } from "./context" -export { decorateFileSystem } from "./filesystem" +export { decorateFileSystem, ensureDirectory } from "./filesystem" export { assertNetwork, decorateHttpClient, httpLayer as networkHttpLayer } from "./network" +export { batchMutations, mutate, withRunner, type Runner as MutationRunner } from "./mutation" +export type { Request as MutationRequest } from "./mutation-protocol" export { prepareCommand } from "./backend" diff --git a/packages/kilo-sandbox/src/kilo-sandbox-mutation-worker.ts b/packages/kilo-sandbox/src/kilo-sandbox-mutation-worker.ts new file mode 100644 index 00000000000..3771daa1002 --- /dev/null +++ b/packages/kilo-sandbox/src/kilo-sandbox-mutation-worker.ts @@ -0,0 +1,140 @@ +import { randomBytes } from "node:crypto" +import * as fs from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { isRequest, type Failure, type Operation, type Request, type Response, type Time } from "./mutation-protocol" + +function time(value: Time) { + return value.type === "date" ? new Date(value.value) : value.value +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function field(value: unknown, key: string) { + if (!isObject(value)) return undefined + return value[key] +} + +function isMutationFailure(value: unknown): value is { readonly error: Failure } { + return isObject(value) && isObject(value.error) && typeof value.error.message === "string" +} + +function failure(cause: unknown, operation?: Operation["op"]): Failure { + const error = cause instanceof Error ? cause : new Error(String(cause)) + const code = field(cause, "code") + const errno = field(cause, "errno") + const syscall = field(cause, "syscall") + const path = field(cause, "path") + const dest = field(cause, "dest") + return { + name: error.name, + message: error.message, + code: typeof code === "string" ? code : undefined, + errno: typeof errno === "number" ? errno : undefined, + syscall: typeof syscall === "string" ? syscall : undefined, + path: typeof path === "string" ? path : undefined, + dest: typeof dest === "string" ? dest : undefined, + operation, + } +} + +async function temporary(request: Extract) { + const prefix = request.options?.prefix ?? "" + const directory = request.options?.directory ? join(request.options.directory, ".") : tmpdir() + return fs.mkdtemp(prefix ? join(directory, prefix) : directory + "/") +} + +async function mutate(request: Operation): Promise { + switch (request.op) { + case "chmod": + await fs.chmod(request.path, request.mode) + return undefined + case "chown": + await fs.chown(request.path, request.uid, request.gid) + return undefined + case "copy": + await fs.cp(request.from, request.to, { + force: request.options?.overwrite ?? false, + preserveTimestamps: request.options?.preserveTimestamps ?? false, + recursive: true, + }) + return undefined + case "copyFile": + await fs.copyFile(request.from, request.to) + return undefined + case "link": + await fs.link(request.from, request.to) + return undefined + case "makeDirectory": + await fs.mkdir(request.path, { + recursive: request.options?.recursive ?? false, + mode: request.options?.mode, + }) + return undefined + case "makeTempDirectory": + return temporary(request) + case "makeTempFile": { + const directory = await temporary(request) + const name = join(directory, randomBytes(6).toString("hex") + (request.options?.suffix ?? "")) + await fs.writeFile(name, new Uint8Array(0)) + return name + } + case "remove": + await fs.rm(request.path, { + recursive: request.options?.recursive ?? false, + force: request.options?.force ?? false, + }) + return undefined + case "rename": + await fs.rename(request.from, request.to) + return undefined + case "symlink": + await fs.symlink(request.from, request.to) + return undefined + case "truncate": + await fs.truncate(request.path, request.length) + return undefined + case "utimes": + await fs.utimes(request.path, time(request.atime), time(request.mtime)) + return undefined + case "writeFile": + await fs.writeFile(request.path, Buffer.from(request.data, "base64"), request.options) + return undefined + case "writeFileString": + await fs.writeFile(request.path, request.data, request.options) + return undefined + } + throw new TypeError("Unsupported filesystem mutation") +} + +async function read() { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")) + if (!isRequest(value)) throw new TypeError("Invalid filesystem mutation request") + return value +} + +function apply(operation: Operation) { + return mutate(operation).catch((cause) => Promise.reject({ error: failure(cause, operation.op) })) +} + +async function execute(request: Request) { + if (request.op !== "batch") return apply(request) + for (const operation of request.operations) await apply(operation) + return undefined +} + +const response: Response = await read() + .then(execute) + .then( + (value) => ({ ok: true, value }), + (cause) => ({ ok: false, error: isMutationFailure(cause) ? cause.error : failure(cause) }), + ) +await new Promise((resolve, reject) => { + process.stdout.write(JSON.stringify(response), (error) => (error ? reject(error) : resolve())) +}) diff --git a/packages/kilo-sandbox/src/mutation-protocol.ts b/packages/kilo-sandbox/src/mutation-protocol.ts new file mode 100644 index 00000000000..a3cfd54b59f --- /dev/null +++ b/packages/kilo-sandbox/src/mutation-protocol.ts @@ -0,0 +1,124 @@ +import type { OpenFlag } from "effect/FileSystem" + +export interface Options { + readonly flag?: OpenFlag | undefined + readonly mode?: number | undefined + readonly recursive?: boolean | undefined + readonly force?: boolean | undefined + readonly overwrite?: boolean | undefined + readonly preserveTimestamps?: boolean | undefined + readonly directory?: string | undefined + readonly prefix?: string | undefined + readonly suffix?: string | undefined +} + +export type Time = + | { readonly type: "date"; readonly value: string } + | { readonly type: "number"; readonly value: number } + +export type Operation = + | { readonly op: "chmod"; readonly path: string; readonly mode: number } + | { readonly op: "chown"; readonly path: string; readonly uid: number; readonly gid: number } + | { readonly op: "copy"; readonly from: string; readonly to: string; readonly options?: Options | undefined } + | { readonly op: "copyFile"; readonly from: string; readonly to: string } + | { readonly op: "link"; readonly from: string; readonly to: string } + | { readonly op: "makeDirectory"; readonly path: string; readonly options?: Options | undefined } + | { readonly op: "makeTempDirectory"; readonly options?: Options | undefined } + | { readonly op: "makeTempFile"; readonly options?: Options | undefined } + | { readonly op: "remove"; readonly path: string; readonly options?: Options | undefined } + | { readonly op: "rename"; readonly from: string; readonly to: string } + | { readonly op: "symlink"; readonly from: string; readonly to: string } + | { readonly op: "truncate"; readonly path: string; readonly length?: number | undefined } + | { readonly op: "utimes"; readonly path: string; readonly atime: Time; readonly mtime: Time } + | { + readonly op: "writeFile" + readonly path: string + readonly data: string + readonly options?: Options | undefined + } + | { + readonly op: "writeFileString" + readonly path: string + readonly data: string + readonly options?: Options | undefined + } + +export type BatchOperation = Exclude +export type Request = Operation | { readonly op: "batch"; readonly operations: ReadonlyArray } + +export interface Failure { + readonly name?: string | undefined + readonly message: string + readonly code?: string | undefined + readonly errno?: number | undefined + readonly syscall?: string | undefined + readonly path?: string | undefined + readonly dest?: string | undefined + readonly operation?: Operation["op"] | undefined +} + +export type Response = + | { readonly ok: true; readonly value?: string | undefined } + | { readonly ok: false; readonly error: Failure } + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function isFailure(value: unknown): value is Failure { + return isObject(value) && typeof value.message === "string" +} + +export function isResponse(value: unknown): value is Response { + if (!isObject(value)) return false + if (value.ok === false) return isFailure(value.error) + if (value.ok !== true) return false + return value.value === undefined || typeof value.value === "string" +} + +function isOperation(value: unknown): value is Operation { + if (!isObject(value) || typeof value.op !== "string") return false + const path = typeof value.path === "string" + const from = typeof value.from === "string" + const to = typeof value.to === "string" + switch (value.op) { + case "chmod": + return path && typeof value.mode === "number" + case "chown": + return path && typeof value.uid === "number" && typeof value.gid === "number" + case "copy": + case "copyFile": + case "link": + case "rename": + case "symlink": + return from && to + case "makeDirectory": + case "remove": + case "truncate": + return path + case "makeTempDirectory": + case "makeTempFile": + return true + case "utimes": + return path && isObject(value.atime) && isObject(value.mtime) + case "writeFile": + case "writeFileString": + return path && typeof value.data === "string" + default: + return false + } +} + +function isBatchOperation(value: unknown): value is BatchOperation { + return isOperation(value) && value.op !== "makeTempDirectory" && value.op !== "makeTempFile" +} + +export function isRequest(value: unknown): value is Request { + if (!isObject(value) || typeof value.op !== "string") return false + if (value.op !== "batch") return isOperation(value) + return Array.isArray(value.operations) && value.operations.length > 0 && value.operations.every(isBatchOperation) +} + +export function date(value: Date | number): Time { + return value instanceof Date ? { type: "date", value: value.toISOString() } : { type: "number", value } +} diff --git a/packages/kilo-sandbox/src/mutation.ts b/packages/kilo-sandbox/src/mutation.ts new file mode 100644 index 00000000000..ad5b42862b2 --- /dev/null +++ b/packages/kilo-sandbox/src/mutation.ts @@ -0,0 +1,233 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process" +import { dirname, resolve } from "node:path" +import type { Writable } from "node:stream" +import { finished } from "node:stream/promises" +import { fileURLToPath } from "node:url" +import { Context, Effect, PlatformError } from "effect" +import { confine } from "./backend" +import { isResponse, type BatchOperation, type Failure, type Operation, type Request } from "./mutation-protocol" +import type { Profile } from "./profile" + +declare const KILO_SANDBOX_MUTATION_WORKER_PATH: string + +function worker() { + if (typeof KILO_SANDBOX_MUTATION_WORKER_PATH === "undefined") { + return { path: fileURLToPath(new URL("./kilo-sandbox-mutation-worker.ts", import.meta.url)), environment: {} } + } + const path = KILO_SANDBOX_MUTATION_WORKER_PATH.startsWith(".") + ? fileURLToPath(new URL(KILO_SANDBOX_MUTATION_WORKER_PATH, import.meta.url)) + : resolve(dirname(process.execPath), KILO_SANDBOX_MUTATION_WORKER_PATH) + return { path, environment: { BUN_BE_BUN: "1" } } +} + +function tag(code: string | undefined): PlatformError.SystemErrorTag { + switch (code) { + case "EEXIST": + return "AlreadyExists" + case "EBADF": + case "EISDIR": + case "ELOOP": + case "ENOTDIR": + return "BadResource" + case "EBUSY": + return "Busy" + case "EINVAL": + return "InvalidData" + case "ENOENT": + return "NotFound" + case "EACCES": + case "EPERM": + return "PermissionDenied" + case "ETIMEDOUT": + return "TimedOut" + case "EAGAIN": + return "WouldBlock" + default: + return "Unknown" + } +} + +function failure(method: string, path: string, error: Failure) { + const cause = Object.assign(new Error(error.message), { + name: error.name, + code: error.code, + errno: error.errno, + syscall: error.syscall, + path: error.path, + dest: error.dest, + }) + if (!error.code) { + return PlatformError.badArgument({ module: "FileSystem", method, description: error.message, cause }) + } + return PlatformError.systemError({ + _tag: tag(error.code), + module: "FileSystem", + method, + pathOrDescriptor: error.path ?? path, + syscall: error.syscall, + description: error.message, + cause, + }) +} + +function infrastructure(path: string, description: string, cause?: unknown) { + return PlatformError.systemError({ + _tag: "Unknown", + module: "Sandbox", + method: "mutate", + pathOrDescriptor: path, + description, + cause, + }) +} + +function target(request: Request): string { + if (request.op === "batch") return target(request.operations[0]) + if ("path" in request) return request.path + if ("to" in request) return request.to + return request.options?.directory ?? process.cwd() +} + +function output(stream: NodeJS.ReadableStream) { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + stream.on("end", () => resolve(Buffer.concat(chunks))) + stream.on("error", reject) + }) +} + +function send(stream: Writable, data: string) { + const done = finished(stream) + stream.end(data) + return done +} + +export async function settle( + input: Promise, + stdout: Promise, + stderr: Promise, + exited: Promise, + path: string, +) { + const delivery = input.then( + () => ({ ok: true as const }), + (cause: unknown) => ({ ok: false as const, cause }), + ) + const [sent, out, err, code] = await Promise.all([delivery, stdout, stderr, exited]) + if (code !== 0) { + throw infrastructure( + path, + err.toString("utf8").trim() || `Filesystem worker exited with code ${code}`, + sent.ok ? undefined : sent.cause, + ) + } + if (!sent.ok) throw sent.cause + return out +} + +async function exchange(proc: ChildProcessWithoutNullStreams, data: string, path: string) { + const exited = new Promise((resolve, reject) => { + proc.once("error", reject) + proc.once("close", resolve) + }) + return settle(send(proc.stdin, data), output(proc.stdout), output(proc.stderr), exited, path) +} + +export type Runner = ( + profile: Profile, + request: Request, +) => Effect.Effect + +export const mutate: Runner = (profile, request) => + Effect.scoped( + Effect.gen(function* () { + const child = worker() + const launch = yield* confine(profile, { + command: process.execPath, + args: [child.path], + cwd: process.cwd(), + environment: process.env, + }) + const path = target(request) + const result = yield* Effect.tryPromise({ + try: async (signal) => { + const proc = spawn(launch.command, launch.args, { + cwd: launch.cwd, + env: { ...launch.environment, ...child.environment }, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: process.platform === "win32", + }) + const abort = () => proc.kill() + signal.addEventListener("abort", abort, { once: true }) + const out = await exchange(proc, JSON.stringify(request), path).finally(() => + signal.removeEventListener("abort", abort), + ) + try { + const response: unknown = JSON.parse(out.toString("utf8")) + if (isResponse(response)) return response + throw new TypeError("Invalid filesystem worker response") + } catch (cause) { + throw infrastructure(path, "Filesystem worker returned an invalid response", cause) + } + }, + catch: (cause) => + cause instanceof PlatformError.PlatformError + ? cause + : infrastructure(path, cause instanceof Error ? cause.message : String(cause), cause), + }) + if (!result.ok) return yield* Effect.fail(failure(result.error.operation ?? request.op, path, result.error)) + return result.value + }), + ) + +const CurrentRunner = Context.Reference("@kilocode/sandbox/CurrentMutationRunner", { + defaultValue: () => mutate, +}) + +export const currentRunner: Effect.Effect = Effect.gen(function* () { + return yield* CurrentRunner +}) + +export function withRunner(runner: Runner, effect: Effect.Effect) { + return effect.pipe(Effect.provideService(CurrentRunner, runner)) +} + +function returnsValue( + request: Request, +): request is Extract { + return request.op === "makeTempDirectory" || request.op === "makeTempFile" +} + +export function batchMutations(effect: Effect.Effect) { + return Effect.gen(function* () { + const upstream = yield* currentRunner + const state: { closed: boolean; profile?: Profile; operations: BatchOperation[] } = { + closed: false, + operations: [], + } + const flush = () => + Effect.gen(function* () { + const profile = state.profile + if (!profile || state.operations.length === 0) return + const operations = state.operations.splice(0) + state.profile = undefined + yield* upstream(profile, { op: "batch", operations }) + }) + const collect: Runner = (profile, request) => + Effect.gen(function* () { + if (state.closed) return yield* upstream(profile, request) + if (state.profile && state.profile !== profile) yield* flush() + if (returnsValue(request)) { + yield* flush() + return yield* upstream(profile, request) + } + state.profile = profile + if (request.op === "batch") state.operations.push(...request.operations) + else state.operations.push(request) + return undefined + }) + const close = Effect.sync(() => (state.closed = true)).pipe(Effect.andThen(flush())) + return yield* withRunner(collect, effect).pipe(Effect.onExit(() => close)) + }) +} diff --git a/packages/kilo-sandbox/test/backend.test.ts b/packages/kilo-sandbox/test/backend.test.ts index 9646f96a044..ed0fb954fa6 100644 --- a/packages/kilo-sandbox/test/backend.test.ts +++ b/packages/kilo-sandbox/test/backend.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test" -import { Effect, Result } from "effect" -import { backendSupport, prepare, type Launch } from "../src/backend" +import { Effect, PlatformError, Result } from "effect" +import { backendSupport, confine, prepare, type Launch } from "../src/backend" import { run } from "../src/context" +import { settle } from "../src/mutation" import type { Profile } from "../src/profile" import { generate } from "../src/seatbelt" @@ -113,6 +114,38 @@ describe("sandbox launch preparation", () => { } }) + test("fails allowed-host profiles closed through explicit confinement", async () => { + const input = makeProfile("allow") + const result = await Effect.runPromise( + Effect.scoped(confine({ ...input, network: { mode: "allow", allowedHosts: ["example.com"] } }, launch)).pipe( + Effect.result, + ), + ) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(result.failure.reason._tag).toBe("BadResource") + expect(result.failure.message).toContain("proxy network mode and allowedHosts are not supported") + } + }) + + test("preserves worker stderr when the request pipe also fails", async () => { + const pipe = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }) + const cause = await settle( + Promise.reject(pipe), + Promise.resolve(Buffer.alloc(0)), + Promise.resolve(Buffer.from("useful worker failure")), + Promise.resolve(7), + "/workspace/value.txt", + ).then( + () => undefined, + (error: unknown) => error, + ) + expect(cause).toBeInstanceOf(PlatformError.PlatformError) + if (!(cause instanceof PlatformError.PlatformError)) return + expect(cause.reason.description).toBe("useful worker failure") + expect(cause.reason.cause).toBe(pipe) + }) + test("reports backend support with a reason when unavailable", () => { expect(typeof backendSupport.available).toBe("boolean") if (!backendSupport.available) expect(backendSupport.reason?.length).toBeGreaterThan(0) diff --git a/packages/kilo-sandbox/test/filesystem.test.ts b/packages/kilo-sandbox/test/filesystem.test.ts index 03881bcaa61..8b9e5ac4ac9 100644 --- a/packages/kilo-sandbox/test/filesystem.test.ts +++ b/packages/kilo-sandbox/test/filesystem.test.ts @@ -1,11 +1,13 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test" -import { lstat, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises" +import { lstat, mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" import { NodeFileSystem } from "@effect/platform-node" import { Effect, FileSystem, Layer, Scope, Stream } from "effect" import { run } from "../src/context" import { layer } from "../src/filesystem" +import { batchMutations, currentRunner, withRunner, type Runner } from "../src/mutation" +import type { Request } from "../src/mutation-protocol" import type { Profile } from "../src/profile" const live = layer.pipe(Layer.provide(NodeFileSystem.layer)) @@ -43,28 +45,133 @@ describe("sandbox FileSystem", () => { await rm(root, { recursive: true, force: true }) }) - test("guards writes with PermissionDenied and forwards mutation options", async () => { + test.skipIf(process.platform !== "darwin")( + "guards writes with PermissionDenied and forwards mutation options through Seatbelt", + async () => { + await execute( + run( + makeProfile(allowed), + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const nested = path.join(allowed, "nested", "directory") + yield* fs.makeDirectory(nested, { recursive: true, mode: 0o700 }) + const file = path.join(nested, "value.txt") + yield* fs.writeFileString(file, "first", { flag: "wx", mode: 0o600 }) + const exists = yield* fs.writeFileString(file, "second", { flag: "wx" }).pipe(Effect.flip) + expect(exists.reason._tag).toBe("AlreadyExists") + const denied = yield* fs.writeFileString(outside, "blocked").pipe(Effect.flip) + expect(denied.reason._tag).toBe("PermissionDenied") + }), + ), + ) + expect(await readFile(path.join(allowed, "nested", "directory", "value.txt"), "utf8")).toBe("first") + expect(await readFile(outside, "utf8")).toBe("outside") + }, + ) + + test("batches nested finite mutations in request order", async () => { + await mkdir(allowed, { recursive: true }) + const requests: Request[] = [] + const runner: Runner = (_profile, request) => Effect.sync(() => requests.push(request)).pipe(Effect.as(undefined)) await execute( - run( - makeProfile(allowed), - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const nested = path.join(allowed, "nested", "directory") - yield* fs.makeDirectory(nested, { recursive: true, mode: 0o700 }) - const file = path.join(nested, "value.txt") - yield* fs.writeFileString(file, "first", { flag: "wx", mode: 0o600 }) - const exists = yield* fs.writeFileString(file, "second", { flag: "wx" }).pipe(Effect.flip) - expect(exists.reason._tag).toBe("AlreadyExists") - const denied = yield* fs.writeFileString(outside, "blocked").pipe(Effect.flip) - expect(denied.reason._tag).toBe("PermissionDenied") - }), + withRunner( + runner, + run( + makeProfile(allowed), + batchMutations( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(path.join(allowed, "nested"), { recursive: true }) + yield* batchMutations(fs.writeFileString(path.join(allowed, "nested", "value.txt"), "value")) + yield* fs.chmod(path.join(allowed, "nested", "value.txt"), 0o600) + }), + ), + ), + ), + ) + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ + op: "batch", + operations: [{ op: "makeDirectory" }, { op: "writeFileString" }, { op: "chmod" }], + }) + }) + + test("flushes queued mutations before propagating a later failure", async () => { + await mkdir(allowed, { recursive: true }) + const requests: Request[] = [] + const runner: Runner = (_profile, request) => Effect.sync(() => requests.push(request)).pipe(Effect.as(undefined)) + const exit = await execute( + withRunner( + runner, + run( + makeProfile(allowed), + batchMutations( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* fs.writeFileString(path.join(allowed, "value.txt"), "value") + return yield* Effect.fail("later failure") + }), + ), + ), + ).pipe(Effect.exit), + ) + expect(exit._tag).toBe("Failure") + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ op: "batch", operations: [{ op: "writeFileString" }] }) + }) + + test("delegates runners retained after a batch closes", async () => { + const requests: Request[] = [] + const runner: Runner = (_profile, request) => Effect.sync(() => requests.push(request)).pipe(Effect.as(undefined)) + const escaped = await execute(withRunner(runner, batchMutations(currentRunner))) + await execute(escaped(makeProfile(allowed), { op: "remove", path: path.join(allowed, "value.txt") })) + expect(requests).toEqual([{ op: "remove", path: path.join(allowed, "value.txt") }]) + }) + + test("delegates retained runners while the final batch flush is in flight", async () => { + const requests: Request[] = [] + const started = Promise.withResolvers() + const release = Promise.withResolvers() + const profile = makeProfile(allowed) + const queued: Request = { op: "remove", path: path.join(allowed, "queued.txt") } + const late: Request = { op: "remove", path: path.join(allowed, "late.txt") } + const runner: Runner = (_profile, request) => + Effect.promise(() => { + requests.push(request) + if (requests.length === 1) { + started.resolve() + return release.promise.then(() => undefined) + } + return Promise.resolve(undefined) + }) + let escaped: Runner | undefined + const closing = execute( + withRunner( + runner, + batchMutations( + Effect.gen(function* () { + escaped = yield* currentRunner + yield* escaped(profile, queued) + }), + ), ), ) - expect(await readFile(path.join(allowed, "nested", "directory", "value.txt"), "utf8")).toBe("first") - expect(await readFile(outside, "utf8")).toBe("outside") + + await started.promise + if (!escaped) throw new Error("Mutation runner did not escape") + try { + await execute(escaped(profile, late)) + expect(requests).toEqual([{ op: "batch", operations: [queued] }, late]) + } finally { + release.resolve() + await closing + } }) test("allows read-only open but guards writable open and sink", async () => { + const inside = path.join(allowed, "open.txt") + await mkdir(allowed, { recursive: true }) + await writeFile(inside, "inside") await execute( run( makeProfile(allowed), @@ -73,6 +180,9 @@ describe("sandbox FileSystem", () => { yield* fs.open(outside, { flag: "r" }) const open = yield* fs.open(outside, { flag: "r+" }).pipe(Effect.flip) expect(open.reason._tag).toBe("PermissionDenied") + const restricted = yield* fs.open(inside, { flag: "r+" }).pipe(Effect.flip) + expect(restricted.reason._tag).toBe("PermissionDenied") + expect(restricted.reason.description).toContain("Writable file handles") const sink = yield* Stream.run(Stream.make(new TextEncoder().encode("blocked")), fs.sink(outside)).pipe( Effect.flip, ) @@ -82,50 +192,146 @@ describe("sandbox FileSystem", () => { ) }) - test("redirects default temporary files and directories and preserves their options", async () => { - await execute( - run( - makeProfile(allowed, allowed), - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - yield* fs.makeDirectory(allowed, { recursive: true }) - const directory = yield* fs.makeTempDirectory({ prefix: "directory-" }) - const file = yield* fs.makeTempFile({ prefix: "file-", suffix: ".txt" }) - expect(path.dirname(directory)).toBe(allowed) - expect(path.basename(directory).startsWith("directory-")).toBe(true) - expect(path.dirname(path.dirname(file))).toBe(allowed) - expect(path.basename(path.dirname(file)).startsWith("file-")).toBe(true) - expect(file.endsWith(".txt")).toBe(true) - }), - ), - ) - }) + test.skipIf(process.platform !== "darwin")( + "redirects default temporary files and directories and preserves their options", + async () => { + await execute( + run( + makeProfile(allowed, allowed), + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(allowed, { recursive: true }) + const directory = yield* fs.makeTempDirectory({ prefix: "directory-" }) + const file = yield* fs.makeTempFile({ prefix: "file-", suffix: ".txt" }) + expect(path.dirname(directory)).toBe(allowed) + expect(path.basename(directory).startsWith("directory-")).toBe(true) + expect(path.dirname(path.dirname(file))).toBe(allowed) + expect(path.basename(path.dirname(file)).startsWith("file-")).toBe(true) + expect(file.endsWith(".txt")).toBe(true) + }), + ), + ) + }, + ) - test("removes and renames allowed symlink entries without following their targets", async () => { - await mkdir(allowed, { recursive: true }) - const removed = path.join(allowed, "removed-link") - const renamed = path.join(allowed, "renamed-link") - const moved = path.join(allowed, "moved-link") - await symlink(outside, removed) - await symlink(outside, renamed) + test.skipIf(process.platform !== "darwin")( + "preserves finite mutation data, options, timestamps, and links", + async () => { + const base = path.join(allowed, "operations") + const source = path.join(base, "source.bin") + const copy = path.join(base, "copy.bin") + const tree = path.join(base, "tree") + const copied = path.join(base, "copied") + const hard = path.join(base, "hard.bin") + const symbolic = path.join(base, "symbolic.bin") + const streamed = path.join(base, "streamed.bin") + const time = new Date("2024-01-02T03:04:05.000Z") + const copiedTime = new Date("2023-02-03T04:05:06.000Z") + await execute( + run( + makeProfile(allowed), + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(tree, { recursive: true, mode: 0o700 }) + yield* fs.writeFile(source, Uint8Array.from([0, 255, 1, 254]), { flag: "wx", mode: 0o600 }) + yield* fs.chmod(source, 0o640) + yield* fs.chown(source, process.getuid!(), process.getgid!()) + yield* fs.truncate(source, 3) + yield* fs.utimes(source, time, time) + yield* fs.copyFile(source, copy) + const value = path.join(tree, "value.txt") + yield* fs.writeFileString(value, "tree") + yield* fs.utimes(value, copiedTime, copiedTime) + yield* fs.copy(tree, copied, { overwrite: true, preserveTimestamps: true }) + yield* fs.link(source, hard) + yield* fs.symlink("source.bin", symbolic) + yield* Stream.run( + Stream.make(Uint8Array.from([1, 2]), Uint8Array.from([3, 4])), + fs.sink(streamed, { flag: "wx", mode: 0o600 }), + ) + }), + ), + ) + expect([...(await readFile(source))]).toEqual([0, 255, 1]) + expect([...(await readFile(copy))]).toEqual([0, 255, 1]) + expect([...(await readFile(hard))]).toEqual([0, 255, 1]) + expect([...(await readFile(symbolic))]).toEqual([0, 255, 1]) + expect([...(await readFile(streamed))]).toEqual([1, 2, 3, 4]) + expect(await readFile(path.join(copied, "value.txt"), "utf8")).toBe("tree") + const info = await stat(source) + const copiedInfo = await stat(path.join(copied, "value.txt")) + expect(info.mode & 0o777).toBe(0o640) + expect(info.mtime.getTime()).toBe(time.getTime()) + expect(copiedInfo.mtime.getTime()).toBe(copiedTime.getTime()) + }, + ) + + test.skipIf(process.platform !== "darwin")( + "cleans up scoped temporary files and directories through Seatbelt", + async () => { + const created: string[] = [] + await execute( + run( + makeProfile(allowed, allowed), + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + created.push(yield* fs.makeTempDirectoryScoped({ prefix: "scoped-directory-" })) + created.push(yield* fs.makeTempFileScoped({ prefix: "scoped-file-", suffix: ".txt" })) + }), + ), + ) + for (const item of created) { + expect( + await lstat(item).then( + () => true, + () => false, + ), + ).toBe(false) + } + }, + ) + + test.skipIf(process.platform !== "darwin")( + "removes and renames allowed symlink entries without following their targets", + async () => { + await mkdir(allowed, { recursive: true }) + const removed = path.join(allowed, "removed-link") + const renamed = path.join(allowed, "renamed-link") + const moved = path.join(allowed, "moved-link") + await symlink(outside, removed) + await symlink(outside, renamed) + await execute( + run( + makeProfile(allowed), + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* fs.remove(removed) + yield* fs.rename(renamed, moved) + }), + ), + ) + const missing = await lstat(removed).then( + () => false, + () => true, + ) + expect(missing).toBe(true) + expect((await lstat(moved)).isSymbolicLink()).toBe(true) + expect(await readFile(outside, "utf8")).toBe("outside") + }, + ) + + test.skipIf(process.platform === "darwin")("fails closed when the OS backend is unavailable", async () => { await execute( run( makeProfile(allowed), Effect.gen(function* () { const fs = yield* FileSystem.FileSystem - yield* fs.remove(removed) - yield* fs.rename(renamed, moved) + const denied = yield* fs.writeFileString(path.join(allowed, "blocked.txt"), "blocked").pipe(Effect.flip) + expect(denied.reason._tag).toBe("PermissionDenied") }), ), ) - const missing = await lstat(removed).then( - () => false, - () => true, - ) - expect(missing).toBe(true) - expect((await lstat(moved)).isSymbolicLink()).toBe(true) - expect(await readFile(outside, "utf8")).toBe("outside") }) test("passes through mutations when no profile is active", async () => { diff --git a/packages/kilo-sandbox/test/kilo-sandbox-mutation-worker.test.ts b/packages/kilo-sandbox/test/kilo-sandbox-mutation-worker.test.ts new file mode 100644 index 00000000000..bb61520907d --- /dev/null +++ b/packages/kilo-sandbox/test/kilo-sandbox-mutation-worker.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdtemp, readFile, rm, stat } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { isResponse, type Request } from "../src/mutation-protocol" + +const roots: string[] = [] + +async function worker(request: Request) { + const entry = fileURLToPath(new URL("../src/kilo-sandbox-mutation-worker.ts", import.meta.url)) + const proc = Bun.spawn([process.execPath, entry], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }) + await proc.stdin.write(JSON.stringify(request)) + await proc.stdin.end() + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(stderr || `Filesystem worker exited ${code}`) + const response: unknown = JSON.parse(stdout) + if (!isResponse(response)) throw new Error("Filesystem worker returned an invalid response") + return response +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe("filesystem mutation worker", () => { + test("executes ordered mutation batches", async () => { + const root = await mkdtemp(path.join(tmpdir(), "kilo-mutation-worker-")) + roots.push(root) + const dir = path.join(root, "nested") + const file = path.join(dir, "value.txt") + const response = await worker({ + op: "batch", + operations: [ + { op: "makeDirectory", path: dir, options: { recursive: true } }, + { op: "writeFileString", path: file, data: "batched" }, + { op: "chmod", path: file, mode: 0o640 }, + ], + }) + + expect(response).toEqual({ ok: true }) + expect(await readFile(file, "utf8")).toBe("batched") + if (process.platform !== "win32") expect((await stat(file)).mode & 0o777).toBe(0o640) + }) + + test("serializes single-operation filesystem failures", async () => { + const root = await mkdtemp(path.join(tmpdir(), "kilo-mutation-worker-")) + roots.push(root) + const response = await worker({ op: "writeFileString", path: path.join(root, "missing", "value.txt"), data: "x" }) + + expect(response.ok).toBe(false) + if (response.ok) return + expect(response.error.message).toContain("no such file or directory") + expect(response.error.operation).toBe("writeFileString") + expect(response.error.code).toBe("ENOENT") + }) + + test("reports the failed operation and stops the remaining batch", async () => { + const root = await mkdtemp(path.join(tmpdir(), "kilo-mutation-worker-")) + roots.push(root) + const missing = path.join(root, "missing", "value.txt") + const skipped = path.join(root, "skipped.txt") + const response = await worker({ + op: "batch", + operations: [ + { op: "writeFileString", path: missing, data: "blocked" }, + { op: "writeFileString", path: skipped, data: "skipped" }, + ], + }) + + expect(response.ok).toBe(false) + if (response.ok) return + expect(response.error.operation).toBe("writeFileString") + expect(response.error.code).toBe("ENOENT") + expect(await Bun.file(skipped).exists()).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index 9547b67aaef..6669eb558f8 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -2,7 +2,13 @@ import { $ } from "bun" import { join, relative, dirname, basename } from "node:path" import { chmodSync, statSync, rmSync, readdirSync, existsSync } from "node:fs" -import { copyTreeSitterResources, hasTreeSitterResources } from "../src/services/cli-backend/cli-resources" +import { + copyKiloSandboxWorker, + copyTreeSitterResources, + hasKiloSandboxWorker, + hasTreeSitterResources, + kiloSandboxWorkerForBinary, +} from "../src/services/cli-backend/cli-resources" import { currentFfmpegTarget, ensureFfmpegForTarget } from "./ffmpeg-helper" const forceRebuild = process.argv.includes("--force") @@ -25,6 +31,7 @@ const opencodeDir = join(packagesDir, "opencode") const coreDir = join(packagesDir, "core") const gatewayDir = join(packagesDir, "kilo-gateway") const indexingDir = join(packagesDir, "kilo-indexing") +const sandboxDir = join(packagesDir, "kilo-sandbox") const targetBinDir = join(kiloVscodeDir, "bin") const binName = process.platform === "win32" ? "kilo.exe" : "kilo" @@ -41,10 +48,8 @@ async function cliSourceHash(): Promise { const coreResult = await $`git log -1 --format=%H -- .`.cwd(coreDir).quiet() const gatewayResult = await $`git log -1 --format=%H -- .`.cwd(gatewayDir).quiet() const indexingResult = await $`git log -1 --format=%H -- .`.cwd(indexingDir).quiet() - return ( - `${opencodeResult.text().trim()}-${coreResult.text().trim()}-${gatewayResult.text().trim()}-${indexingResult.text().trim()}` || - null - ) + const sandboxResult = await $`git log -1 --format=%H -- .`.cwd(sandboxDir).quiet() + return `${opencodeResult.text().trim()}-${coreResult.text().trim()}-${gatewayResult.text().trim()}-${indexingResult.text().trim()}-${sandboxResult.text().trim()}` } catch { return null } @@ -56,11 +61,13 @@ async function isDirty(): Promise { const coreResult = await $`git status --porcelain -- .`.cwd(coreDir).quiet() const gatewayResult = await $`git status --porcelain -- .`.cwd(gatewayDir).quiet() const indexingResult = await $`git status --porcelain -- .`.cwd(indexingDir).quiet() + const sandboxResult = await $`git status --porcelain -- .`.cwd(sandboxDir).quiet() return ( opencodeResult.text().trim().length > 0 || coreResult.text().trim().length > 0 || gatewayResult.text().trim().length > 0 || - indexingResult.text().trim().length > 0 + indexingResult.text().trim().length > 0 || + sandboxResult.text().trim().length > 0 ) } catch { return false @@ -98,7 +105,7 @@ async function findKiloBinaryInOpencodeDist(): Promise { const preferred = join(distDir, `@kilocode`, tag, "bin", binName) try { statSync(preferred) - if (!hasTreeSitterResources(preferred)) return null + if (!hasTreeSitterResources(preferred) || !hasKiloSandboxWorker(preferred)) return null return preferred } catch { // fall through to generic search @@ -124,7 +131,7 @@ async function findKiloBinaryInOpencodeDist(): Promise { continue } if (e.isFile() && (e.name === "kilo" || e.name === "kilo.exe") && basename(dirname(p)) === "bin") { - if (!hasTreeSitterResources(p)) continue + if (!hasTreeSitterResources(p) || !hasKiloSandboxWorker(p)) continue return p } } @@ -164,6 +171,17 @@ async function ensureBuiltBinary(): Promise { return built } +async function bundleKiloSandboxWorker() { + const result = await Bun.build({ + entrypoints: [join(sandboxDir, "src", "kilo-sandbox-mutation-worker.ts")], + target: "bun", + format: "esm", + minify: true, + }) + if (!result.success || result.outputs.length !== 1) throw new Error("Could not bundle Kilo sandbox mutation worker") + await Bun.write(kiloSandboxWorkerForBinary(targetBinPath), result.outputs[0]) +} + async function writeSourceWrapper() { if (process.platform === "win32") { throw new Error("Compiled CLI build failed and source wrapper fallback is not supported on Windows.") @@ -182,6 +200,7 @@ async function writeSourceWrapper() { ].join("\n"), ) chmodSync(targetBinPath, 0o755) + await bundleKiloSandboxWorker() await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir) const hash = await cliSourceHash() @@ -194,10 +213,10 @@ async function writeSourceWrapper() { async function main() { const targetFile = Bun.file(targetBinPath) const exists = await targetFile.exists() - const ready = exists + const ready = exists && hasTreeSitterResources(targetBinPath) && hasKiloSandboxWorker(targetBinPath) const stale = ready && !forceRebuild && (await isStale()) - const rebuild = forceRebuild || stale + const rebuild = forceRebuild || stale || !ready if (ready && !rebuild) { const st = statSync(targetBinPath) @@ -215,7 +234,7 @@ async function main() { if (exists && rebuild) { log(stale ? `CLI source has changed — rebuilding.` : `Refreshing existing CLI resources.`) rmSync(targetBinPath) - if (forceRebuild || stale) { + if (forceRebuild || stale || !ready) { removeDist() } } @@ -234,10 +253,10 @@ async function main() { await $`mkdir -p ${targetBinDir}` await $`cp ${sourceBinPath} ${targetBinPath}` await copyTreeSitterResources(sourceBinPath, targetBinPath) + await copyKiloSandboxWorker(sourceBinPath, targetBinPath) chmodSync(targetBinPath, 0o755) await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir) - // Record the CLI source version so future runs detect when a rebuild is needed const hash = await cliSourceHash() if (hash) await Bun.write(versionFile, hash + "\n") diff --git a/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts index 29b53f093da..a712200a6c4 100644 --- a/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts +++ b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts @@ -3,6 +3,7 @@ import * as path from "path" const dir = "tree-sitter" const runtime = "tree-sitter.wasm" +const kiloSandboxWorker = "kilo-sandbox-mutation-worker.js" function paths(file: string) { if (/^[a-z]:[\\/]/i.test(file) || file.includes("\\")) return path.win32 @@ -26,6 +27,15 @@ export function hasTreeSitterResources(file: string): boolean { return fs.existsSync(path.join(treeSitterDirForBinary(file), runtime)) } +export function kiloSandboxWorkerForBinary(file: string): string { + const p = paths(file) + return p.join(p.dirname(file), kiloSandboxWorker) +} + +export function hasKiloSandboxWorker(file: string): boolean { + return fs.existsSync(kiloSandboxWorkerForBinary(file)) +} + export async function copyTreeSitterResources(source: string, target: string): Promise { const from = treeSitterDirForBinary(source) const to = treeSitterDirForBinary(target) @@ -37,3 +47,10 @@ export async function copyTreeSitterResources(source: string, target: string): P await fs.promises.rm(to, { recursive: true, force: true }) await fs.promises.cp(from, to, { recursive: true }) } + +export async function copyKiloSandboxWorker(source: string, target: string): Promise { + const from = kiloSandboxWorkerForBinary(source) + const to = kiloSandboxWorkerForBinary(target) + if (!fs.existsSync(from)) throw new Error(`Kilo sandbox mutation worker not found at ${from}`) + await fs.promises.copyFile(from, to) +} diff --git a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts index 9fb6aab88f1..c3fa64cab4a 100644 --- a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts @@ -7,8 +7,10 @@ import { toErrorMessage, } from "../../src/services/cli-backend/server-manager" import { + copyKiloSandboxWorker, copyTreeSitterResources, resolveTreeSitterEnv, + kiloSandboxWorkerForBinary, treeSitterDirForBinary, treeSitterDirForExtension, } from "../../src/services/cli-backend/cli-resources" @@ -67,6 +69,7 @@ describe("cli tree-sitter resources", () => { expect(treeSitterDirForBinary(bin)).toBe(`${root}/bin/tree-sitter`) expect(treeSitterDirForExtension(root)).toBe(`${root}/bin/tree-sitter`) + expect(kiloSandboxWorkerForBinary(bin)).toBe(`${root}/bin/kilo-sandbox-mutation-worker.js`) expect(resolveTreeSitterEnv(root)).toEqual({ KILO_TREE_SITTER_WASM_DIR: `${root}/bin/tree-sitter` }) }) @@ -76,11 +79,29 @@ describe("cli tree-sitter resources", () => { expect(treeSitterDirForBinary(bin)).toBe(String.raw`${root}\bin\tree-sitter`) expect(treeSitterDirForExtension(root)).toBe(String.raw`${root}\bin\tree-sitter`) + expect(kiloSandboxWorkerForBinary(bin)).toBe(String.raw`${root}\bin\kilo-sandbox-mutation-worker.js`) expect(resolveTreeSitterEnv(root)).toEqual({ KILO_TREE_SITTER_WASM_DIR: String.raw`${root}\bin\tree-sitter`, }) }) + it("copies the Kilo sandbox worker with the packaged CLI binary", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-vscode-sandbox-worker-")) + try { + const source = path.join(root, "dist", "@kilocode", "cli-darwin-arm64", "bin", "kilo") + const target = path.join(root, "extension", "bin", "kilo") + await fs.mkdir(path.dirname(source), { recursive: true }) + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(kiloSandboxWorkerForBinary(source), "worker") + + await copyKiloSandboxWorker(source, target) + + expect(await fs.readFile(kiloSandboxWorkerForBinary(target), "utf8")).toBe("worker") + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) + it("copies runtime and language WASMs with the packaged CLI binary", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-vscode-tree-sitter-")) try { diff --git a/packages/opencode/script/build-node.ts b/packages/opencode/script/build-node.ts old mode 100755 new mode 100644 index 8c471d7bae0..53099f5b587 --- a/packages/opencode/script/build-node.ts +++ b/packages/opencode/script/build-node.ts @@ -45,7 +45,7 @@ console.log(`Loaded ${migrations.length} migrations`) await Bun.build({ target: "node", - entrypoints: ["./src/node.ts"], + entrypoints: ["./src/node.ts", "../kilo-sandbox/src/kilo-sandbox-mutation-worker.ts"], // kilocode_change outdir: "./dist/node", format: "esm", sourcemap: "linked", @@ -53,6 +53,7 @@ await Bun.build({ define: { KILO_MIGRATIONS: JSON.stringify(migrations), KILO_MODELS_DEV: generated.modelsData, + KILO_SANDBOX_MUTATION_WORKER_PATH: `'./kilo-sandbox-mutation-worker.js'`, // kilocode_change KILO_CHANNEL: `'${Script.channel}'`, }, files: { diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts old mode 100755 new mode 100644 index a3ac6301fdc..6bf6fbe061e --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -20,6 +20,7 @@ const generated = await import("./generate.ts") import { Script } from "@opencode-ai/script" import pkg from "../package.json" import { LanceDBRuntime } from "../src/kilocode/lancedb" // kilocode_change +import { KiloSandboxWorker } from "./kilocode/kilo-sandbox-worker" // kilocode_change // Load migrations from migration directories const migrationDirs = ( @@ -251,6 +252,7 @@ const targets = singleFlag await $`rm -rf dist` const kiloConsoleDist = await buildKiloConsole() // kilocode_change +const kiloSandboxWorker = await KiloSandboxWorker.bundle() // kilocode_change const binaries: Record = {} if (!skipInstall) { @@ -324,6 +326,7 @@ for (const item of targets) { KILO_WORKER_PATH: workerPath, KILO_SESSION_EXPORT_WORKER_PATH: sessionExportWorkerPath, // kilocode_change KILO_INDEXING_WORKER_PATH: indexingWorkerPath, // kilocode_change + KILO_SANDBOX_MUTATION_WORKER_PATH: JSON.stringify(KiloSandboxWorker.filename), // kilocode_change KILO_CHANNEL: `'${Script.channel}'`, KILO_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "", KILO_BUILD_KIND: Script.release ? `'release'` : `'source'`, // kilocode_change @@ -332,6 +335,7 @@ for (const item of targets) { await copyTreeSitterWasms(path.resolve(dir, `dist/${name}/bin`)) // kilocode_change await copyKiloConsole(kiloConsoleDist, path.resolve(dir, `dist/${name}/bin`)) // kilocode_change + await KiloSandboxWorker.copy(kiloSandboxWorker, path.resolve(dir, `dist/${name}/bin`)) // kilocode_change // kilocode_change start - fix Nix-specific ELF interpreter paths for Linux binaries if (item.os === "linux") { @@ -364,6 +368,8 @@ for (const item of targets) { console.log(`Running smoke test: ${binaryPath} --pure models anthropic`) await smokeModels(binaryPath) console.log("Models snapshot smoke test passed") + await KiloSandboxWorker.smoke(binaryPath) // kilocode_change + console.log("Kilo sandbox mutation worker smoke test passed") // kilocode_change } catch (e) { console.error(`Smoke test failed for ${name}:`, e) process.exit(1) diff --git a/packages/opencode/script/kilocode/kilo-sandbox-worker.ts b/packages/opencode/script/kilocode/kilo-sandbox-worker.ts new file mode 100644 index 00000000000..c82cc5b9f14 --- /dev/null +++ b/packages/opencode/script/kilocode/kilo-sandbox-worker.ts @@ -0,0 +1,69 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +export namespace KiloSandboxWorker { + export const filename = "kilo-sandbox-mutation-worker.js" + + export async function bundle() { + const result = await Bun.build({ + entrypoints: ["../kilo-sandbox/src/kilo-sandbox-mutation-worker.ts"], + target: "bun", + format: "esm", + minify: true, + }) + if (!result.success || result.outputs.length !== 1) throw new Error("Could not bundle Kilo sandbox mutation worker") + return result.outputs[0] + } + + export async function copy(worker: Blob, dir: string) { + const target = path.join(dir, filename) + await Bun.write(target, worker) + console.log(`copied Kilo sandbox mutation worker to ${target}`) + } + + export async function smoke(binary: string) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-sandbox-worker-")) + const target = path.join(root, "value.txt") + const worker = path.join(path.dirname(binary), filename) + try { + const proc = Bun.spawn([binary, worker], { + env: { ...process.env, BUN_BE_BUN: "1" }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }) + await proc.stdin.write( + JSON.stringify({ + op: "batch", + operations: [ + { op: "makeDirectory", path: root, options: { recursive: true } }, + { op: "writeFileString", path: target, data: "worker" }, + ], + }), + ) + await proc.stdin.end() + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(stderr || `Kilo sandbox mutation worker exited ${code}`) + const response: unknown = JSON.parse(stdout) + if ( + typeof response !== "object" || + response === null || + !("ok" in response) || + response.ok !== true || + (await Bun.file(target).text()) !== "worker" + ) { + throw new Error("Packaged Kilo sandbox mutation worker did not write the expected content") + } + } finally { + await fs + .rm(root, { recursive: true, force: true }) + .catch((err) => console.warn(`Failed to remove Kilo sandbox worker smoke test directory ${root}`, err)) + } + } +} diff --git a/packages/opencode/script/kilocode/test-profile.ts b/packages/opencode/script/kilocode/test-profile.ts index b169404a0cc..9851b2f4a0b 100644 --- a/packages/opencode/script/kilocode/test-profile.ts +++ b/packages/opencode/script/kilocode/test-profile.ts @@ -7,7 +7,6 @@ export namespace TestProfile { groups: { cli: [ "cli/acp/*.test.ts", - "cli/install-artifact.test.ts", "cli/run/{footer.view,run-process,scrollback.surface}.test.{ts,tsx}", "cli/serve/*.test.ts", "cli/smokes/*.test.ts", @@ -25,6 +24,8 @@ export namespace TestProfile { ], kilo: [ "kilocode/{background-process,bin-tree-sitter-env,daemon,external-directory-boundary,indexing-worker,indexing-worktree,mcp-oauth-callback,primary-worktree,snapshot-freeze-repro,snapshot-revert-move,snapshot-seed}.test.ts", + "kilocode/cli/install-artifact.test.ts", + "kilocode/sandbox/*.test.ts", "kilocode/server/{listener-runtime,worktree-list}.test.ts", "kilocode/session-export/{e2e,sequence,worker,workspace-provider}.test.ts", "kilocode/session-export/worker/{storage,zstd}.test.ts", diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index d158c645259..6dae70c1f8d 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -137,6 +137,8 @@ function copyResources(source) { fs.rmSync(target, { recursive: true, force: true }) fs.cpSync(dir, target, { recursive: true }) } + const worker = path.join(path.dirname(source), "kilo-sandbox-mutation-worker.js") + if (fs.existsSync(worker)) fs.copyFileSync(worker, path.join(__dirname, "bin", "kilo-sandbox-mutation-worker.js")) } function copyBinary(source) { diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts old mode 100755 new mode 100644 index 0d9ab24e2d5..de7d1e970e5 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -118,6 +118,7 @@ if (!Script.preview) { "", "package() {", ' install -Dm755 ./kilo "${pkgdir}/usr/lib/kilo/kilo"', // kilocode_change + ' install -Dm644 ./kilo-sandbox-mutation-worker.js "${pkgdir}/usr/lib/kilo/kilo-sandbox-mutation-worker.js"', // kilocode_change ' install -dm755 "${pkgdir}/usr/bin" "${pkgdir}/usr/lib/kilo/tree-sitter"', // kilocode_change ' cp -r ./tree-sitter/. "${pkgdir}/usr/lib/kilo/tree-sitter/"', // kilocode_change " printf '%s\\n' '#!/bin/sh' 'export KILO_TREE_SITTER_WASM_DIR=/usr/lib/kilo/tree-sitter' 'exec /usr/lib/kilo/kilo \"$@\"' > \"${pkgdir}/usr/bin/kilo\"", // kilocode_change @@ -164,7 +165,7 @@ if (!Script.preview) { ` sha256 "${macX64Sha}"`, "", " def install", - ' libexec.install "kilo", "tree-sitter"', // kilocode_change + ' libexec.install "kilo", "kilo-sandbox-mutation-worker.js", "tree-sitter"', // kilocode_change ' (bin/"kilo").write_env_script libexec/"kilo", KILO_TREE_SITTER_WASM_DIR: libexec/"tree-sitter"', // kilocode_change " end", " end", @@ -173,7 +174,7 @@ if (!Script.preview) { ` sha256 "${macArm64Sha}"`, "", " def install", - ' libexec.install "kilo", "tree-sitter"', // kilocode_change + ' libexec.install "kilo", "kilo-sandbox-mutation-worker.js", "tree-sitter"', // kilocode_change ' (bin/"kilo").write_env_script libexec/"kilo", KILO_TREE_SITTER_WASM_DIR: libexec/"tree-sitter"', // kilocode_change " end", " end", @@ -184,7 +185,7 @@ if (!Script.preview) { ` url "https://github.com/Kilo-Org/kilocode/releases/download/v${Script.version}/kilo-linux-x64.tar.gz"`, ` sha256 "${x64Sha}"`, " def install", - ' libexec.install "kilo", "tree-sitter"', // kilocode_change + ' libexec.install "kilo", "kilo-sandbox-mutation-worker.js", "tree-sitter"', // kilocode_change ' (bin/"kilo").write_env_script libexec/"kilo", KILO_TREE_SITTER_WASM_DIR: libexec/"tree-sitter"', // kilocode_change " end", " end", @@ -192,7 +193,7 @@ if (!Script.preview) { ` url "https://github.com/Kilo-Org/kilocode/releases/download/v${Script.version}/kilo-linux-arm64.tar.gz"`, ` sha256 "${arm64Sha}"`, " def install", - ' libexec.install "kilo", "tree-sitter"', // kilocode_change + ' libexec.install "kilo", "kilo-sandbox-mutation-worker.js", "tree-sitter"', // kilocode_change ' (bin/"kilo").write_env_script libexec/"kilo", KILO_TREE_SITTER_WASM_DIR: libexec/"tree-sitter"', // kilocode_change " end", " end", diff --git a/packages/opencode/src/kilocode/tool/encoded-io.ts b/packages/opencode/src/kilocode/tool/encoded-io.ts index 60514763eb1..cba6429ca0f 100644 --- a/packages/opencode/src/kilocode/tool/encoded-io.ts +++ b/packages/opencode/src/kilocode/tool/encoded-io.ts @@ -1,4 +1,6 @@ +import { dirname } from "node:path" import { Effect } from "effect" +import { batchMutations, enabled, ensureDirectory } from "@kilocode/sandbox" import type { AppFileSystem } from "@opencode-ai/core/filesystem" import * as Encoding from "../encoding" import * as Bom from "@/util/bom" @@ -19,7 +21,16 @@ export const read = (fs: AppFileSystem.Interface, path: string) => }) export const write = (fs: AppFileSystem.Interface, path: string, text: string, encoding: string = Encoding.DEFAULT) => - fs.writeWithDirs(path, Encoding.encode(text, encoding)).pipe(Effect.mapError(wrap)) + Effect.gen(function* () { + const data = Encoding.encode(text, encoding) + if (!(yield* enabled)) return yield* fs.writeWithDirs(path, data) + yield* batchMutations( + Effect.gen(function* () { + yield* ensureDirectory(fs, dirname(path)) + yield* fs.writeFile(path, data) + }), + ) + }).pipe(Effect.mapError(wrap)) export const sync = (fs: AppFileSystem.Interface, path: string, bom: boolean, encoding: string) => Effect.gen(function* () { diff --git a/packages/opencode/test/cli/install-artifact.test.ts b/packages/opencode/test/kilocode/cli/install-artifact.test.ts similarity index 94% rename from packages/opencode/test/cli/install-artifact.test.ts rename to packages/opencode/test/kilocode/cli/install-artifact.test.ts index 933ce0f7c55..fbf8b7a8933 100644 --- a/packages/opencode/test/cli/install-artifact.test.ts +++ b/packages/opencode/test/kilocode/cli/install-artifact.test.ts @@ -1,11 +1,10 @@ -// kilocode_change - new file import { describe, expect, test } from "bun:test" import { $ } from "bun" import fs from "fs/promises" import os from "os" import path from "path" -const root = path.join(import.meta.dir, "..", "..") +const root = path.join(import.meta.dir, "..", "..", "..") const wrapper = path.join(root, "bin", "kilo") const postinstall = path.join(root, "script", "postinstall.mjs") @@ -49,6 +48,7 @@ describe("npm install artifact behavior", () => { ) const binary = "#!/bin/sh\n# binary\nexit 0\n" await Bun.write(path.join(bin, "kilo"), binary) + await Bun.write(path.join(bin, "kilo-sandbox-mutation-worker.js"), "worker") await Bun.write(path.join(bin, "tree-sitter", "tree-sitter.wasm"), "wasm") await Bun.write(path.join(bin, "console", "index.html"), "console") await Bun.write(path.join(bin, "console", "assets", "app.js"), "asset") @@ -56,6 +56,7 @@ describe("npm install artifact behavior", () => { const proc = Bun.spawn([node, path.join(pkg, "postinstall.mjs")], { cwd: pkg }) expect(await proc.exited).toBe(0) expect(await Bun.file(path.join(pkg, "bin", ".kilo")).text()).toBe(binary) + expect(await Bun.file(path.join(pkg, "bin", "kilo-sandbox-mutation-worker.js")).text()).toBe("worker") expect(await Bun.file(path.join(pkg, "bin", "tree-sitter", "tree-sitter.wasm")).text()).toBe("wasm") expect(await Bun.file(path.join(pkg, "bin", "console", "index.html")).text()).toBe("console") expect(await Bun.file(path.join(pkg, "bin", "console", "assets", "app.js")).text()).toBe("asset") @@ -117,5 +118,5 @@ describe("npm install artifact behavior", () => { } finally { await fs.rm(tmp, { recursive: true, force: true }) } - }, 60_000) // kilocode_change + }, 60_000) }) diff --git a/packages/opencode/test/kilocode/sandbox/macos-confinement.test.ts b/packages/opencode/test/kilocode/sandbox/macos-confinement.test.ts new file mode 100644 index 00000000000..42fbd0e4260 --- /dev/null +++ b/packages/opencode/test/kilocode/sandbox/macos-confinement.test.ts @@ -0,0 +1,462 @@ +import { afterEach, describe, expect } from "bun:test" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import * as AppProcess from "@opencode-ai/core/process" +import { + mutate, + run as sandbox, + withRunner, + type MutationRequest, + type MutationRunner, + type Profile, +} from "@kilocode/sandbox" +import { Effect, Exit, Layer } from "effect" +import { ChildProcess } from "effect/unstable/process" +import fs from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import iconv from "iconv-lite" +import { Agent } from "@/agent/agent" +import { Bus } from "@/bus" +import { Format } from "@/format" +import { BackgroundProcess } from "@/kilocode/background-process" +import { BackgroundProcessTool } from "@/kilocode/tool/background-process" +import * as EncodedIO from "@/kilocode/tool/encoded-io" +import { Instruction } from "@/session/instruction" +import { LSP } from "@/lsp/lsp" +import { Permission } from "@/permission" +import { MessageID, SessionID } from "@/session/schema" +import { ApplyPatchTool } from "@/tool/apply_patch" +import { EditTool } from "@/tool/edit" +import type * as Tool from "@/tool/tool" +import { Truncate } from "@/tool/truncate" +import { WriteTool } from "@/tool/write" +import { disposeAllInstances, provideTmpdirInstance } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" + +const gate: { run?: (() => Promise) | undefined; requests?: MutationRequest[] | undefined } = {} +const runner: MutationRunner = (profile, request) => + Effect.gen(function* () { + gate.requests?.push(request) + if (gate.run) yield* Effect.promise(gate.run) + return yield* mutate(profile, request) + }) +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + AppFileSystem.defaultLayer, + AppProcess.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Instruction.defaultLayer, + LSP.defaultLayer, + Bus.layer, + Format.defaultLayer, + Truncate.defaultLayer, + ), +) + +const context = (ask: Tool.Context["ask"] = () => Effect.void): Tool.Context => ({ + sessionID: SessionID.make("ses_macos_sandbox"), + messageID: MessageID.make("msg_macos_sandbox"), + callID: "sandbox-call", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask, +}) + +function profile(root: string): Profile { + return { + filesystem: { + allowWrite: [{ path: root, kind: "subtree" }], + denyWrite: [], + denyNames: [".git"], + temporaryDirectory: path.join(root, ".tmp"), + }, + network: { mode: "deny", allowedHosts: [] }, + environment: { deny: [], set: {} }, + } +} + +const runWrite = (args: { filePath: string; content: string }, ctx = context()) => + Effect.gen(function* () { + const info = yield* WriteTool + return yield* (yield* info.init()).execute(args, ctx) + }) + +const runEdit = (args: { filePath: string; oldString: string; newString: string }, ctx = context()) => + Effect.gen(function* () { + const info = yield* EditTool + return yield* (yield* info.init()).execute(args, ctx) + }) + +const runPatch = (patchText: string, ctx = context()) => + Effect.gen(function* () { + const info = yield* ApplyPatchTool + return yield* (yield* info.init()).execute({ patchText }, ctx) + }) + +const runBackground = (args: { action: "start"; command: string } | { action: "restart"; id: BackgroundProcess.ID }) => + Effect.gen(function* () { + const info = yield* BackgroundProcessTool + return yield* (yield* info.init()).execute(args, context()) + }) + +const attackSource = String.raw` +import fs from "node:fs/promises" +const input = JSON.parse(await new Response(Bun.stdin.stream()).text()) +const exists = (file) => fs.access(file).then(() => true, () => false) +while (!(await exists(input.start)) && !(await exists(input.stop))) await Bun.sleep(1) +const state = { index: 0, ready: false } +while (!(await exists(input.stop))) { + const temp = input.live + ".swap-" + process.pid + await fs.rm(temp, { force: true }) + await fs.symlink(input.targets[state.index % input.targets.length], temp) + await fs.rename(temp, input.live) + state.index++ + if (!state.ready) { + await fs.writeFile(input.ready, "ready") + state.ready = true + } + await Bun.sleep(0) +} +` + +async function wait(file: string) { + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + if ( + await fs.access(file).then( + () => true, + () => false, + ) + ) + return + await Bun.sleep(1) + } + throw new Error(`Timed out waiting for ${file}`) +} + +async function attacker(root: string, live: string, targets: ReadonlyArray) { + const start = path.join(root, "race-start") + const ready = path.join(root, "race-ready") + const stop = path.join(root, "race-stop") + const proc = Bun.spawn([process.execPath, "-e", attackSource], { + env: { ...process.env, BUN_BE_BUN: "1" }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }) + await proc.stdin.write(JSON.stringify({ start, ready, stop, live, targets })) + await proc.stdin.end() + const state = { open: false } + return { + open: async () => { + if (state.open) return + state.open = true + await fs.writeFile(start, "start") + await wait(ready) + }, + close: async () => { + await fs.writeFile(stop, "stop") + await fs.writeFile(start, "start") + const code = await proc.exited + if (code !== 0) throw new Error((await new Response(proc.stderr).text()) || `Attacker exited with ${code}`) + }, + } +} + +async function outside(dir: string) { + return fs.mkdtemp(path.join(tmpdir(), `${path.basename(dir)}-outside-`)) +} + +const encoding = { + utf16: (text: string) => Buffer.concat([Buffer.from([0xff, 0xfe]), iconv.encode(text, "utf-16le")]), + windows1251: (text: string) => iconv.encode(text, "windows-1251"), + bom: (text: string) => Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(text)]), +} + +afterEach(async () => { + gate.run = undefined + gate.requests = undefined + await disposeAllInstances() +}) + +describe.skipIf(process.platform !== "darwin").serial("real macOS sandbox confinement", () => { + it.live("confines shell writes inside the workspace and denies outside and .git", () => + provideTmpdirInstance((dir) => + Effect.acquireUseRelease( + Effect.promise(async () => { + const ext = await outside(dir) + await fs.mkdir(path.join(dir, ".git"), { recursive: true }) + return ext + }), + (ext) => + Effect.gen(function* () { + const proc = yield* AppProcess.Service + const inside = path.join(dir, "inside.txt") + const escaped = path.join(ext, "outside.txt") + const git = path.join(dir, ".git", "blocked.txt") + const write = (file: string) => + sandbox( + profile(dir), + proc.run(ChildProcess.make("/bin/sh", ["-c", `printf confined > ${JSON.stringify(file)}`])), + ) + expect((yield* write(inside)).exitCode).toBe(0) + expect((yield* write(escaped)).exitCode).not.toBe(0) + expect((yield* write(git)).exitCode).not.toBe(0) + const results = yield* Effect.promise(() => + Promise.all([ + fs.readFile(inside, "utf8"), + fs.access(escaped).then( + () => true, + () => false, + ), + fs.access(git).then( + () => true, + () => false, + ), + ]), + ) + expect(results).toEqual(["confined", false, false]) + }), + (ext) => Effect.promise(() => fs.rm(ext, { recursive: true, force: true })), + ), + ), + ) + + it.live("keeps permission approval and denial independent from confinement", () => + provideTmpdirInstance((dir) => + Effect.acquireUseRelease( + Effect.promise(() => outside(dir)), + (ext) => + Effect.gen(function* () { + const target = path.join(ext, "approved.txt") + const requests: string[] = [] + const approved = context((input) => Effect.sync(() => requests.push(input.permission))) + const escaped = yield* sandbox( + profile(dir), + runWrite({ filePath: target, content: "blocked" }, approved), + ).pipe(Effect.exit) + expect(Exit.isFailure(escaped)).toBe(true) + expect(requests).toContain("external_directory") + expect(requests).toContain("edit") + expect( + yield* Effect.promise(() => + fs.access(target).then( + () => true, + () => false, + ), + ), + ).toBe(false) + + const denied = path.join(dir, "permission-denied.txt") + const rejected = context(() => Effect.die(new Permission.RejectedError())) + const result = yield* sandbox( + profile(dir), + runWrite({ filePath: denied, content: "blocked" }, rejected), + ).pipe(Effect.exit) + expect(Exit.isFailure(result)).toBe(true) + expect( + yield* Effect.promise(() => + fs.access(denied).then( + () => true, + () => false, + ), + ), + ).toBe(false) + }), + (ext) => Effect.promise(() => fs.rm(ext, { recursive: true, force: true })), + ), + ), + ) + + it.live("allows Write, Edit, and ApplyPatch within the workspace", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const write = path.join(dir, "nested", "write.txt") + const edit = path.join(dir, "edit.txt") + const patch = path.join(dir, "patch.txt") + const second = path.join(dir, "second.txt") + yield* Effect.promise(async () => { + await fs.mkdir(path.join(dir, ".tmp"), { recursive: true }) + await fs.writeFile(edit, "before\n") + await fs.writeFile(patch, "before\n") + await fs.writeFile(second, "before\n") + }) + gate.requests = [] + yield* withRunner(runner, sandbox(profile(dir), runWrite({ filePath: write, content: "written" }))) + expect(gate.requests).toHaveLength(1) + expect(gate.requests[0]).toMatchObject({ + op: "batch", + operations: [{ op: "makeDirectory" }, { op: "writeFile" }], + }) + gate.requests = [] + yield* withRunner( + runner, + sandbox(profile(dir), runEdit({ filePath: edit, oldString: "before", newString: "edited" })), + ) + expect(gate.requests).toHaveLength(1) + expect(gate.requests[0]).toMatchObject({ + op: "batch", + operations: [{ op: "makeDirectory" }, { op: "writeFile" }], + }) + gate.requests = [] + yield* withRunner( + runner, + sandbox( + profile(dir), + runPatch( + "*** Begin Patch\n*** Update File: patch.txt\n@@\n-before\n+patched\n*** Update File: second.txt\n@@\n-before\n+second\n*** End Patch", + ), + ), + ) + const content = yield* Effect.promise(() => + Promise.all([ + fs.readFile(write, "utf8"), + fs.readFile(edit, "utf8"), + fs.readFile(patch, "utf8"), + fs.readFile(second, "utf8"), + ]), + ) + expect(content).toEqual(["written", "edited\n", "patched\n", "second\n"]) + expect(gate.requests).toHaveLength(2) + for (const request of gate.requests) { + expect(request).toMatchObject({ + op: "batch", + operations: [{ op: "makeDirectory" }, { op: "writeFile" }], + }) + } + }), + ), + ) + + it.live("preserves UTF-16 LE, Windows-1251, and UTF-8 BOM mutations", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const write = path.join(dir, "utf16.txt") + const edit = path.join(dir, "windows1251.txt") + const patch = path.join(dir, "bom.txt") + yield* Effect.promise(async () => { + await fs.mkdir(path.join(dir, ".tmp"), { recursive: true }) + await fs.writeFile(write, encoding.utf16("before")) + await fs.writeFile(edit, encoding.windows1251("Привет мир")) + await fs.writeFile(patch, encoding.bom("before\n")) + }) + yield* sandbox(profile(dir), runWrite({ filePath: write, content: "after" })) + yield* sandbox(profile(dir), runEdit({ filePath: edit, oldString: "мир", newString: "тест" })) + yield* sandbox( + profile(dir), + runPatch("*** Begin Patch\n*** Update File: bom.txt\n@@\n-before\n+after\n*** End Patch"), + ) + const afs = yield* AppFileSystem.Service + const synced = [ + { path: path.join(dir, "formatted-utf16.txt"), encoding: "utf-16le", bom: false }, + { path: path.join(dir, "formatted-windows1251.txt"), encoding: "windows-1251", bom: false }, + { path: path.join(dir, "formatted-bom.txt"), encoding: "utf-8-bom", bom: true }, + ] + for (const item of synced) { + yield* sandbox( + profile(dir), + afs + .writeFileString(item.path, "formatted") + .pipe(Effect.andThen(EncodedIO.sync(afs, item.path, item.bom, item.encoding))), + ) + } + const bytes = yield* Effect.promise(() => + Promise.all([ + fs.readFile(write), + fs.readFile(edit), + fs.readFile(patch), + ...synced.map((item) => fs.readFile(item.path)), + ]), + ) + expect(bytes[0].equals(encoding.utf16("after"))).toBe(true) + expect(bytes[1].equals(encoding.windows1251("Привет тест"))).toBe(true) + expect(bytes[2].equals(encoding.bom("after\n"))).toBe(true) + expect(bytes[3].equals(encoding.utf16("formatted"))).toBe(true) + expect(bytes[4].equals(encoding.windows1251("formatted"))).toBe(true) + expect(bytes[5].equals(encoding.bom("formatted"))).toBe(true) + }), + ), + ) + + it.live("preserves useful symlinks whose targets remain in the workspace", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const target = path.join(dir, "target") + const link = path.join(dir, "link") + yield* Effect.promise(async () => { + await fs.mkdir(target) + await fs.symlink(target, link) + }) + yield* sandbox(profile(dir), runWrite({ filePath: path.join(link, "value.txt"), content: "linked" })) + expect(yield* Effect.promise(() => fs.readFile(path.join(target, "value.txt"), "utf8"))).toBe("linked") + }), + ), + ) + + it.live("rejects background-process start and restart while sandboxed", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const start = yield* sandbox(profile(dir), runBackground({ action: "start", command: "sleep 30" })) + const restart = yield* sandbox( + profile(dir), + runBackground({ action: "restart", id: BackgroundProcess.ID.ascending("bgp-test") }), + ) + expect(start.output).toContain("unavailable while the sandbox is enabled") + expect(restart.output).toContain("unavailable while the sandbox is enabled") + }), + ), + ) + + for (const destination of ["outside", ".git"] as const) { + for (const tool of ["Write", "Edit", "ApplyPatch"] as const) { + it.live(`${tool} cannot follow a racing parent into ${destination}`, () => + provideTmpdirInstance((dir) => + Effect.acquireUseRelease( + Effect.promise(async () => { + const ext = await outside(dir) + const safe = path.join(dir, "safe") + const protectedDir = destination === "outside" ? ext : path.join(dir, ".git", "race") + const live = path.join(dir, "live") + await fs.mkdir(path.join(dir, ".tmp"), { recursive: true }) + await fs.mkdir(safe, { recursive: true }) + await fs.mkdir(protectedDir, { recursive: true }) + await fs.writeFile(path.join(safe, "value.txt"), "before\n") + await fs.writeFile(path.join(protectedDir, "value.txt"), "protected\n") + await fs.symlink(safe, live) + const swap = await attacker(dir, live, [protectedDir, safe]) + return { ext, live, protectedDir, swap } + }), + (setup) => + Effect.gen(function* () { + gate.run = setup.swap.open + const target = path.join(setup.live, "value.txt") + const effect = + tool === "Write" + ? runWrite({ filePath: target, content: "escaped" }).pipe(Effect.asVoid) + : tool === "Edit" + ? runEdit({ filePath: target, oldString: "before", newString: "escaped" }).pipe(Effect.asVoid) + : runPatch( + "*** Begin Patch\n*** Update File: live/value.txt\n@@\n-before\n+escaped\n*** End Patch", + ).pipe(Effect.asVoid) + yield* withRunner(runner, sandbox(profile(dir), effect)).pipe(Effect.exit) + const protectedText = yield* Effect.promise(() => + fs.readFile(path.join(setup.protectedDir, "value.txt"), "utf8"), + ) + expect(protectedText).toBe("protected\n") + }), + (setup) => + Effect.promise(async () => { + gate.run = undefined + await setup.swap.close() + await fs.rm(setup.ext, { recursive: true, force: true }) + }), + ), + ), + ) + } + } +}) diff --git a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts index a0d4b20f163..ff97b434b16 100644 --- a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts @@ -150,6 +150,7 @@ const registry = Layer.effect( }), ).pipe(Layer.provideMerge(base)) const it = testEffect(registry) +const mac = process.platform === "darwin" && existsSync("/usr/bin/sandbox-exec") ? it.live : it.live.skip function resolve(ctx: InstanceContext) { return SessionTools.resolve({ @@ -221,7 +222,7 @@ function fixture() { }) } -it.live("confines model-originated file mutations to the active worktree", () => +mac("confines model-originated file mutations to the active worktree", () => Effect.gen(function* () { const dirs = yield* fixture() const tools = yield* resolve(dirs.ctx) @@ -280,7 +281,7 @@ it.live("confines model-originated file mutations to the active worktree", () => }), ) -it.live("keeps model-originated file mutations writable in a local checkout", () => +mac("keeps model-originated file mutations writable in a local checkout", () => Effect.gen(function* () { const dirs = yield* fixture() const ctx = context(dirs.local, dirs.main, [dirs.a, dirs.b]) @@ -296,7 +297,7 @@ it.live("keeps model-originated file mutations writable in a local checkout", () }), ) -it.live("keeps concurrent session profiles call-local", () => +mac("keeps concurrent session profiles call-local", () => Effect.gen(function* () { const dirs = yield* fixture() const left = yield* resolve(context(dirs.a, dirs.main, [dirs.a, dirs.b])) @@ -328,8 +329,6 @@ it.live("keeps concurrent session profiles call-local", () => }), ) -const mac = process.platform === "darwin" && existsSync("/usr/bin/sandbox-exec") ? it.live : it.live.skip - mac("confines a model-originated sandboxed process to the active worktree", () => Effect.gen(function* () { const dirs = yield* fixture() diff --git a/packages/opencode/test/kilocode/test-profile.test.ts b/packages/opencode/test/kilocode/test-profile.test.ts index c96772d531e..65665793168 100644 --- a/packages/opencode/test/kilocode/test-profile.test.ts +++ b/packages/opencode/test/kilocode/test-profile.test.ts @@ -13,6 +13,8 @@ describe("test profiles", () => { if (!result.ok) return expect(result.files.length).toBeGreaterThan(50) expect(result.files).toContain("pty/pty-session.test.ts") + expect(result.files).toContain("kilocode/cli/install-artifact.test.ts") + expect(result.files).toContain("kilocode/sandbox/macos-confinement.test.ts") expect(result.files).toContain("kilocode/sessions/remote-ws.test.ts") expect(result.files).toContain("kilocode/sessions/remote-sender.test.ts") })