From b793bf788f20e5d96898c0565916af7bc71a5683 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 13:42:15 +0200 Subject: [PATCH 01/18] fix(cli): block file/env references in untrusted project config Project-scoped config could resolve {file:...} and {env:...} tokens, letting a malicious repo exfiltrate arbitrary local files by pointing a provider apiKey at a local file and baseURL at an attacker server. Substitution now requires a trusted source (global config, KILO_CONFIG, KILO_CONFIG_CONTENT, well-known/org/MDM config). Untrusted project config rejects such tokens (surfaced as a warning). Threaded the trust flag through config.ts, agent.ts, overlay.ts, and tui.ts; TUI reuses the same project-boundary classification as config.ts. --- .changeset/config-file-substitution-trust.md | 5 ++ .../opencode/src/cli/cmd/tui/config/tui.ts | 34 ++++--- packages/opencode/src/config/agent.ts | 5 +- packages/opencode/src/config/config.ts | 90 +++++++++++++------ packages/opencode/src/config/variable.ts | 29 +++++- .../opencode/src/kilocode/config/overlay.ts | 15 ++-- packages/opencode/test/config/config.test.ts | 65 ++++++-------- packages/opencode/test/config/tui.test.ts | 35 ++++++-- .../test/kilocode/config/variable.test.ts | 51 +++++++++-- 9 files changed, 231 insertions(+), 98 deletions(-) create mode 100644 .changeset/config-file-substitution-trust.md diff --git a/.changeset/config-file-substitution-trust.md b/.changeset/config-file-substitution-trust.md new file mode 100644 index 0000000000..3ae377de41 --- /dev/null +++ b/.changeset/config-file-substitution-trust.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent project config from reading local files or environment variables via `{file:...}` / `{env:...}` references. These references now resolve only in trusted user-owned config (global config, `KILO_CONFIG`, well-known org config), closing a path where a malicious project could exfiltrate local files through a provider API key. diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 4363e249fa..d9d136dc2f 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -112,10 +112,15 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: return config }) - const load = (text: string, configFilepath: string): Effect.Effect => + const load = ( + text: string, + configFilepath: string, + trusted: boolean, + ): Effect.Effect => // kilocode_change - trusted param Effect.gen(function* () { const expanded = yield* Effect.promise(() => - ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }), + // kilocode_change - only trusted (global/explicit) tui config may resolve {file:}/{env:} tokens + ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", trusted }), ) const data = ConfigParse.jsonc(expanded, configFilepath) if (!isRecord(data)) return {} as Info @@ -149,7 +154,10 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ), ) - const loadFile = (filepath: string): Effect.Effect => + const loadFile = ( + filepath: string, + trusted: boolean, + ): Effect.Effect => // kilocode_change - trusted param Effect.gen(function* () { // Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip. // Matches how parse/schema/plugin failures in load() are handled — every @@ -169,12 +177,16 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ) if (!text) return {} as Info log.info("loading tui config", { path: filepath }) - return yield* load(text, filepath) + return yield* load(text, filepath, trusted) }) - const mergeFile = (acc: Acc, file: string) => + const mergeFile = ( + acc: Acc, + file: string, + trusted: boolean, // kilocode_change - trusted param + ) => Effect.gen(function* () { - const data = yield* loadFile(file) + const data = yield* loadFile(file, trusted) if (Object.keys(data).length) { appliedOrder += 1 log.info("applying tui config", { path: file, order: appliedOrder }) @@ -207,19 +219,19 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // 1. Global tui config (lowest precedence). for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { - yield* mergeFile(acc, file) + yield* mergeFile(acc, file, true) // kilocode_change - global config is trusted } // 2. Explicit KILO_TUI_CONFIG override, if set. if (Flag.KILO_TUI_CONFIG) { const configFile = Flag.KILO_TUI_CONFIG - yield* mergeFile(acc, configFile) + yield* mergeFile(acc, configFile, true) // kilocode_change - explicit env-provided path is trusted log.debug("loaded custom tui config", { path: configFile }) } // 3. Project tui files, applied root-first so the closest file wins. for (const file of projectFiles) { - yield* mergeFile(acc, file) + yield* mergeFile(acc, file, false) // kilocode_change - project config is untrusted } // kilocode_change start - load tui.json from supported Kilo config directories @@ -232,8 +244,10 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // kilocode_change end for (const dir of dirs) { + // kilocode_change - trust global (home/KILO_CONFIG_DIR) dirs like config.ts; in-repo .kilo/.kilocode stay untrusted + const trusted = pluginScope(dir, ctx) === "global" for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { - yield* mergeFile(acc, file) + yield* mergeFile(acc, file, trusted) } } diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 2b00ccdbde..aca3488488 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -134,8 +134,8 @@ export const Info = AgentSchema.pipe( ).annotate({ identifier: "AgentConfig" }) export type Info = Schema.Schema.Type -// kilocode_change start -export async function load(dir: string, warnings?: Warning[]) { +// kilocode_change start - trusted controls whether agent prompts may resolve {file:}/{env:} tokens +export async function load(dir: string, warnings?: Warning[], trusted?: boolean) { // kilocode_change end const result: Record = {} for (const item of await Glob.scan("{agent,agents}/**/*.md", { @@ -176,6 +176,7 @@ export async function load(dir: string, warnings?: Warning[]) { source: item, missing: "empty", escapeJson: false, + trusted, // kilocode_change - project agents must not resolve file/env references }) const config = { name, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index be259ff3d9..48484f1269 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -116,6 +116,7 @@ async function substituteWellKnownRemoteConfig(input: { dir: input.dir, source: input.source, env: input.env, + trusted: true, // kilocode_change - well-known org config is a trusted source }) const headers = isRecord(input.value.headers) ? Object.fromEntries( @@ -130,6 +131,7 @@ async function substituteWellKnownRemoteConfig(input: { dir: input.dir, source: input.source, env: input.env, + trusted: true, // kilocode_change - well-known org config is a trusted source }), ]), ), @@ -566,13 +568,14 @@ export const layer = Layer.effect( text: string, options: { path: string } | { dir: string; source: string }, env?: Record, + trusted?: boolean, // kilocode_change - only user-owned config may resolve {file:}/{env:} tokens ) { const source = "path" in options ? options.path : options.source const expanded = yield* Effect.promise(() => ConfigVariable.substitute( "path" in options - ? { text, type: "path", path: options.path, env } - : { text, type: "virtual", ...options, env }, + ? { text, type: "path", path: options.path, env, trusted } // kilocode_change + : { text, type: "virtual", ...options, env, trusted }, // kilocode_change ), ) const parsed = ConfigParse.jsonc(expanded, source) @@ -590,11 +593,15 @@ export const layer = Layer.effect( return data }) - const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record) { + const loadFile = Effect.fnUntraced(function* ( + filepath: string, + env?: Record, + trusted?: boolean, // kilocode_change + ) { 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, trusted) // kilocode_change }) let globalStamp = "" // kilocode_change @@ -615,13 +622,14 @@ export const layer = Layer.effect( .pipe(Effect.catch(() => Effect.void)) } } - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"), env)) + // kilocode_change - global config is user-owned and trusted to resolve {file:}/{env:} tokens + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"), env, true)) // kilocode_change start - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "kilo.json"), env)) - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "kilo.jsonc"), env)) + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "kilo.json"), env, true)) + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "kilo.jsonc"), env, true)) // kilocode_change end - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"), env)) - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env)) + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"), env, true)) // kilocode_change + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env, true)) // kilocode_change const legacy = path.join(Global.Path.config, "config") if (existsSync(legacy)) { @@ -799,6 +807,7 @@ export const layer = Layer.effect( source, }, authEnv, + true, // kilocode_change - well-known org config is a trusted source ) yield* merge(source, next, "global") log.debug("loaded remote config from well-known", { url }) @@ -833,7 +842,8 @@ export const layer = Layer.effect( // kilocode_change start - capture KILO_CONFIG failures as warnings yield* merge( Flag.KILO_CONFIG, - yield* loadFile(Flag.KILO_CONFIG, authEnv).pipe( + // kilocode_change - KILO_CONFIG is an explicit user-provided path, trusted for {file:}/{env:} + yield* loadFile(Flag.KILO_CONFIG, authEnv, true).pipe( Effect.catchDefect((err: unknown) => { caughtWarning(warnings, Flag.KILO_CONFIG!, err) return Effect.succeed({} as Info) @@ -850,7 +860,8 @@ export const layer = Layer.effect( for (const file of yield* ConfigPaths.files(name, ctx.directory, ctx.worktree).pipe(Effect.orDie)) { yield* merge( file, - yield* loadFile(file, authEnv).pipe( + // kilocode_change - project config is untrusted: {file:}/{env:} tokens are rejected + yield* loadFile(file, authEnv, false).pipe( Effect.catchDefect((err: unknown) => { caughtWarning(warnings, file, err) return Effect.succeed({} as Info) @@ -886,19 +897,23 @@ export const layer = Layer.effect( // kilocode_change start for (const dir of unique(directories)) { const scope = primarySet.has(dir) ? "local" : undefined + // kilocode_change - trust {file:}/{env:} only for global-scoped config dirs, never project ones + const dirScope = scope ?? (yield* pluginScopeForSource(dir)) + const dirTrusted = dirScope === "global" if (KilocodeConfig.isConfigDir(dir, Flag.KILO_CONFIG_DIR)) { for (const file of KilocodeConfig.ALL_CONFIG_FILES) { const source = path.join(dir, file) log.debug(`loading config from ${source}`) yield* merge( source, - yield* loadFile(source, authEnv).pipe( + yield* loadFile(source, authEnv, dirTrusted).pipe( + // kilocode_change Effect.catchDefect((err: unknown) => { caughtWarning(warnings, source, err) return Effect.succeed({} as Info) }), ), - scope, + dirScope, // kilocode_change ) result.agent ??= {} result.mode ??= {} @@ -937,13 +952,16 @@ export const layer = Layer.effect( result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir, warnings)), ) - result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir, warnings))) + result.agent = mergeDeep( + result.agent ?? {}, + yield* Effect.promise(() => ConfigAgent.load(dir, warnings, dirTrusted)), // kilocode_change + ) result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir, warnings))) // kilocode_change end // kilocode_change - Auto-discovered plugins under config directories are already local files, so ConfigPlugin.load // returns normalized Specs and we only need to attach origin metadata here. const list = yield* Effect.promise(() => ConfigPlugin.load(dir)) - yield* mergePluginOrigins(dir, list, scope) // kilocode_change + yield* mergePluginOrigins(dir, list, dirScope) // kilocode_change } if (process.env.KILO_CONFIG_CONTENT) { @@ -951,10 +969,15 @@ export const layer = Layer.effect( const source = "KILO_CONFIG_CONTENT" yield* merge( source, - yield* loadConfig(process.env.KILO_CONFIG_CONTENT, { - dir: ctx.directory, - source, - }).pipe( + yield* loadConfig( + process.env.KILO_CONFIG_CONTENT, + { + dir: ctx.directory, + source, + }, + undefined, + true, // kilocode_change - KILO_CONFIG_CONTENT is user-provided, trusted for {file:}/{env:} + ).pipe( Effect.tap(() => Effect.sync(() => log.debug("loaded custom config from KILO_CONFIG_CONTENT"))), Effect.catchDefect((err: unknown) => { caughtWarning(warnings, source, err) @@ -985,10 +1008,15 @@ export const layer = Layer.effect( if (Option.isSome(configOpt)) { const source = `${url}/api/config` - const next = yield* loadConfig(JSON.stringify(configOpt.value), { - dir: path.dirname(source), - source, - }) + const next = yield* loadConfig( + JSON.stringify(configOpt.value), + { + dir: path.dirname(source), + source, + }, + undefined, + true, // kilocode_change - console-managed org config is a trusted source + ) for (const providerID of Object.keys(next.provider ?? {})) { consoleManagedProviders.add(providerID) } @@ -1010,7 +1038,8 @@ export const layer = Layer.effect( if (existsSync(managedDir)) { for (const file of KilocodeConfig.ALL_CONFIG_FILES) { const source = path.join(managedDir, file) - yield* merge(source, yield* loadFile(source), "global") + // kilocode_change - MDM/enterprise-managed config is a trusted source + yield* merge(source, yield* loadFile(source, undefined, true), "global") } } // kilocode_change end @@ -1021,10 +1050,15 @@ export const layer = Layer.effect( if (managed) { yield* merge( managed.source, - yield* loadConfig(managed.text, { - dir: path.dirname(managed.source), - source: managed.source, - }), + yield* loadConfig( + managed.text, + { + dir: path.dirname(managed.source), + source: managed.source, + }, + undefined, + true, // kilocode_change - MDM-managed preferences are a trusted source + ), "global", ) } diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index 8a1aa2cab0..661c80fd85 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -21,6 +21,9 @@ type SubstituteInput = ParseSource & { text: string missing?: "error" | "empty" escapeJson?: boolean // kilocode_change + // kilocode_change start - only trusted (user-owned) sources may reference files/env; project config cannot + trusted?: boolean + // kilocode_change end env?: Record } @@ -32,10 +35,32 @@ function dir(input: ParseSource) { return input.type === "path" ? path.dirname(input.path) : input.dir } +// kilocode_change start - a token is inert when its line is commented out with // +function commented(text: string, index: number) { + const lineStart = text.lastIndexOf("\n", index - 1) + 1 + return text.slice(lineStart, index).trimStart().startsWith("//") +} +// kilocode_change end + /** Apply {env:VAR} and {file:path} substitutions to config text. */ export async function substitute(input: SubstituteInput) { const missing = input.missing ?? "error" const escape = input.escapeJson ?? true // kilocode_change + // kilocode_change start - only trusted (user-owned) config may read files or environment variables. + // Untrusted project config could otherwise exfiltrate arbitrary local files via a provider apiKey. + if (!(input.trusted ?? false)) { + const active = Array.from(input.text.matchAll(/\{(?:env|file):[^}]+\}/g)).find( + (m) => !commented(input.text, m.index), + ) + if (active) { + throw new InvalidError({ + path: source(input), + message: `file and environment references are not allowed in project config: "${active[0]}"`, + }) + } + return input.text + } + // kilocode_change end let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => { // kilocode_change start - reject server credentials instead of silently changing config semantics if (!ConfigVariableGuard.env(varName)) { @@ -58,9 +83,7 @@ export async function substitute(input: SubstituteInput) { const index = match.index out += text.slice(cursor, index) - const lineStart = text.lastIndexOf("\n", index - 1) + 1 - const prefix = text.slice(lineStart, index).trimStart() - if (prefix.startsWith("//")) { + if (commented(text, index)) { out += token cursor = index + token.length continue diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index 67412155ea..3e48ec8151 100644 --- a/packages/opencode/src/kilocode/config/overlay.ts +++ b/packages/opencode/src/kilocode/config/overlay.ts @@ -138,8 +138,8 @@ export namespace KilocodeConfigOverlay { } export async function resolve(input: Input): Promise { - const local = await withAgents(await project(input), await projectDirs(input)) - const global = await withAgents(input.global, globalDirs()) + const local = await withAgents(await project(input), await projectDirs(input), false) // kilocode_change - project agents untrusted + const global = await withAgents(input.global, globalDirs(), true) // kilocode_change - global agents trusted const targets = { global: globalTarget(), project: await projectTarget(input), @@ -185,19 +185,20 @@ export namespace KilocodeConfigOverlay { return [Global.Path.config, path.join(Global.Path.home, ".kilocode"), path.join(Global.Path.home, ".kilo")] } - async function withAgents(input: Config.Info, dirs: string[]): Promise { + async function withAgents(input: Config.Info, dirs: string[], trusted: boolean): Promise { const [dir, ...rest] = dirs if (!dir) return input - if (!existsSync(dir)) return withAgents(input, rest) - const agent = await ConfigAgent.load(dir) + if (!existsSync(dir)) return withAgents(input, rest, trusted) + const agent = await ConfigAgent.load(dir, undefined, trusted) // kilocode_change const mode = await ConfigAgent.loadMode(dir) const next = KilocodeConfig.mergeConfig(KilocodeConfig.mergeConfig(input, { agent }), { agent: mode }) - return withAgents(next, rest) + return withAgents(next, rest, trusted) } async function load(file: string): Promise { const text = await Bun.file(file).text() - const expanded = await ConfigVariable.substitute({ text, type: "path", path: file }) + // kilocode_change - overlay reads project config files; {file:}/{env:} references are untrusted here + const expanded = await ConfigVariable.substitute({ text, type: "path", path: file, trusted: false }) const parsed = ConfigParse.jsonc(expanded, file) if (!isRecord(parsed)) return {} return ConfigParse.schema(Config.Info, parsed, file) as Config.Info diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 4b22d4bb87..d34c928167 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -547,70 +547,60 @@ it.instance("prefers .kilo directory config over legacy .kilocode", () => ) // kilocode_change end -it.instance("handles environment variable substitution", () => +// kilocode_change start - project config is untrusted: {env:}/{file:} references must not resolve +it.instance("rejects environment variable substitution in project config", () => withProcessEnv( "TEST_VAR", "test-user", Effect.gen(function* () { const test = yield* TestInstance yield* writeConfigEffect(test.directory, { - $schema: "https://app.kilo.ai/config.json", // kilocode_change + $schema: "https://app.kilo.ai/config.json", username: "{env:TEST_VAR}", }) const config = yield* Config.use.get() - expect(config.username).toBe("test-user") + expect(config.username).not.toBe("test-user") + const issues = yield* Config.Service.use((svc) => svc.warnings()) + expect(issues.length).toBeGreaterThan(0) }), ), ) -it.instance("preserves env variables when adding $schema to config", () => - withProcessEnv( - "PRESERVE_VAR", - "secret_value", - Effect.gen(function* () { - const test = yield* TestInstance - // Config without $schema - should trigger auto-add - yield* AppFileSystem.use.writeWithDirs( - path.join(test.directory, "kilo.json"), // kilocode_change - JSON.stringify({ username: "{env:PRESERVE_VAR}" }), - ) - const config = yield* Config.use.get() - expect(config.username).toBe("secret_value") - - // Read the file to verify the env variable was preserved - const content = yield* AppFileSystem.use.readFileString(path.join(test.directory, "kilo.json")) // kilocode_change - expect(content).toContain("{env:PRESERVE_VAR}") - expect(content).not.toContain("secret_value") - expect(content).toContain("$schema") - }), - ), -) - -it.instance("handles file inclusion substitution", () => +it.instance("does not leak file contents via {file:} in project config", () => Effect.gen(function* () { const test = yield* TestInstance - yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.txt"), "test-user") + yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.txt"), "top-secret") yield* writeConfigEffect(test.directory, { - $schema: "https://app.kilo.ai/config.json", // kilocode_change + $schema: "https://app.kilo.ai/config.json", username: "{file:included.txt}", }) const config = yield* Config.use.get() - expect(config.username).toBe("test-user") + expect(config.username).not.toBe("top-secret") + const issues = yield* Config.Service.use((svc) => svc.warnings()) + expect(issues.length).toBeGreaterThan(0) }), ) -it.instance("handles file inclusion with replacement tokens", () => +it.instance("rejects {file:} exfiltration of provider apiKey in project config", () => Effect.gen(function* () { const test = yield* TestInstance - yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`") + yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "secret.txt"), "leaked-credential") yield* writeConfigEffect(test.directory, { - $schema: "https://app.kilo.ai/config.json", // kilocode_change - username: "{file:included.md}", + $schema: "https://app.kilo.ai/config.json", + provider: { + "openai-compatible": { + options: { baseURL: "http://127.0.0.1:4444/v1", apiKey: "{file:secret.txt}" }, + models: { "test-model": { name: "Test Model" } }, + }, + }, }) const config = yield* Config.use.get() - expect(config.username).toBe("const out = await Bun.$`echo hi`") + expect(JSON.stringify(config.provider ?? {})).not.toContain("leaked-credential") + const issues = yield* Config.Service.use((svc) => svc.warnings()) + expect(issues.length).toBeGreaterThan(0) }), ) +// kilocode_change end const accountTokenIt = configIt({ account: Layer.mock(Account.Service)({ @@ -1758,8 +1748,11 @@ envIsolationWellKnown.it.instance( Effect.gen(function* () { process.env.TEST_TOKEN = "preexisting-token" const config = yield* Config.use.get() + // The well-known header (trusted source) resolves the auth-provided token... expect(envIsolationWellKnown.seen.authorization).toBe("Bearer test-token") - expect(config.username).toBe("test-token") + // ...but the project config token is untrusted and must not be substituted. + expect(config.username).not.toBe("test-token") + // ...and the auth env used for substitution must not leak into the real process env. expect(process.env.TEST_TOKEN).toBe("preexisting-token") }), { git: true, config: { username: "{env:TEST_TOKEN}" } }, diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 1506e0e84f..ff75384cd3 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -678,7 +678,30 @@ it.instance("does not derive tui path from KILO_CONFIG", () => ), ) -it.instance("applies env and file substitutions in tui.json", () => +it.instance("applies env and file substitutions in global tui.json", () => + withCleanState( + withEnv( + "TUI_THEME_TEST", + "env-theme", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + // Global config is trusted, so {env:}/{file:} references resolve. + yield* fs.writeFileString(path.join(Global.Path.config, "keybind.txt"), "ctrl+q") + yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { + theme: "{env:TUI_THEME_TEST}", + keybinds: { app_exit: "{file:keybind.txt}" }, + }) + + const config = yield* getTuiConfig(test.directory) + expect(config.theme).toBe("env-theme") + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") + }), + ), + ), +) + +it.instance("does not substitute env or file references in untrusted project tui.json", () => withCleanState( withEnv( "TUI_THEME_TEST", @@ -692,9 +715,10 @@ it.instance("applies env and file substitutions in tui.json", () => keybinds: { app_exit: "{file:keybind.txt}" }, }) + // Project tui config with references is rejected and skipped, so nothing is substituted. const config = yield* getTuiConfig(test.directory) - expect(config.theme).toBe("env-theme") - expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") + expect(config.theme).not.toBe("env-theme") + expect(config.keybinds.get("app.exit")?.[0]?.key).not.toBe("ctrl+q") }), ), ), @@ -705,9 +729,10 @@ it.instance("applies file substitutions when first identical token is in a comme Effect.gen(function* () { const fs = yield* AppFileSystem.Service const test = yield* TestInstance - yield* fs.writeFileString(path.join(test.directory, "theme.txt"), "resolved-theme") + // Global config is trusted, so the second (uncommented) reference resolves. + yield* fs.writeFileString(path.join(Global.Path.config, "theme.txt"), "resolved-theme") yield* fs.writeFileString( - path.join(test.directory, "tui.jsonc"), + path.join(Global.Path.config, "tui.jsonc"), `{ // "theme": "{file:theme.txt}", "theme": "{file:theme.txt}" diff --git a/packages/opencode/test/kilocode/config/variable.test.ts b/packages/opencode/test/kilocode/config/variable.test.ts index ac94471b0c..c8131c0d25 100644 --- a/packages/opencode/test/kilocode/config/variable.test.ts +++ b/packages/opencode/test/kilocode/config/variable.test.ts @@ -6,32 +6,67 @@ import { ConfigVariable } from "@/config/variable" import { InvalidError } from "@/config/error" const source = { type: "virtual" as const, source: "test", dir: process.cwd() } +const trusted = { ...source, trusted: true } + +test("rejects file references in untrusted (project) config", async () => { + await expect( + ConfigVariable.substitute({ ...source, text: "apiKey={file:/etc/passwd}" }), + ).rejects.toBeInstanceOf(InvalidError) +}) + +test("rejects file references when trusted is omitted (secure by default)", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-untrusted-")) + const file = path.join(dir, "secret") + await fs.writeFile(file, "top-secret") + try { + await expect(ConfigVariable.substitute({ ...source, text: `{file:${file}}` })).rejects.toBeInstanceOf( + InvalidError, + ) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } +}) + +test("rejects environment references in untrusted (project) config", async () => { + await expect( + ConfigVariable.substitute({ ...source, text: "value={env:SAFE_VALUE}", env: { SAFE_VALUE: "allowed" } }), + ).rejects.toBeInstanceOf(InvalidError) +}) + +test("leaves untrusted text without references untouched", async () => { + expect(await ConfigVariable.substitute({ ...source, text: "plain value" })).toBe("plain value") +}) + +test("ignores commented-out references in untrusted config", async () => { + const text = ["// {file:/etc/passwd}", "// {env:SAFE_VALUE}"].join("\n") + expect(await ConfigVariable.substitute({ ...source, text })).toBe(text) +}) test("rejects server credential environment substitutions", async () => { await expect( ConfigVariable.substitute({ - ...source, + ...trusted, text: "password={env:KILO_SERVER_PASSWORD}", env: { KILO_SERVER_PASSWORD: "secret" }, }), ).rejects.toBeInstanceOf(InvalidError) }) -test("continues to substitute ordinary environment variables", async () => { +test("continues to substitute ordinary environment variables when trusted", async () => { const result = await ConfigVariable.substitute({ - ...source, + ...trusted, text: "value={env:SAFE_VALUE}", env: { SAFE_VALUE: "allowed" }, }) expect(result).toBe("value=allowed") }) -test("reads ordinary file substitutions on every platform", async () => { +test("reads ordinary file substitutions on every platform when trusted", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-file-")) const file = path.join(dir, "value") await fs.writeFile(file, "allowed") try { - expect(await ConfigVariable.substitute({ ...source, text: `{file:${file}}` })).toBe("allowed") + expect(await ConfigVariable.substitute({ ...trusted, text: `{file:${file}}` })).toBe("allowed") } finally { await fs.rm(dir, { recursive: true, force: true }) } @@ -40,7 +75,7 @@ test("reads ordinary file substitutions on every platform", async () => { test.skipIf(process.platform !== "linux")("does not substitute process environment files", async () => { await expect( ConfigVariable.substitute({ - ...source, + ...trusted, text: "{file:/proc/self/environ}", }), ).rejects.toBeInstanceOf(InvalidError) @@ -51,7 +86,9 @@ test.skipIf(process.platform !== "linux")("does not substitute an environment fi const link = path.join(dir, "value") await fs.symlink("/proc/self/environ", link) try { - await expect(ConfigVariable.substitute({ ...source, text: `{file:${link}}` })).rejects.toBeInstanceOf(InvalidError) + await expect(ConfigVariable.substitute({ ...trusted, text: `{file:${link}}` })).rejects.toBeInstanceOf( + InvalidError, + ) } finally { await fs.rm(dir, { recursive: true, force: true }) } From ac62aef00c1a15048927bd8baae917fa31742003 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 14:04:40 +0200 Subject: [PATCH 02/18] chore(cli): annotate kilocode_change markers on config trust changes --- .../opencode/src/cli/cmd/tui/config/tui.ts | 30 ++++++++----------- packages/opencode/src/config/variable.ts | 2 ++ packages/opencode/test/config/tui.test.ts | 5 +++- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index d9d136dc2f..8a74185a37 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -112,11 +112,9 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: return config }) - const load = ( - text: string, - configFilepath: string, - trusted: boolean, - ): Effect.Effect => // kilocode_change - trusted param + // kilocode_change start - trusted controls whether tui config may resolve {file:}/{env:} tokens + const load = (text: string, configFilepath: string, trusted: boolean): Effect.Effect => + // kilocode_change end Effect.gen(function* () { const expanded = yield* Effect.promise(() => // kilocode_change - only trusted (global/explicit) tui config may resolve {file:}/{env:} tokens @@ -154,10 +152,9 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ), ) - const loadFile = ( - filepath: string, - trusted: boolean, - ): Effect.Effect => // kilocode_change - trusted param + // kilocode_change start - trusted param threaded to load + const loadFile = (filepath: string, trusted: boolean): Effect.Effect => + // kilocode_change end Effect.gen(function* () { // Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip. // Matches how parse/schema/plugin failures in load() are handled — every @@ -177,16 +174,14 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ) if (!text) return {} as Info log.info("loading tui config", { path: filepath }) - return yield* load(text, filepath, trusted) + return yield* load(text, filepath, trusted) // kilocode_change }) - const mergeFile = ( - acc: Acc, - file: string, - trusted: boolean, // kilocode_change - trusted param - ) => + // kilocode_change start - trusted param threaded to loadFile + const mergeFile = (acc: Acc, file: string, trusted: boolean) => + // kilocode_change end Effect.gen(function* () { - const data = yield* loadFile(file, trusted) + const data = yield* loadFile(file, trusted) // kilocode_change if (Object.keys(data).length) { appliedOrder += 1 log.info("applying tui config", { path: file, order: appliedOrder }) @@ -244,11 +239,12 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // kilocode_change end for (const dir of dirs) { - // kilocode_change - trust global (home/KILO_CONFIG_DIR) dirs like config.ts; in-repo .kilo/.kilocode stay untrusted + // kilocode_change start - trust global (home/KILO_CONFIG_DIR) dirs like config.ts; in-repo .kilo/.kilocode stay untrusted const trusted = pluginScope(dir, ctx) === "global" for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { yield* mergeFile(acc, file, trusted) } + // kilocode_change end } const keybinds = { ...acc.result.keybinds } diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index 661c80fd85..3c05b27d3d 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -83,11 +83,13 @@ export async function substitute(input: SubstituteInput) { const index = match.index out += text.slice(cursor, index) + // kilocode_change start - skip tokens on commented-out lines if (commented(text, index)) { out += token cursor = index + token.length continue } + // kilocode_change end let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "") if (filePath.startsWith("~/")) { diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index ff75384cd3..a1e611257c 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -678,6 +678,7 @@ it.instance("does not derive tui path from KILO_CONFIG", () => ), ) +// kilocode_change start - trusted global config substitutes; untrusted project config does not it.instance("applies env and file substitutions in global tui.json", () => withCleanState( withEnv( @@ -723,16 +724,18 @@ it.instance("does not substitute env or file references in untrusted project tui ), ), ) +// kilocode_change end it.instance("applies file substitutions when first identical token is in a commented line", () => withCleanState( Effect.gen(function* () { const fs = yield* AppFileSystem.Service const test = yield* TestInstance - // Global config is trusted, so the second (uncommented) reference resolves. + // kilocode_change start - global config is trusted, so the second (uncommented) reference resolves yield* fs.writeFileString(path.join(Global.Path.config, "theme.txt"), "resolved-theme") yield* fs.writeFileString( path.join(Global.Path.config, "tui.jsonc"), + // kilocode_change end `{ // "theme": "{file:theme.txt}", "theme": "{file:theme.txt}" From 7d312ee2caff654ba420eb957606ddf7309ec505 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 14:05:05 +0200 Subject: [PATCH 03/18] chore(cli): wrap tui substitute call in kilocode_change block --- packages/opencode/src/cli/cmd/tui/config/tui.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 8a74185a37..bbb96d5ce8 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -116,9 +116,11 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: const load = (text: string, configFilepath: string, trusted: boolean): Effect.Effect => // kilocode_change end Effect.gen(function* () { - const expanded = yield* Effect.promise(() => - // kilocode_change - only trusted (global/explicit) tui config may resolve {file:}/{env:} tokens - ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", trusted }), + const expanded = yield* Effect.promise( + () => + // kilocode_change start - only trusted (global/explicit) tui config may resolve {file:}/{env:} tokens + ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", trusted }), + // kilocode_change end ) const data = ConfigParse.jsonc(expanded, configFilepath) if (!isRecord(data)) return {} as Info From 47da23170d36db29fda3bccd2274017b6bb8f9b7 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 14:06:06 +0200 Subject: [PATCH 04/18] chore(cli): stabilize kilocode_change block around tui substitute --- packages/opencode/src/cli/cmd/tui/config/tui.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index bbb96d5ce8..2597068d22 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -116,12 +116,11 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: const load = (text: string, configFilepath: string, trusted: boolean): Effect.Effect => // kilocode_change end Effect.gen(function* () { - const expanded = yield* Effect.promise( - () => - // kilocode_change start - only trusted (global/explicit) tui config may resolve {file:}/{env:} tokens - ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", trusted }), - // kilocode_change end + // kilocode_change start - only trusted tui config resolves {file:}/{env:} tokens + const expanded = yield* Effect.promise(() => + ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", trusted }), ) + // kilocode_change end const data = ConfigParse.jsonc(expanded, configFilepath) if (!isRecord(data)) return {} as Info // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json From 6dfbc54c855a3209b26b3e688a83277510d636fd Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 14:37:42 +0200 Subject: [PATCH 05/18] refactor(cli): root-scope project {file:} instead of blocking; keep {env:} block Adopt the file-scoping approach from #11883: untrusted project config may still read {file:...} as long as the target stays inside the project root (absolute paths, ../ traversal, and symlink escapes are rejected via realpath). Keep {env:} fully blocked in project config (no safe scoped form). Make the /proc/self/fd guard cross-platform. Thread fileScope through config.ts, agent.ts, tui.ts, and overlay.ts. Update docs and changeset. --- .changeset/config-file-substitution-trust.md | 2 +- .../code-with-ai/agents/custom-models.md | 6 +- .../pages/code-with-ai/platforms/cli.md | 4 + .../opencode/src/cli/cmd/tui/config/tui.ts | 31 +++++--- packages/opencode/src/config/agent.ts | 9 ++- packages/opencode/src/config/config.ts | 26 +++++-- packages/opencode/src/config/variable.ts | 46 +++++++---- .../opencode/src/kilocode/config/overlay.ts | 28 ++++--- .../opencode/src/kilocode/config/variable.ts | 32 ++++++-- packages/opencode/test/config/config.test.ts | 76 ++++++++++++++++--- packages/opencode/test/config/tui.test.ts | 41 ++++++++-- .../test/kilocode/config/variable.test.ts | 46 +++++++---- 12 files changed, 264 insertions(+), 83 deletions(-) diff --git a/.changeset/config-file-substitution-trust.md b/.changeset/config-file-substitution-trust.md index 3ae377de41..3bbc2bfd35 100644 --- a/.changeset/config-file-substitution-trust.md +++ b/.changeset/config-file-substitution-trust.md @@ -2,4 +2,4 @@ "@kilocode/cli": patch --- -Prevent project config from reading local files or environment variables via `{file:...}` / `{env:...}` references. These references now resolve only in trusted user-owned config (global config, `KILO_CONFIG`, well-known org config), closing a path where a malicious project could exfiltrate local files through a provider API key. +Harden config credential substitution against untrusted project config. Environment references (`{env:VAR}`) now resolve only in trusted config (global config, `KILO_CONFIG`, `KILO_CONFIG_CONTENT`, and org/MDM-managed config); a project-committed `kilo.json` / `opencode.json` can no longer use them. File references (`{file:...}`) still work in project config but are confined to the project root, so absolute paths, `../` traversal, and symlink escapes are rejected. This closes a path where a malicious repository could exfiltrate local secrets to an attacker-controlled `baseURL`. diff --git a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md index 103043b644..3793825dd3 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md @@ -385,8 +385,12 @@ You can also set options that apply to all models from a provider: | Option | Type | Description | |---|---|---| -| `apiKey` | `string` | API key (supports `{env:VAR}` syntax) | +| `apiKey` | `string` | API key (supports `{env:VAR}` and `{file:...}` syntax in trusted config — see note below) | | `baseURL` | `string` | Override the provider's base API URL | + +{% callout type="warning" title="{env:} / {file:} only resolve in trusted config" %} +`{env:VAR}` and `{file:...}` references in `apiKey` (or any option) are resolved **only** when the config lives in a trusted location: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** resolve `{env:VAR}` — the reference is ignored and a warning is logged, so a provider configured this way in a repo will not authenticate. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` just by being opened. `{file:...}` still works in project config, but only for files that stay inside the project root (absolute paths, `../` traversal, and symlink escapes are rejected). Keep provider credentials in your global config. +{% /callout %} | `timeout` | `number \| false` | Request timeout in milliseconds. Defaults to `300000` (5 minutes); set to `false` to disable | | `chunkTimeout` | `number` | Timeout in milliseconds between streamed response chunks. If no chunk arrives within this window, the request is aborted and retried. This catches silent provider dropouts where the TCP connection stays open but SSE streaming stops. Recommended: `15000`–`30000` (15–30 seconds) for providers with unreliable streaming. | diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index 9e68a71bdd..dcf1cf0eb5 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -464,6 +464,10 @@ Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variab } ``` +{% callout type="warning" title="Only works in trusted config" %} +`{env:VAR}` (and `{file:...}`) references are resolved **only** in trusted config: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** use `{env:VAR}` — the reference is ignored and a warning is logged. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` simply by being opened. `{file:...}` still works in project config, but only for files that stay inside the project root. +{% /callout %} + For full details on all configuration options including compaction, file watchers, plugins, and experimental features, see the [OpenCode Config documentation](https://opencode.ai/docs/config). ## Interactive Mode diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 2597068d22..035db497f2 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -112,13 +112,18 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: return config }) - // kilocode_change start - trusted controls whether tui config may resolve {file:}/{env:} tokens - const load = (text: string, configFilepath: string, trusted: boolean): Effect.Effect => + // kilocode_change start - trusted gates {env:}; fileScope confines untrusted {file:} reads + const load = ( + text: string, + configFilepath: string, + trusted: boolean, + fileScope?: ConfigVariable.FileScope, + ): Effect.Effect => // kilocode_change end Effect.gen(function* () { - // kilocode_change start - only trusted tui config resolves {file:}/{env:} tokens + // kilocode_change start - only trusted tui config resolves {env:}; untrusted {file:} confined to fileScope const expanded = yield* Effect.promise(() => - ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", trusted }), + ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", trusted, fileScope }), ) // kilocode_change end const data = ConfigParse.jsonc(expanded, configFilepath) @@ -153,8 +158,8 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ), ) - // kilocode_change start - trusted param threaded to load - const loadFile = (filepath: string, trusted: boolean): Effect.Effect => + // kilocode_change start - trusted + fileScope threaded to load + const loadFile = (filepath: string, trusted: boolean, fileScope?: ConfigVariable.FileScope): Effect.Effect => // kilocode_change end Effect.gen(function* () { // Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip. @@ -175,14 +180,14 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ) if (!text) return {} as Info log.info("loading tui config", { path: filepath }) - return yield* load(text, filepath, trusted) // kilocode_change + return yield* load(text, filepath, trusted, fileScope) // kilocode_change }) - // kilocode_change start - trusted param threaded to loadFile - const mergeFile = (acc: Acc, file: string, trusted: boolean) => + // kilocode_change start - trusted + fileScope threaded to loadFile + const mergeFile = (acc: Acc, file: string, trusted: boolean, fileScope?: ConfigVariable.FileScope) => // kilocode_change end Effect.gen(function* () { - const data = yield* loadFile(file, trusted) // kilocode_change + const data = yield* loadFile(file, trusted, fileScope) // kilocode_change if (Object.keys(data).length) { appliedOrder += 1 log.info("applying tui config", { path: file, order: appliedOrder }) @@ -227,7 +232,8 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // 3. Project tui files, applied root-first so the closest file wins. for (const file of projectFiles) { - yield* mergeFile(acc, file, false) // kilocode_change - project config is untrusted + // kilocode_change - project config is untrusted: {env:} rejected, {file:} confined to the project dir + yield* mergeFile(acc, file, false, { root: ctx.directory, source: file }) } // kilocode_change start - load tui.json from supported Kilo config directories @@ -242,8 +248,9 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: for (const dir of dirs) { // kilocode_change start - trust global (home/KILO_CONFIG_DIR) dirs like config.ts; in-repo .kilo/.kilocode stay untrusted const trusted = pluginScope(dir, ctx) === "global" + const fileScope = trusted ? undefined : { root: ctx.directory, source: dir } for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { - yield* mergeFile(acc, file, trusted) + yield* mergeFile(acc, file, trusted, fileScope) } // kilocode_change end } diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index aca3488488..8888b6b9cd 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -134,8 +134,8 @@ export const Info = AgentSchema.pipe( ).annotate({ identifier: "AgentConfig" }) export type Info = Schema.Schema.Type -// kilocode_change start - trusted controls whether agent prompts may resolve {file:}/{env:} tokens -export async function load(dir: string, warnings?: Warning[], trusted?: boolean) { +// kilocode_change start - trusted gates {env:}; fileScope confines untrusted agent prompt {file:} reads +export async function load(dir: string, warnings?: Warning[], trusted?: boolean, fileScope?: ConfigVariable.FileScope) { // kilocode_change end const result: Record = {} for (const item of await Glob.scan("{agent,agents}/**/*.md", { @@ -176,7 +176,10 @@ export async function load(dir: string, warnings?: Warning[], trusted?: boolean) source: item, missing: "empty", escapeJson: false, - trusted, // kilocode_change - project agents must not resolve file/env references + // kilocode_change start - project agents: no env, files confined to fileScope.root + trusted, + fileScope, + // kilocode_change end }) const config = { name, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 48484f1269..f70f99b375 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -568,14 +568,17 @@ export const layer = Layer.effect( text: string, options: { path: string } | { dir: string; source: string }, env?: Record, - trusted?: boolean, // kilocode_change - only user-owned config may resolve {file:}/{env:} tokens + // kilocode_change start - trusted allows {env:}; fileScope confines untrusted {file:} reads to a root + trusted?: boolean, + fileScope?: ConfigVariable.FileScope, + // kilocode_change end ) { const source = "path" in options ? options.path : options.source const expanded = yield* Effect.promise(() => ConfigVariable.substitute( "path" in options - ? { text, type: "path", path: options.path, env, trusted } // kilocode_change - : { text, type: "virtual", ...options, env, trusted }, // kilocode_change + ? { text, type: "path", path: options.path, env, trusted, fileScope } // kilocode_change + : { text, type: "virtual", ...options, env, trusted, fileScope }, // kilocode_change ), ) const parsed = ConfigParse.jsonc(expanded, source) @@ -597,11 +600,12 @@ export const layer = Layer.effect( filepath: string, env?: Record, trusted?: boolean, // kilocode_change + fileScope?: ConfigVariable.FileScope, // kilocode_change ) { log.info("loading", { path: filepath }) const text = yield* readConfigFile(filepath) if (!text) return {} as Info - return yield* loadConfig(text, { path: filepath }, env, trusted) // kilocode_change + return yield* loadConfig(text, { path: filepath }, env, trusted, fileScope) // kilocode_change }) let globalStamp = "" // kilocode_change @@ -709,6 +713,8 @@ export const layer = Layer.effect( function* (ctx: InstanceContext) { // kilocode_change start - warning accumulator and legacy Kilo config const warnings: Warning[] = [] + // Untrusted project config may only read files inside this root (worktree, or directory for non-git projects). + const projectRoot = ctx.worktree === "/" ? ctx.directory : ctx.worktree const auth = yield* authSvc.all().pipe(Effect.orDie) let result: Info = {} @@ -860,8 +866,8 @@ export const layer = Layer.effect( for (const file of yield* ConfigPaths.files(name, ctx.directory, ctx.worktree).pipe(Effect.orDie)) { yield* merge( file, - // kilocode_change - project config is untrusted: {file:}/{env:} tokens are rejected - yield* loadFile(file, authEnv, false).pipe( + // kilocode_change - project config is untrusted: {env:} rejected, {file:} confined to projectRoot + yield* loadFile(file, authEnv, false, { root: projectRoot, source: file }).pipe( Effect.catchDefect((err: unknown) => { caughtWarning(warnings, file, err) return Effect.succeed({} as Info) @@ -900,13 +906,17 @@ export const layer = Layer.effect( // kilocode_change - trust {file:}/{env:} only for global-scoped config dirs, never project ones const dirScope = scope ?? (yield* pluginScopeForSource(dir)) const dirTrusted = dirScope === "global" + // kilocode_change - untrusted config dirs confine {file:} reads to projectRoot + const dirFileScope = dirTrusted ? undefined : { root: projectRoot, source: dir } if (KilocodeConfig.isConfigDir(dir, Flag.KILO_CONFIG_DIR)) { for (const file of KilocodeConfig.ALL_CONFIG_FILES) { const source = path.join(dir, file) log.debug(`loading config from ${source}`) + // kilocode_change - untrusted config dirs confine {file:} reads to projectRoot + const fileScope = dirTrusted ? undefined : { root: projectRoot, source } yield* merge( source, - yield* loadFile(source, authEnv, dirTrusted).pipe( + yield* loadFile(source, authEnv, dirTrusted, fileScope).pipe( // kilocode_change Effect.catchDefect((err: unknown) => { caughtWarning(warnings, source, err) @@ -954,7 +964,7 @@ export const layer = Layer.effect( ) result.agent = mergeDeep( result.agent ?? {}, - yield* Effect.promise(() => ConfigAgent.load(dir, warnings, dirTrusted)), // kilocode_change + yield* Effect.promise(() => ConfigAgent.load(dir, warnings, dirTrusted, dirFileScope)), // kilocode_change ) result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir, warnings))) // kilocode_change end diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index 3c05b27d3d..d39a51b26f 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -17,12 +17,17 @@ type ParseSource = dir: string } +// kilocode_change start +export type FileScope = ConfigVariableGuard.FileScope +// kilocode_change end + type SubstituteInput = ParseSource & { text: string missing?: "error" | "empty" escapeJson?: boolean // kilocode_change - // kilocode_change start - only trusted (user-owned) sources may reference files/env; project config cannot + // kilocode_change start - trust gates {env:}; untrusted project config may only read files inside fileScope.root trusted?: boolean + fileScope?: ConfigVariableGuard.FileScope // kilocode_change end env?: Record } @@ -46,23 +51,33 @@ function commented(text: string, index: number) { export async function substitute(input: SubstituteInput) { const missing = input.missing ?? "error" const escape = input.escapeJson ?? true // kilocode_change - // kilocode_change start - only trusted (user-owned) config may read files or environment variables. - // Untrusted project config could otherwise exfiltrate arbitrary local files via a provider apiKey. - if (!(input.trusted ?? false)) { - const active = Array.from(input.text.matchAll(/\{(?:env|file):[^}]+\}/g)).find( - (m) => !commented(input.text, m.index), - ) + // kilocode_change start - untrusted (project) config cannot read environment variables. {env:} has no safe + // scoped form, so it is rejected outright; {file:} is allowed but confined to fileScope.root below. + const trusted = input.trusted ?? false + if (!trusted) { + const active = Array.from(input.text.matchAll(/\{env:[^}]+\}/g)).find((m) => !commented(input.text, m.index)) if (active) { throw new InvalidError({ path: source(input), - message: `file and environment references are not allowed in project config: "${active[0]}"`, + message: `environment references are not allowed in project config: "${active[0]}"`, }) } - return input.text + // Secure default: untrusted config must supply a fileScope to read files. Without one, {file:} is rejected + // rather than allowed unrestricted, so a caller that forgets the scope cannot reopen the exfiltration path. + if (!input.fileScope) { + const file = Array.from(input.text.matchAll(/\{file:[^}]+\}/g)).find((m) => !commented(input.text, m.index)) + if (file) { + throw new InvalidError({ + path: source(input), + message: `file references are not allowed in project config: "${file[0]}"`, + }) + } + } } // kilocode_change end - let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => { - // kilocode_change start - reject server credentials instead of silently changing config semantics + let text = input.text.replace(/\{env:([^}]+)\}/g, (match, varName, offset: number) => { + // kilocode_change start - leave commented tokens literal; reject server credentials + if (commented(input.text, offset)) return match if (!ConfigVariableGuard.env(varName)) { throw new InvalidError({ path: source(input), message: `blocked environment reference: "{env:${varName}}"` }) } @@ -97,9 +112,14 @@ 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 + // kilocode_change start - validate and read one opened file to prevent credential substitution races; + // untrusted config passes a fileScope so reads are confined to the project root. const fileContent = ( - await ConfigVariableGuard.read(resolvedPath, Filesystem.readText).catch((error: NodeJS.ErrnoException) => { + 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}"` diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index 3e48ec8151..ef6aae17ba 100644 --- a/packages/opencode/src/kilocode/config/overlay.ts +++ b/packages/opencode/src/kilocode/config/overlay.ts @@ -119,7 +119,9 @@ export namespace KilocodeConfigOverlay { export async function project(input: { directory: string; worktree?: string }): Promise { const found = await projectFiles(input) - const configs = await Promise.all(found.map(load)) + // kilocode_change - project config is untrusted; confine {file:} reads to the project root + const root = input.worktree && input.worktree !== "/" ? input.worktree : input.directory + const configs = await Promise.all(found.map((file) => load(file, { root, source: file }))) return configs.reduce((result, cfg) => KilocodeConfig.mergeConfig(result, cfg), {} as Config.Info) } @@ -138,8 +140,11 @@ export namespace KilocodeConfigOverlay { } export async function resolve(input: Input): Promise { - const local = await withAgents(await project(input), await projectDirs(input), false) // kilocode_change - project agents untrusted - const global = await withAgents(input.global, globalDirs(), true) // kilocode_change - global agents trusted + // kilocode_change start - project agents untrusted, {file:} confined to the project root; global agents trusted + const root = input.worktree && input.worktree !== "/" ? input.worktree : input.directory + const local = await withAgents(await project(input), await projectDirs(input), false, root) + const global = await withAgents(input.global, globalDirs(), true) + // kilocode_change end const targets = { global: globalTarget(), project: await projectTarget(input), @@ -185,20 +190,23 @@ export namespace KilocodeConfigOverlay { return [Global.Path.config, path.join(Global.Path.home, ".kilocode"), path.join(Global.Path.home, ".kilo")] } - async function withAgents(input: Config.Info, dirs: string[], trusted: boolean): Promise { + // kilocode_change start - root confines untrusted agent {file:} reads + async function withAgents(input: Config.Info, dirs: string[], trusted: boolean, root?: string): Promise { const [dir, ...rest] = dirs if (!dir) return input - if (!existsSync(dir)) return withAgents(input, rest, trusted) - const agent = await ConfigAgent.load(dir, undefined, trusted) // kilocode_change + if (!existsSync(dir)) return withAgents(input, rest, trusted, root) + const fileScope = trusted || !root ? undefined : { root, source: dir } + const agent = await ConfigAgent.load(dir, undefined, trusted, fileScope) const mode = await ConfigAgent.loadMode(dir) const next = KilocodeConfig.mergeConfig(KilocodeConfig.mergeConfig(input, { agent }), { agent: mode }) - return withAgents(next, rest, trusted) + return withAgents(next, rest, trusted, root) } + // kilocode_change end - async function load(file: string): Promise { + async function load(file: string, fileScope?: ConfigVariable.FileScope): Promise { const text = await Bun.file(file).text() - // kilocode_change - overlay reads project config files; {file:}/{env:} references are untrusted here - const expanded = await ConfigVariable.substitute({ text, type: "path", path: file, trusted: false }) + // 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 }) const parsed = ConfigParse.jsonc(expanded, file) if (!isRecord(parsed)) return {} return ConfigParse.schema(Config.Info, parsed, file) as Config.Info diff --git a/packages/opencode/src/kilocode/config/variable.ts b/packages/opencode/src/kilocode/config/variable.ts index 6575a8d858..c1bbf8d57e 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 d34c928167..c81de67060 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -547,7 +547,7 @@ it.instance("prefers .kilo directory config over legacy .kilocode", () => ) // kilocode_change end -// kilocode_change start - project config is untrusted: {env:}/{file:} references must not resolve +// kilocode_change start - project config is untrusted: {env:} rejected; {file:} confined to the project root it.instance("rejects environment variable substitution in project config", () => withProcessEnv( "TEST_VAR", @@ -566,40 +566,94 @@ it.instance("rejects environment variable substitution in project config", () => ), ) -it.instance("does not leak file contents via {file:} in project config", () => +it.instance("allows {file:} that stays inside the project root", () => Effect.gen(function* () { const test = yield* TestInstance - yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.txt"), "top-secret") + yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.txt"), "in-project") yield* writeConfigEffect(test.directory, { $schema: "https://app.kilo.ai/config.json", username: "{file:included.txt}", }) const config = yield* Config.use.get() - expect(config.username).not.toBe("top-secret") - const issues = yield* Config.Service.use((svc) => svc.warnings()) - expect(issues.length).toBeGreaterThan(0) + expect(config.username).toBe("in-project") }), ) -it.instance("rejects {file:} exfiltration of provider apiKey in project config", () => +it.instance("rejects {file:} that reads an absolute path from project config", () => Effect.gen(function* () { const test = yield* TestInstance - yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "secret.txt"), "leaked-credential") + 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:") + }), +) + +it.instance("rejects {file:} that escapes the project root 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("rejects {file:} that escapes the project root 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("blocks provider apiKey {file:} exfiltration that escapes the project root", () => + Effect.gen(function* () { + const test = yield* TestInstance + const outside = path.join(path.dirname(test.directory), "creds.txt") + yield* AppFileSystem.use.writeWithDirs(outside, "leaked-credential") yield* writeConfigEffect(test.directory, { $schema: "https://app.kilo.ai/config.json", provider: { "openai-compatible": { - options: { baseURL: "http://127.0.0.1:4444/v1", apiKey: "{file:secret.txt}" }, + options: { baseURL: "http://127.0.0.1:4444/v1", apiKey: "{file:../creds.txt}" }, models: { "test-model": { name: "Test Model" } }, }, }, }) const config = yield* Config.use.get() expect(JSON.stringify(config.provider ?? {})).not.toContain("leaked-credential") - const issues = yield* Config.Service.use((svc) => svc.warnings()) - expect(issues.length).toBeGreaterThan(0) }), ) + +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({ diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index a1e611257c..c58fbc4285 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -702,7 +702,7 @@ it.instance("applies env and file substitutions in global tui.json", () => ), ) -it.instance("does not substitute env or file references in untrusted project tui.json", () => +it.instance("does not substitute env references in untrusted project tui.json", () => withCleanState( withEnv( "TUI_THEME_TEST", @@ -710,20 +710,51 @@ it.instance("does not substitute env or file references in untrusted project tui Effect.gen(function* () { const fs = yield* AppFileSystem.Service const test = yield* TestInstance - yield* fs.writeFileString(path.join(test.directory, "keybind.txt"), "ctrl+q") yield* fs.writeJson(path.join(test.directory, "tui.json"), { theme: "{env:TUI_THEME_TEST}", - keybinds: { app_exit: "{file:keybind.txt}" }, }) - // Project tui config with references is rejected and skipped, so nothing is substituted. + // {env:} in project config is rejected, so the file is skipped and the theme is not applied. const config = yield* getTuiConfig(test.directory) expect(config.theme).not.toBe("env-theme") - expect(config.keybinds.get("app.exit")?.[0]?.key).not.toBe("ctrl+q") }), ), ), ) + +it.instance("applies in-project file references in project tui.json", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + // {file:} that stays inside the project root is allowed even in untrusted project config. + yield* fs.writeFileString(path.join(test.directory, "keybind.txt"), "ctrl+q") + yield* fs.writeJson(path.join(test.directory, "tui.json"), { + keybinds: { app_exit: "{file:keybind.txt}" }, + }) + + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") + }), + ), +) + +it.instance("rejects project tui.json file references that escape the project root", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + const outside = path.join(path.dirname(test.directory), "keybind.txt") + yield* fs.writeFileString(outside, "ctrl+q") + yield* fs.writeJson(path.join(test.directory, "tui.json"), { + keybinds: { app_exit: "{file:../keybind.txt}" }, + }) + + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("app.exit")?.[0]?.key).not.toBe("ctrl+q") + }), + ), +) // kilocode_change end it.instance("applies file substitutions when first identical token is in a commented line", () => diff --git a/packages/opencode/test/kilocode/config/variable.test.ts b/packages/opencode/test/kilocode/config/variable.test.ts index c8131c0d25..9e792dba76 100644 --- a/packages/opencode/test/kilocode/config/variable.test.ts +++ b/packages/opencode/test/kilocode/config/variable.test.ts @@ -8,22 +8,42 @@ import { InvalidError } from "@/config/error" const source = { type: "virtual" as const, source: "test", dir: process.cwd() } const trusted = { ...source, trusted: true } -test("rejects file references in untrusted (project) config", async () => { - await expect( - ConfigVariable.substitute({ ...source, text: "apiKey={file:/etc/passwd}" }), - ).rejects.toBeInstanceOf(InvalidError) +test("rejects file references in untrusted config without a fileScope", async () => { + await expect(ConfigVariable.substitute({ ...source, text: "apiKey={file:/etc/passwd}" })).rejects.toBeInstanceOf( + InvalidError, + ) }) -test("rejects file references when trusted is omitted (secure by default)", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-untrusted-")) - const file = path.join(dir, "secret") +test("rejects untrusted file references that escape the scope root", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-root-")) + const outside = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-outside-")) + const file = path.join(outside, "secret") await fs.writeFile(file, "top-secret") try { - await expect(ConfigVariable.substitute({ ...source, text: `{file:${file}}` })).rejects.toBeInstanceOf( - InvalidError, - ) + await expect( + ConfigVariable.substitute({ ...source, text: `{file:${file}}`, fileScope: { root, source: "test" } }), + ).rejects.toBeInstanceOf(InvalidError) } finally { - await fs.rm(dir, { recursive: true, force: true }) + await fs.rm(root, { recursive: true, force: true }) + await fs.rm(outside, { recursive: true, force: true }) + } +}) + +test("allows untrusted file references that stay inside the scope root", async () => { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-inside-"))) + const file = path.join(root, "value") + await fs.writeFile(file, "allowed") + try { + expect( + await ConfigVariable.substitute({ + ...source, + dir: root, + text: "{file:value}", + fileScope: { root, source: path.join(root, "kilo.json") }, + }), + ).toBe("allowed") + } finally { + await fs.rm(root, { recursive: true, force: true }) } }) @@ -86,9 +106,7 @@ test.skipIf(process.platform !== "linux")("does not substitute an environment fi const link = path.join(dir, "value") await fs.symlink("/proc/self/environ", link) try { - await expect(ConfigVariable.substitute({ ...trusted, text: `{file:${link}}` })).rejects.toBeInstanceOf( - InvalidError, - ) + await expect(ConfigVariable.substitute({ ...trusted, text: `{file:${link}}` })).rejects.toBeInstanceOf(InvalidError) } finally { await fs.rm(dir, { recursive: true, force: true }) } From ebb0029d88e2bb061c807c4d6fdca489b93e3c37 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 2 Jul 2026 14:38:13 +0200 Subject: [PATCH 06/18] chore(cli): inline kilocode_change marker on project tui mergeFile --- packages/opencode/src/cli/cmd/tui/config/tui.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 035db497f2..8289b559b9 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -232,8 +232,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // 3. Project tui files, applied root-first so the closest file wins. for (const file of projectFiles) { - // kilocode_change - project config is untrusted: {env:} rejected, {file:} confined to the project dir - yield* mergeFile(acc, file, false, { root: ctx.directory, source: file }) + yield* mergeFile(acc, file, false, { root: ctx.directory, source: file }) // kilocode_change - untrusted, {file:} confined to project } // kilocode_change start - load tui.json from supported Kilo config directories From 1d49502b7759941f40ed6aa72fd346183202fce8 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 6 Jul 2026 09:51:26 +0200 Subject: [PATCH 07/18] fix(cli): read config file references through the pinned fd to close TOCTOU race On non-Linux, read() validated the target via realpath but re-read by path, letting an attacker swap the file between the check and the read. Read through the already-open FileHandle (file.readFile) on every platform so the validated inode is the one read. Drops the now-unused load callback. --- packages/opencode/src/config/variable.ts | 35 +++++++++---------- .../opencode/src/kilocode/config/variable.ts | 11 +++--- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index d39a51b26f..7ff3f82728 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -2,7 +2,6 @@ export * as ConfigVariable from "./variable" import path from "path" import os from "os" -import { Filesystem } from "@/util/filesystem" import { InvalidError } from "./error" import { ConfigVariableGuard } from "@/kilocode/config/variable" // kilocode_change @@ -115,25 +114,23 @@ export async function substitute(input: SubstituteInput) { // kilocode_change start - validate and read one opened file to prevent credential substitution races; // untrusted config passes a fileScope so reads are confined to the project root. const fileContent = ( - await ConfigVariableGuard.read( - resolvedPath, - Filesystem.readText, - input.fileScope && { ...input.fileScope, token }, - ).catch((error: NodeJS.ErrnoException) => { - if (missing === "empty") return "" + await ConfigVariableGuard.read(resolvedPath, 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 }) - }) + 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 c1bbf8d57e..9a2cc2d2e7 100644 --- a/packages/opencode/src/kilocode/config/variable.ts +++ b/packages/opencode/src/kilocode/config/variable.ts @@ -26,18 +26,17 @@ export namespace ConfigVariableGuard { 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 }, - ) { + export async function read(filePath: string, scope?: FileScope & { token?: string }) { const file = await fs.open(filePath, "r") try { + // Resolve and validate the file the fd actually points at. On Linux /proc/self/fd pins the fd; on other + // platforms we realpath the path. Either way the subsequent read is done through the same open fd + // (file.readFile), never by re-opening the path, so the validated inode is the one we read (no TOCTOU race). 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(process.platform === "linux" ? target : resolved) + return await file.readFile("utf-8") } finally { await file.close() } From e9f6b0208438305b72edcfea63e6c498de6e5403 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 6 Jul 2026 09:52:05 +0200 Subject: [PATCH 08/18] docs: move trusted-config callout below the provider options table The warning callout was inserted between table rows, breaking the timeout/chunkTimeout rows' rendering. Move it after the full table. --- packages/kilo-docs/pages/code-with-ai/agents/custom-models.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md index 3793825dd3..4dc20ddfa3 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md @@ -387,12 +387,12 @@ You can also set options that apply to all models from a provider: |---|---|---| | `apiKey` | `string` | API key (supports `{env:VAR}` and `{file:...}` syntax in trusted config — see note below) | | `baseURL` | `string` | Override the provider's base API URL | +| `timeout` | `number \| false` | Request timeout in milliseconds. Defaults to `300000` (5 minutes); set to `false` to disable | +| `chunkTimeout` | `number` | Timeout in milliseconds between streamed response chunks. If no chunk arrives within this window, the request is aborted and retried. This catches silent provider dropouts where the TCP connection stays open but SSE streaming stops. Recommended: `15000`–`30000` (15–30 seconds) for providers with unreliable streaming. | {% callout type="warning" title="{env:} / {file:} only resolve in trusted config" %} `{env:VAR}` and `{file:...}` references in `apiKey` (or any option) are resolved **only** when the config lives in a trusted location: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** resolve `{env:VAR}` — the reference is ignored and a warning is logged, so a provider configured this way in a repo will not authenticate. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` just by being opened. `{file:...}` still works in project config, but only for files that stay inside the project root (absolute paths, `../` traversal, and symlink escapes are rejected). Keep provider credentials in your global config. {% /callout %} -| `timeout` | `number \| false` | Request timeout in milliseconds. Defaults to `300000` (5 minutes); set to `false` to disable | -| `chunkTimeout` | `number` | Timeout in milliseconds between streamed response chunks. If no chunk arrives within this window, the request is aborted and retried. This catches silent provider dropouts where the TCP connection stays open but SSE streaming stops. Recommended: `15000`–`30000` (15–30 seconds) for providers with unreliable streaming. | ## Filtering Available Models From 47a40fe7f78c524d3b97973c71b166fe4bbde832 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 6 Jul 2026 10:02:29 +0200 Subject: [PATCH 09/18] 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. --- .../opencode/src/kilocode/config/overlay.ts | 14 +++++++++++++ .../kilocode/server/config-overlay.test.ts | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index ef6aae17ba..4d67a66e85 100644 --- a/packages/opencode/src/kilocode/config/overlay.ts +++ b/packages/opencode/src/kilocode/config/overlay.ts @@ -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 @@ -204,6 +207,17 @@ export namespace KilocodeConfigOverlay { // kilocode_change end async function load(file: string, fileScope?: ConfigVariable.FileScope): Promise { + // 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 { + // 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 }) diff --git a/packages/opencode/test/kilocode/server/config-overlay.test.ts b/packages/opencode/test/kilocode/server/config-overlay.test.ts index 6767b9fa01..89fa6da213 100644 --- a/packages/opencode/test/kilocode/server/config-overlay.test.ts +++ b/packages/opencode/test/kilocode/server/config-overlay.test.ts @@ -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() From faa2bae1044e91c5b36ee1305e2319883d3eb3c1 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 6 Jul 2026 10:03:15 +0200 Subject: [PATCH 10/18] docs: clarify project {file:} rejects paths that escape the project root In-root absolute paths are intentionally allowed; only references that leave the root (absolute paths outside it, ../ traversal, symlinks) are rejected. Fix the docs wording and add a regression test for the in-root absolute case. --- .../code-with-ai/agents/custom-models.md | 2 +- .../pages/code-with-ai/platforms/cli.md | 2 +- .../test/kilocode/config/variable.test.ts | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md index 4dc20ddfa3..d489dc63eb 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md @@ -391,7 +391,7 @@ You can also set options that apply to all models from a provider: | `chunkTimeout` | `number` | Timeout in milliseconds between streamed response chunks. If no chunk arrives within this window, the request is aborted and retried. This catches silent provider dropouts where the TCP connection stays open but SSE streaming stops. Recommended: `15000`–`30000` (15–30 seconds) for providers with unreliable streaming. | {% callout type="warning" title="{env:} / {file:} only resolve in trusted config" %} -`{env:VAR}` and `{file:...}` references in `apiKey` (or any option) are resolved **only** when the config lives in a trusted location: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** resolve `{env:VAR}` — the reference is ignored and a warning is logged, so a provider configured this way in a repo will not authenticate. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` just by being opened. `{file:...}` still works in project config, but only for files that stay inside the project root (absolute paths, `../` traversal, and symlink escapes are rejected). Keep provider credentials in your global config. +`{env:VAR}` and `{file:...}` references in `apiKey` (or any option) are resolved **only** when the config lives in a trusted location: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** resolve `{env:VAR}` — the reference is ignored and a warning is logged, so a provider configured this way in a repo will not authenticate. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` just by being opened. `{file:...}` still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. Keep provider credentials in your global config. {% /callout %} ## Filtering Available Models diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index dcf1cf0eb5..750653c2f0 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -465,7 +465,7 @@ Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variab ``` {% callout type="warning" title="Only works in trusted config" %} -`{env:VAR}` (and `{file:...}`) references are resolved **only** in trusted config: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** use `{env:VAR}` — the reference is ignored and a warning is logged. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` simply by being opened. `{file:...}` still works in project config, but only for files that stay inside the project root. +`{env:VAR}` (and `{file:...}`) references are resolved **only** in trusted config: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** use `{env:VAR}` — the reference is ignored and a warning is logged. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` simply by being opened. `{file:...}` still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. {% /callout %} For full details on all configuration options including compaction, file watchers, plugins, and experimental features, see the [OpenCode Config documentation](https://opencode.ai/docs/config). diff --git a/packages/opencode/test/kilocode/config/variable.test.ts b/packages/opencode/test/kilocode/config/variable.test.ts index 9e792dba76..5cbf347ce6 100644 --- a/packages/opencode/test/kilocode/config/variable.test.ts +++ b/packages/opencode/test/kilocode/config/variable.test.ts @@ -47,6 +47,25 @@ test("allows untrusted file references that stay inside the scope root", async ( } }) +test("allows untrusted absolute file references that resolve inside the scope root", async () => { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-abs-inside-"))) + const file = path.join(root, "value") + await fs.writeFile(file, "allowed") + try { + // An absolute path is fine as long as it stays inside the root; only escapes are rejected. + expect( + await ConfigVariable.substitute({ + ...source, + dir: root, + text: `{file:${file}}`, + fileScope: { root, source: path.join(root, "kilo.json") }, + }), + ).toBe("allowed") + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + test("rejects environment references in untrusted (project) config", async () => { await expect( ConfigVariable.substitute({ ...source, text: "value={env:SAFE_VALUE}", env: { SAFE_VALUE: "allowed" } }), From c7f0ccf102e91355bfc6c1d8cd5fb4c70180ea2e Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 6 Jul 2026 10:40:59 +0200 Subject: [PATCH 11/18] fix(cli): close non-Linux scope-check TOCTOU by verifying fd matches validated path The read is fd-pinned, but on non-Linux the scope check realpath'd the caller's path independently of the fd, so an attacker could swap the path between open and check to validate an in-root inode while the fd pointed elsewhere. fstat the open fd and compare dev/ino against the resolved path; reject if they differ, so the inode we validate is the inode we read. --- .../opencode/src/kilocode/config/variable.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/kilocode/config/variable.ts b/packages/opencode/src/kilocode/config/variable.ts index 9a2cc2d2e7..fc4f4469a9 100644 --- a/packages/opencode/src/kilocode/config/variable.ts +++ b/packages/opencode/src/kilocode/config/variable.ts @@ -1,5 +1,5 @@ import fs from "node:fs/promises" -import { realpathSync } from "node:fs" +import { realpathSync, statSync } from "node:fs" import path from "node:path" export namespace ConfigVariableGuard { @@ -29,11 +29,23 @@ export namespace ConfigVariableGuard { export async function read(filePath: string, scope?: FileScope & { token?: string }) { const file = await fs.open(filePath, "r") try { - // Resolve and validate the file the fd actually points at. On Linux /proc/self/fd pins the fd; on other - // platforms we realpath the path. Either way the subsequent read is done through the same open fd - // (file.readFile), never by re-opening the path, so the validated inode is the one we read (no TOCTOU race). + // Resolve the file the fd actually points at, then validate the scope and read through the same fd + // (file.readFile) so the validated inode is exactly the one we read. + // + // On Linux /proc/self/fd/ is the kernel's canonical path for the open fd, so realpath + read both + // follow the fd — no path is re-resolved after open. On other platforms we cannot name the fd directly, + // so we realpath the caller's path and then confirm, via fstat vs. stat on that resolved path, that it + // still refers to the same inode as the open fd. If an attacker swapped the path between open and check, + // the inodes differ and we reject rather than validating one inode while reading another. const target = process.platform === "linux" ? `/proc/self/fd/${file.fd}` : filePath const resolved = realpathSync.native(target) + if (process.platform !== "linux" && scope) { + const opened = await file.stat() + const seen = statSync(resolved) + if (opened.dev !== seen.dev || opened.ino !== seen.ino) { + throw new Error(`blocked file reference changed during read: "${scope.token ?? "{file:...}"}"`) + } + } check(resolved, scope?.token ?? "{file:...}", scope) if (/^\/proc\/.*\/environ$/.test(resolved)) throw new Error("blocked process environment reference") return await file.readFile("utf-8") From 10e519d830c7898457db1a9392d4dea7eb4fd20d Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 6 Jul 2026 11:58:18 +0200 Subject: [PATCH 12/18] fix(cli): don't let a project agent substitution error break config loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfigAgent.load() awaited ConfigVariable.substitute without a catch, so a throw (untrusted {env:} or out-of-scope {file:} in a project agent prompt) propagated through Effect.promise and failed the whole config load. Catch it, record a warning, and skip only the offending agent — mirroring the existing frontmatter-parse handling and the project config-file loops. Scope stays JSON-config-loading; the markdown substitution path (KilocodeMarkdown.substitute) remains the separate follow-up in #11889. --- packages/opencode/src/config/agent.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 8888b6b9cd..f8efe7f932 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -168,7 +168,9 @@ export async function load(dir: string, warnings?: Warning[], trusted?: boolean, const name = configEntryNameFromPath(path.relative(dir, item), ["agent/", "agents/"]) - // kilocode_change start - substitute agent prompt variables relative to the agent file + // kilocode_change start - substitute agent prompt variables relative to the agent file. Project agents are + // untrusted (no {env:}, {file:} confined to fileScope.root); a rejected substitution must skip only this + // agent with a warning, not fail the whole config load, mirroring the frontmatter-parse handling above. const prompt = await ConfigVariable.substitute({ text: md.content.trim(), type: "virtual", @@ -176,11 +178,17 @@ export async function load(dir: string, warnings?: Warning[], trusted?: boolean, source: item, missing: "empty", escapeJson: false, - // kilocode_change start - project agents: no env, files confined to fileScope.root trusted, fileScope, - // kilocode_change end + }).catch((err): string | undefined => { + const message = + (ConfigError.InvalidError.isInstance(err) ? err.data.message : undefined) ?? + `Failed to substitute variables in agent ${item}` + if (warnings) warnings.push({ path: item, message }) + log.error("failed to substitute agent prompt", { agent: item, err }) + return undefined }) + if (prompt === undefined) continue const config = { name, ...md.data, From e06feff06cc195a1e5c190c53d3bc54f66569b55 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 6 Jul 2026 12:02:05 +0200 Subject: [PATCH 13/18] docs(cli): clarify {file:} rejection message is about a missing project scope The message said file references are 'not allowed in project config', but in-root file references ARE allowed when a fileScope is supplied (the normal project path). This branch only fires when no scope was provided, so reword it to reflect that specific case and update the comment. --- packages/opencode/src/config/variable.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index 7ff3f82728..a46c4bf7c1 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -61,14 +61,15 @@ export async function substitute(input: SubstituteInput) { message: `environment references are not allowed in project config: "${active[0]}"`, }) } - // Secure default: untrusted config must supply a fileScope to read files. Without one, {file:} is rejected - // rather than allowed unrestricted, so a caller that forgets the scope cannot reopen the exfiltration path. + // Secure default: untrusted config needs a fileScope to bound {file:} reads to the project root. Without a + // scope we cannot enforce that bound, so we reject rather than read unrestricted. In-root file references are + // still allowed when a scope is supplied (the normal project path); this only guards a caller that omitted it. if (!input.fileScope) { const file = Array.from(input.text.matchAll(/\{file:[^}]+\}/g)).find((m) => !commented(input.text, m.index)) if (file) { throw new InvalidError({ path: source(input), - message: `file references are not allowed in project config: "${file[0]}"`, + message: `file references cannot be resolved without a project scope: "${file[0]}"`, }) } } From 8dad071203152809a26ab93a2d03eac50f42afbc Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 6 Jul 2026 13:37:39 +0200 Subject: [PATCH 14/18] fix(cli): surface scope-blocked {file:} even under missing:empty; cover guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent prompts substitute with missing:"empty", which swallowed every file read error — including a deliberate out-of-scope scope block — so an escaping {file:} was silently emptied and never warned, contradicting the agent.ts catch narrative. Tag security blocks as ConfigVariableGuard.BlockedError (out-of-scope, fd swap, /proc) and, in substitute(), always reject those regardless of missing:"empty"; genuine missing/IO errors are still emptied. Now an out-of-scope {file:} in an agent prompt rejects, hits the agent catch, and records a warning. Adds guard/substitute tests for the block-under-missing:empty, missing-is-emptied, and BlockedError classification cases. --- packages/opencode/src/config/variable.ts | 5 ++ .../opencode/src/kilocode/config/variable.ts | 16 ++++- .../test/kilocode/config/variable.test.ts | 60 +++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index a46c4bf7c1..171ae796fd 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -117,6 +117,11 @@ export async function substitute(input: SubstituteInput) { const fileContent = ( await ConfigVariableGuard.read(resolvedPath, input.fileScope && { ...input.fileScope, token }).catch( (error: NodeJS.ErrnoException) => { + // kilocode_change - a deliberate scope block must always reject; only genuine missing/IO errors are + // emptied under missing:"empty", so an out-of-scope {file:} surfaces instead of being silently dropped. + if (ConfigVariableGuard.isBlocked(error)) { + throw new InvalidError({ path: configSource, message: error.message }, { cause: error }) + } if (missing === "empty") return "" const errMsg = `bad file reference: "${token}"` diff --git a/packages/opencode/src/kilocode/config/variable.ts b/packages/opencode/src/kilocode/config/variable.ts index fc4f4469a9..ab01d33499 100644 --- a/packages/opencode/src/kilocode/config/variable.ts +++ b/packages/opencode/src/kilocode/config/variable.ts @@ -8,6 +8,16 @@ export namespace ConfigVariableGuard { source: string } + // A deliberate security block (out-of-scope, swapped, or /proc) — distinct from a plain missing/IO error so + // callers using missing:"empty" still surface the block instead of silently emptying it. + export class BlockedError extends Error { + readonly blocked = true as const + } + + export function isBlocked(err: unknown): err is BlockedError { + return err instanceof BlockedError || (typeof err === "object" && err !== null && (err as any).blocked === true) + } + const secret = new Set(["KILO_SERVER_PASSWORD", "KILO_SERVER_USERNAME"]) export function env(name: string) { @@ -23,7 +33,7 @@ export namespace ConfigVariableGuard { 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}"`) + throw new BlockedError(`blocked file reference outside project config scope: "${token}"`) } export async function read(filePath: string, scope?: FileScope & { token?: string }) { @@ -43,11 +53,11 @@ export namespace ConfigVariableGuard { const opened = await file.stat() const seen = statSync(resolved) if (opened.dev !== seen.dev || opened.ino !== seen.ino) { - throw new Error(`blocked file reference changed during read: "${scope.token ?? "{file:...}"}"`) + throw new BlockedError(`blocked file reference changed during read: "${scope.token ?? "{file:...}"}"`) } } check(resolved, scope?.token ?? "{file:...}", scope) - if (/^\/proc\/.*\/environ$/.test(resolved)) throw new Error("blocked process environment reference") + if (/^\/proc\/.*\/environ$/.test(resolved)) throw new BlockedError("blocked process environment reference") return await file.readFile("utf-8") } finally { await file.close() diff --git a/packages/opencode/test/kilocode/config/variable.test.ts b/packages/opencode/test/kilocode/config/variable.test.ts index 5cbf347ce6..617e80fd6d 100644 --- a/packages/opencode/test/kilocode/config/variable.test.ts +++ b/packages/opencode/test/kilocode/config/variable.test.ts @@ -3,6 +3,7 @@ import os from "node:os" import path from "node:path" import { expect, test } from "bun:test" import { ConfigVariable } from "@/config/variable" +import { ConfigVariableGuard } from "@/kilocode/config/variable" import { InvalidError } from "@/config/error" const source = { type: "virtual" as const, source: "test", dir: process.cwd() } @@ -130,3 +131,62 @@ test.skipIf(process.platform !== "linux")("does not substitute an environment fi await fs.rm(dir, { recursive: true, force: true }) } }) + +// A deliberate scope block must surface even for callers that use missing:"empty" (e.g. agent prompts), +// rather than being silently emptied like a genuine missing/IO error. +test("scope-blocked file reference still rejects under missing:empty", async () => { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-empty-root-"))) + const outside = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-empty-out-")) + const file = path.join(outside, "secret") + await fs.writeFile(file, "top-secret") + try { + await expect( + ConfigVariable.substitute({ + ...source, + dir: root, + missing: "empty", + text: `{file:${file}}`, + fileScope: { root, source: path.join(root, "kilo.json") }, + }), + ).rejects.toBeInstanceOf(InvalidError) + } finally { + await fs.rm(root, { recursive: true, force: true }) + await fs.rm(outside, { recursive: true, force: true }) + } +}) + +// A genuine missing file under missing:"empty" is still emptied, not rejected. +test("missing (non-blocked) file reference is emptied under missing:empty", async () => { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-missing-"))) + try { + const out = await ConfigVariable.substitute({ + ...source, + dir: root, + missing: "empty", + text: "value={file:nope.txt}", + fileScope: { root, source: path.join(root, "kilo.json") }, + }) + expect(out).toBe("value=") + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +// The guard's BlockedError is classified by isBlocked (used to bypass missing:"empty"). +test("guard read rejects an out-of-scope file with a BlockedError", async () => { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "kilo-guard-root-"))) + const outside = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-guard-out-")) + const file = path.join(outside, "secret") + await fs.writeFile(file, "top-secret") + try { + const err = await ConfigVariableGuard.read(file, { root, source: "kilo.json", token: "{file:...}" }).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + expect(ConfigVariableGuard.isBlocked(err)).toBe(true) + } finally { + await fs.rm(root, { recursive: true, force: true }) + await fs.rm(outside, { recursive: true, force: true }) + } +}) From 40790d8139ea3a87b0b1ccf51339e2effb16ae67 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 7 Jul 2026 09:32:11 +0200 Subject: [PATCH 15/18] fix(cli): show remote badge in prompt status (#11976) --- .changeset/remote-tui-badge.md | 5 +++ .../src/cli/cmd/tui/plugin/internal.ts | 2 + .../src/kilocode/plugins/home-footer.tsx | 44 ++++--------------- .../opencode/src/kilocode/plugins/remote.tsx | 32 ++++++++++++++ 4 files changed, 48 insertions(+), 35 deletions(-) create mode 100644 .changeset/remote-tui-badge.md create mode 100644 packages/opencode/src/kilocode/plugins/remote.tsx diff --git a/.changeset/remote-tui-badge.md b/.changeset/remote-tui-badge.md new file mode 100644 index 0000000000..bb23dbb560 --- /dev/null +++ b/.changeset/remote-tui-badge.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Show the Remote badge in the TUI prompt status area when remote session relay is enabled. diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 88dd992a1c..1b9481664b 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -14,6 +14,7 @@ import KiloMemoryPalette from "@/kilocode/plugins/memory-palette" // kilocode_ch import KiloSidebarPr from "@/kilocode/plugins/sidebar-pr" import KiloSidebarUsage from "@/kilocode/plugins/sidebar-usage" import KiloSandbox from "@/kilocode/plugins/sandbox" +import KiloRemote from "@/kilocode/plugins/remote" // kilocode_change end import SidebarContext from "../feature-plugins/sidebar/context" import SidebarMcp from "../feature-plugins/sidebar/mcp" @@ -52,6 +53,7 @@ export function internalTuiPlugins(flags: Pick props.api.theme.current - const [status, setStatus] = createSignal(null) - - onMount(() => { - void props.api.client.remote - .status() - .then((res: { data?: Status }) => { - if (res.data) setStatus(res.data) - }) - .catch(() => undefined) - const off = props.api.event.on("kilo-sessions.remote-status-changed", (evt) => setStatus(evt.properties)) - onCleanup(off) - }) - - return ( - - - ◆ Remote{status()?.connected ? "" : " …"} - - - ) -} - // --------------------------------------------------------------------------- // Sub-components (mirror upstream home/footer with kilo additions) // --------------------------------------------------------------------------- @@ -105,6 +73,7 @@ function Version(props: { api: TuiPluginApi }) { function View(props: { api: TuiPluginApi }) { const kilo = createMemo(() => props.api.state.provider.some((p) => p.id === "kilo")) + const sdk = { client: props.api.client } return ( - + diff --git a/packages/opencode/src/kilocode/plugins/remote.tsx b/packages/opencode/src/kilocode/plugins/remote.tsx new file mode 100644 index 0000000000..098a94563b --- /dev/null +++ b/packages/opencode/src/kilocode/plugins/remote.tsx @@ -0,0 +1,32 @@ +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui" +import { RemoteIndicator } from "@/kilocode/remote-tui" + +const id = "internal:remote" + +function View(props: { api: TuiPluginApi }) { + return ( + + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 51, + slots: { + session_prompt_right() { + return + }, + }, + }) +} + +const plugin: TuiPluginModule & { id: string } = { id, tui } + +export default plugin From 75b994330fa42bc59f498b58a2750c389402ddfd Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 7 Jul 2026 10:57:43 +0200 Subject: [PATCH 16/18] docs: add deprecation notice to App Builder page (#11997) Co-authored-by: eshurakov <54751+eshurakov@users.noreply.github.com> Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/kilo-docs/pages/code-with-ai/app-builder.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/kilo-docs/pages/code-with-ai/app-builder.md b/packages/kilo-docs/pages/code-with-ai/app-builder.md index c67d1c80bc..ce9f87f027 100644 --- a/packages/kilo-docs/pages/code-with-ai/app-builder.md +++ b/packages/kilo-docs/pages/code-with-ai/app-builder.md @@ -5,6 +5,10 @@ description: "Build complete applications with Kilo Code" # App Builder +{% callout type="warning" title="App Builder is deprecated" %} +App Builder is deprecated and no longer available to new users. Only existing users who previously used App Builder can still access it. +{% /callout %} + Kilo's **App Builder** lets you create end-to-end applications through natural language conversation. Describe what you want to build, watch it come to life in a real-time preview, and deploy directly from your Kilo dashboard. No local environment setup required. --- From a1670270f1880ef3f4919d17882aefdb911f7691 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:35:37 +0000 Subject: [PATCH 17/18] fix(vscode): keep routed free-model name in usage panel --- packages/kilo-vscode/tests/unit/model-usage.test.ts | 2 ++ packages/kilo-vscode/webview-ui/src/context/model-usage.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/tests/unit/model-usage.test.ts b/packages/kilo-vscode/tests/unit/model-usage.test.ts index 4d1c84c981..4b6461f530 100644 --- a/packages/kilo-vscode/tests/unit/model-usage.test.ts +++ b/packages/kilo-vscode/tests/unit/model-usage.test.ts @@ -48,6 +48,8 @@ describe("model usage", () => { expect(modelUsageName(models[0], providers)).toBe("Qwen 3.7 Plus") expect(modelUsageName(models[1], providers)).toBe("MiniMax M3") expect(modelUsageName({ ...models[0], modelID: "moonshotai/kimi-k2.7-code-20260612" }, {})).toBe("kimi-k2.7-code") + // Routed free-variant ids keep their name instead of collapsing to the ":free" suffix + expect(modelUsageName({ ...models[0], modelID: "tencent/hy3:free" }, {})).toBe("hy3:free") }) test("matches sessions through their top-level tree", () => { diff --git a/packages/kilo-vscode/webview-ui/src/context/model-usage.ts b/packages/kilo-vscode/webview-ui/src/context/model-usage.ts index b6427f5d4f..4bcb528e7a 100644 --- a/packages/kilo-vscode/webview-ui/src/context/model-usage.ts +++ b/packages/kilo-vscode/webview-ui/src/context/model-usage.ts @@ -65,7 +65,7 @@ export function modelUsageName(model: SessionModelUsage["models"][number], provi const id = model.modelID.replace(DATE_SUFFIX, "") const name = provider?.models[model.modelID]?.name ?? provider?.models[id]?.name ?? id return name - .replace(/^[^:]+:\s*/, "") + .replace(/^[^:]+:\s+/, "") .replace(/^[^/]+\//, "") .replace(/\s*\([^)]*%\s*off[^)]*\)\s*$/i, "") .replace(/^qwen(?=\d)/i, "Qwen ") From dfce4059f364da8c294723e582c44885fa6e55e1 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:04:17 +0000 Subject: [PATCH 18/18] chore: add changeset for routed free-model name fix --- .changeset/routed-free-model-name.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/routed-free-model-name.md diff --git a/.changeset/routed-free-model-name.md b/.changeset/routed-free-model-name.md new file mode 100644 index 0000000000..31185ae44e --- /dev/null +++ b/.changeset/routed-free-model-name.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix the model usage panel showing just "free" for auto-routed sessions. The routed model id (e.g. `tencent/hy3:free`) is now displayed correctly instead of being collapsed to its `:free` suffix.