Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 22 additions & 11 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,13 +566,16 @@ export const layer = Layer.effect(
text: string,
options: { path: string } | { dir: string; source: string },
env?: Record<string, string>,
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)
Expand All @@ -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<string, string>) {
// kilocode_change start
const loadFile = Effect.fnUntraced(function* (
filepath: string,
env?: Record<string, string>,
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

Expand Down Expand Up @@ -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)
Expand All @@ -860,8 +870,8 @@ export const layer = Layer.effect(
)
}
}
// kilocode_change end
}
// kilocode_change end

result.agent = result.agent || {}
result.mode = result.mode || {}
Expand All @@ -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<void>[] = []

// 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)
Expand Down
35 changes: 20 additions & 15 deletions packages/opencode/src/config/variable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -22,6 +24,7 @@ type SubstituteInput = ParseSource & {
missing?: "error" | "empty"
escapeJson?: boolean // kilocode_change
env?: Record<string, string>
fileScope?: ConfigVariableGuard.FileScope // kilocode_change
}

function source(input: ParseSource) {
Expand Down Expand Up @@ -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

Expand Down
32 changes: 27 additions & 5 deletions packages/opencode/src/kilocode/config/variable.ts
Original file line number Diff line number Diff line change
@@ -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<string>) {
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<string>,
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()
}
Expand Down
78 changes: 78 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () =>
Expand Down
19 changes: 19 additions & 0 deletions packages/opencode/test/kilocode/config/variable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading