diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index be259ff3d9d..fdaa90c33d4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -566,13 +566,16 @@ export const layer = Layer.effect( text: string, options: { path: string } | { dir: string; source: string }, env?: Record, + fileScope?: ConfigVariable.FileScope, // kilocode_change ) { const source = "path" in options ? options.path : options.source const expanded = yield* Effect.promise(() => ConfigVariable.substitute( + // kilocode_change start "path" in options - ? { text, type: "path", path: options.path, env } - : { text, type: "virtual", ...options, env }, + ? { text, type: "path", path: options.path, env, fileScope } + : { text, type: "virtual", ...options, env, fileScope }, + // kilocode_change end ), ) const parsed = ConfigParse.jsonc(expanded, source) @@ -588,14 +591,20 @@ export const layer = Layer.effect( yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) } return data - }) + }) // kilocode_change - const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record) { + // kilocode_change start + const loadFile = Effect.fnUntraced(function* ( + filepath: string, + env?: Record, + fileScope?: ConfigVariable.FileScope, + ) { log.info("loading", { path: filepath }) const text = yield* readConfigFile(filepath) if (!text) return {} as Info - return yield* loadConfig(text, { path: filepath }, env) + return yield* loadConfig(text, { path: filepath }, env, fileScope) // kilocode_change }) + // kilocode_change end let globalStamp = "" // kilocode_change @@ -844,13 +853,14 @@ export const layer = Layer.effect( log.debug("loaded custom config", { path: Flag.KILO_CONFIG }) } + // kilocode_change start - also discover kilo.json project files if (!Flag.KILO_DISABLE_PROJECT_CONFIG) { - // kilocode_change start - also discover kilo.json project files for (const name of ["kilo", "opencode"] as const) { for (const file of yield* ConfigPaths.files(name, ctx.directory, ctx.worktree).pipe(Effect.orDie)) { + const fileScope = { root: ctx.worktree === "/" ? ctx.directory : ctx.worktree, source: file } yield* merge( file, - yield* loadFile(file, authEnv).pipe( + yield* loadFile(file, authEnv, fileScope).pipe( Effect.catchDefect((err: unknown) => { caughtWarning(warnings, file, err) return Effect.succeed({} as Info) @@ -860,8 +870,8 @@ export const layer = Layer.effect( ) } } - // kilocode_change end } + // kilocode_change end result.agent = result.agent || {} result.mode = result.mode || {} @@ -881,18 +891,19 @@ export const layer = Layer.effect( log.debug("loading config from KILO_CONFIG_DIR", { path: Flag.KILO_CONFIG_DIR }) } + // kilocode_change start const deps: Fiber.Fiber[] = [] - // kilocode_change start for (const dir of unique(directories)) { - const scope = primarySet.has(dir) ? "local" : undefined + const scope = primarySet.has(dir) || containsPath(dir, ctx) ? "local" : undefined if (KilocodeConfig.isConfigDir(dir, Flag.KILO_CONFIG_DIR)) { for (const file of KilocodeConfig.ALL_CONFIG_FILES) { const source = path.join(dir, file) + const fileScope = scope === "local" ? { root: ctx.worktree === "/" ? ctx.directory : ctx.worktree, source } : undefined log.debug(`loading config from ${source}`) yield* merge( source, - yield* loadFile(source, authEnv).pipe( + yield* loadFile(source, authEnv, fileScope).pipe( Effect.catchDefect((err: unknown) => { caughtWarning(warnings, source, err) return Effect.succeed({} as Info) diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index 8a1aa2cab08..eed8bee3067 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -6,6 +6,8 @@ import { Filesystem } from "@/util/filesystem" import { InvalidError } from "./error" import { ConfigVariableGuard } from "@/kilocode/config/variable" // kilocode_change +export type FileScope = ConfigVariableGuard.FileScope // kilocode_change + type ParseSource = | { type: "path" @@ -22,6 +24,7 @@ type SubstituteInput = ParseSource & { missing?: "error" | "empty" escapeJson?: boolean // kilocode_change env?: Record + fileScope?: ConfigVariableGuard.FileScope // kilocode_change } function source(input: ParseSource) { @@ -74,21 +77,23 @@ export async function substitute(input: SubstituteInput) { const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) // kilocode_change start - validate and read one opened file to prevent credential substitution races const fileContent = ( - await ConfigVariableGuard.read(resolvedPath, Filesystem.readText).catch((error: NodeJS.ErrnoException) => { - if (missing === "empty") return "" - - const errMsg = `bad file reference: "${token}"` - if (error.code === "ENOENT") { - throw new InvalidError( - { - path: configSource, - message: errMsg + ` ${resolvedPath} does not exist`, - }, - { cause: error }, - ) - } - throw new InvalidError({ path: configSource, message: errMsg }, { cause: error }) - }) + await ConfigVariableGuard.read(resolvedPath, Filesystem.readText, input.fileScope && { ...input.fileScope, token }).catch( + (error: NodeJS.ErrnoException) => { + if (missing === "empty") return "" + + const errMsg = `bad file reference: "${token}"` + if (error.code === "ENOENT") { + throw new InvalidError( + { + path: configSource, + message: errMsg + ` ${resolvedPath} does not exist`, + }, + { cause: error }, + ) + } + throw new InvalidError({ path: configSource, message: errMsg }, { cause: error }) + }, + ) ).trim() // kilocode_change end diff --git a/packages/opencode/src/kilocode/config/variable.ts b/packages/opencode/src/kilocode/config/variable.ts index 6575a8d858b..c1bbf8d57e9 100644 --- a/packages/opencode/src/kilocode/config/variable.ts +++ b/packages/opencode/src/kilocode/config/variable.ts @@ -1,21 +1,43 @@ import fs from "node:fs/promises" import { realpathSync } from "node:fs" +import path from "node:path" export namespace ConfigVariableGuard { + export type FileScope = { + root: string + source: string + } + const secret = new Set(["KILO_SERVER_PASSWORD", "KILO_SERVER_USERNAME"]) export function env(name: string) { return !secret.has(name.toUpperCase()) } - export async function read(path: string, load: (path: string) => Promise) { - if (process.platform !== "linux") return load(path) - const file = await fs.open(path, "r") + function inside(root: string, file: string) { + const rel = path.relative(root, file) + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) + } + + function check(file: string, token: string, scope?: FileScope) { + if (!scope) return + const root = realpathSync.native(scope.root) + if (inside(root, file)) return + throw new Error(`blocked file reference outside project config scope: "${token}"`) + } + + export async function read( + filePath: string, + load: (path: string) => Promise, + scope?: FileScope & { token?: string }, + ) { + const file = await fs.open(filePath, "r") try { - const target = `/proc/self/fd/${file.fd}` + const target = process.platform === "linux" ? `/proc/self/fd/${file.fd}` : filePath const resolved = realpathSync.native(target) + check(resolved, scope?.token ?? "{file:...}", scope) if (/^\/proc\/.*\/environ$/.test(resolved)) throw new Error("blocked process environment reference") - return await load(target) + return await load(process.platform === "linux" ? target : resolved) } finally { await file.close() } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 4b22d4bb87b..43c8ded4a74 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -612,6 +612,84 @@ it.instance("handles file inclusion with replacement tokens", () => }), ) +// kilocode_change start +describe("project config file reference scope", () => { + it.instance("skips project config that reads an absolute file", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeConfigEffect(test.directory, { + $schema: "https://app.kilo.ai/config.json", + username: "{file:/etc/passwd}", + }) + const config = yield* Config.use.get() + expect(config.username).not.toContain("root:") + expect(config.username).toBeDefined() + }), + ) + + it.instance("skips project config that reads a home file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const home = yield* tmpdirScoped() + yield* AppFileSystem.use.writeWithDirs(path.join(home, "secret.txt"), "home-secret") + yield* writeConfigEffect(test.directory, { + $schema: "https://app.kilo.ai/config.json", + username: `{file:${path.join(home, "secret.txt")}}`, + }) + const config = yield* Config.use.get() + expect(config.username).not.toBe("home-secret") + }), + ) + + it.instance("skips project config that escapes with parent directories", () => + Effect.gen(function* () { + const test = yield* TestInstance + const outside = path.join(path.dirname(test.directory), "secret.txt") + yield* AppFileSystem.use.writeWithDirs(outside, "outside-secret") + yield* writeConfigEffect(test.directory, { + $schema: "https://app.kilo.ai/config.json", + username: "{file:../secret.txt}", + }) + const config = yield* Config.use.get() + expect(config.username).not.toBe("outside-secret") + }), + ) + + it.instance("skips project config that escapes through a symlink", () => + Effect.gen(function* () { + const test = yield* TestInstance + const outside = path.join(path.dirname(test.directory), "secret.txt") + const link = path.join(test.directory, "secret-link") + yield* AppFileSystem.use.writeWithDirs(outside, "outside-secret") + yield* Effect.promise(() => fs.symlink(outside, link)) + yield* writeConfigEffect(test.directory, { + $schema: "https://app.kilo.ai/config.json", + username: "{file:secret-link}", + }) + const config = yield* Config.use.get() + expect(config.username).not.toBe("outside-secret") + }), + ) + + it.instance("still allows global config to read absolute files", () => + withGlobalConfig( + {}, + ({ dir }) => + Effect.gen(function* () { + const secret = path.join(dir, "secret.txt") + yield* AppFileSystem.use.writeWithDirs(secret, "global-secret") + yield* writeConfigEffect(dir, { + $schema: "https://app.kilo.ai/config.json", + username: `{file:${secret}}`, + }) + const config = yield* Config.use.get() + expect(config.username).toBe("global-secret") + }), + ), + ) +}) +// kilocode_change end + const accountTokenIt = configIt({ account: Layer.mock(Account.Service)({ active: () => diff --git a/packages/opencode/test/kilocode/config/variable.test.ts b/packages/opencode/test/kilocode/config/variable.test.ts index ac94471b0c8..79bbd228d0f 100644 --- a/packages/opencode/test/kilocode/config/variable.test.ts +++ b/packages/opencode/test/kilocode/config/variable.test.ts @@ -37,6 +37,25 @@ test("reads ordinary file substitutions on every platform", async () => { } }) +test("rejects scoped file substitutions outside the allowed root", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-root-")) + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-outside-")) + const file = path.join(dir, "value") + await fs.writeFile(file, "blocked") + try { + await expect( + ConfigVariable.substitute({ + ...source, + text: `{file:${file}}`, + fileScope: { root, source: "test" }, + }), + ).rejects.toBeInstanceOf(InvalidError) + } finally { + await fs.rm(root, { recursive: true, force: true }) + await fs.rm(dir, { recursive: true, force: true }) + } +}) + test.skipIf(process.platform !== "linux")("does not substitute process environment files", async () => { await expect( ConfigVariable.substitute({