fix(cli): tolerate unsafe project config in settings overlay

KilocodeConfigOverlay.load() propagated InvalidError from untrusted {env:}/out-of-scope {file:} substitutions through Promise.all, breaking the whole /config/overlay instead of skipping the offending file. Skip failed files (log + return {}) so the settings overlay still shows remaining config, matching the main config loader's degrade-gracefully behavior.
This commit is contained in:
Mark IJbema
2026-07-06 10:02:29 +02:00
parent e9f6b02084
commit 47a40fe7f7
2 changed files with 34 additions and 0 deletions
@@ -2,6 +2,7 @@ import path from "path"
import { existsSync } from "fs"
import { Schema } from "effect"
import z from "zod"
import * as Log from "@opencode-ai/core/util/log"
import { Global } from "@opencode-ai/core/global"
import { ConfigAgent } from "@/config/agent"
import { Config } from "@/config/config"
@@ -13,6 +14,8 @@ import { KilocodeConfig } from "./config"
import { KilocodeConfigSources } from "./sources"
export namespace KilocodeConfigOverlay {
const log = Log.create({ service: "kilocode.config.overlay" })
export const Scope = z.enum(["global", "project"])
export type Scope = z.infer<typeof Scope>
@@ -204,6 +207,17 @@ export namespace KilocodeConfigOverlay {
// kilocode_change end
async function load(file: string, fileScope?: ConfigVariable.FileScope): Promise<Config.Info> {
// kilocode_change start - a single unsafe/invalid project config file must not break the settings overlay;
// untrusted {env:} and out-of-scope {file:} throw InvalidError here, so skip the offending file like the
// main config loader does rather than failing the whole overlay.
return await loadUnsafe(file, fileScope).catch((err) => {
log.warn("skipping unreadable project config in overlay", { file, err })
return {} as Config.Info
})
}
async function loadUnsafe(file: string, fileScope?: ConfigVariable.FileScope): Promise<Config.Info> {
// kilocode_change end
const text = await Bun.file(file).text()
// kilocode_change - overlay reads project config files: {env:} rejected, {file:} confined to fileScope.root
const expanded = await ConfigVariable.substitute({ text, type: "path", path: file, trusted: false, fileScope })
@@ -144,6 +144,26 @@ describe("config overlay routes", () => {
expect(body.targets.project).toBe(path.join(project.path, ".kilo", "kilo.json"))
})
test.serial("tolerates unsafe project config instead of failing the overlay", async () => {
await using project = await tmpdir()
// A project config that references a file outside the project root throws during substitution.
// The overlay must skip it and still resolve, rather than rejecting the whole request.
await Filesystem.write(
path.join(project.path, ".kilo", "kilo.json"),
JSON.stringify({ username: "{file:/etc/passwd}" }),
)
const body = await KilocodeConfigOverlay.resolve({
directory: project.path,
scope: "project",
effective: {},
global: {},
sources: [],
})
expect(body.project.username ?? "").not.toContain("root:")
})
test.serial("marks global values inherited in project scope", async () => {
await using global = await tmpdir()
await using project = await tmpdir()