diff --git a/.changeset/config-file-substitution-trust.md b/.changeset/config-file-substitution-trust.md new file mode 100644 index 0000000000..3bbc2bfd35 --- /dev/null +++ b/.changeset/config-file-substitution-trust.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +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/.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/.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. 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..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 @@ -385,11 +385,15 @@ 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 | | `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 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 Control which models appear in the model picker for a provider using allowlists and blocklists: 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. --- 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..750653c2f0 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 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). ## Interactive Mode 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 ") diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 4363e249fa..8289b559b9 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -112,11 +112,20 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: return config }) - const load = (text: string, configFilepath: string): 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 {env:}; untrusted {file:} confined to fileScope const expanded = yield* Effect.promise(() => - ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }), + ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty", trusted, fileScope }), ) + // 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 @@ -149,7 +158,9 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ), ) - const loadFile = (filepath: string): 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. // Matches how parse/schema/plugin failures in load() are handled — every @@ -169,12 +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) + return yield* load(text, filepath, trusted, fileScope) // kilocode_change }) - const mergeFile = (acc: Acc, file: string) => + // 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) + 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 }) @@ -207,19 +220,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, { root: ctx.directory, source: file }) // kilocode_change - untrusted, {file:} confined to project } // kilocode_change start - load tui.json from supported Kilo config directories @@ -232,9 +245,13 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // kilocode_change end 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) + yield* mergeFile(acc, file, trusted, fileScope) } + // kilocode_change end } const keybinds = { ...acc.result.keybinds } 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 -// kilocode_change start -export async function load(dir: string, warnings?: Warning[]) { +// 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", { @@ -168,7 +168,9 @@ export async function load(dir: string, warnings?: Warning[]) { 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,7 +178,17 @@ export async function load(dir: string, warnings?: Warning[]) { source: item, missing: "empty", escapeJson: false, + trusted, + fileScope, + }).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, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index be259ff3d9..f70f99b375 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,17 @@ export const layer = Layer.effect( text: string, options: { path: string } | { dir: string; source: string }, env?: Record, + // 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 } - : { text, type: "virtual", ...options, env }, + ? { 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) @@ -590,11 +596,16 @@ 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 + 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) + return yield* loadConfig(text, { path: filepath }, env, trusted, fileScope) // kilocode_change }) let globalStamp = "" // kilocode_change @@ -615,13 +626,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)) { @@ -701,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 = {} @@ -799,6 +813,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 +848,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 +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, - yield* loadFile(file, authEnv).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) @@ -886,19 +903,27 @@ 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" + // 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).pipe( + yield* loadFile(source, authEnv, dirTrusted, fileScope).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 +962,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, dirFileScope)), // 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 +979,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 +1018,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 +1048,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 +1060,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..171ae796fd 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 @@ -17,10 +16,18 @@ 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 - trust gates {env:}; untrusted project config may only read files inside fileScope.root + trusted?: boolean + fileScope?: ConfigVariableGuard.FileScope + // kilocode_change end env?: Record } @@ -32,12 +39,45 @@ 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 - let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => { - // kilocode_change start - reject server credentials instead of silently changing config semantics + // 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: `environment references are not allowed in project config: "${active[0]}"`, + }) + } + // 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 cannot be resolved without a project scope: "${file[0]}"`, + }) + } + } + } + // kilocode_change end + 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}}"` }) } @@ -58,13 +98,13 @@ 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("//")) { + // 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("~/")) { @@ -72,23 +112,31 @@ 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) => { - if (missing === "empty") return "" + 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}"` - 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/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index 67412155ea..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 @@ -119,7 +122,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 +143,11 @@ 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()) + // 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,19 +193,34 @@ 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 { + // 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) - const agent = await ConfigAgent.load(dir) + 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) + return withAgents(next, rest, trusted, root) + } + // 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 load(file: string): Promise { + async function loadUnsafe(file: string, fileScope?: ConfigVariable.FileScope): Promise { + // kilocode_change end const text = await Bun.file(file).text() - const expanded = await ConfigVariable.substitute({ text, type: "path", path: file }) + // 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..ab01d33499 100644 --- a/packages/opencode/src/kilocode/config/variable.ts +++ b/packages/opencode/src/kilocode/config/variable.ts @@ -1,21 +1,64 @@ 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 { + export type FileScope = { + root: string + 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) { 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 BlockedError(`blocked file reference outside project config scope: "${token}"`) + } + + export async function read(filePath: string, scope?: FileScope & { token?: string }) { + const file = await fs.open(filePath, "r") try { - const target = `/proc/self/fd/${file.fd}` + // 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 (/^\/proc\/.*\/environ$/.test(resolved)) throw new Error("blocked process environment reference") - return await load(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 BlockedError(`blocked file reference changed during read: "${scope.token ?? "{file:...}"}"`) + } + } + check(resolved, scope?.token ?? "{file:...}", scope) + 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/src/kilocode/plugins/home-footer.tsx b/packages/opencode/src/kilocode/plugins/home-footer.tsx index 6b1ce02450..a189feb059 100644 --- a/packages/opencode/src/kilocode/plugins/home-footer.tsx +++ b/packages/opencode/src/kilocode/plugins/home-footer.tsx @@ -7,44 +7,12 @@ * and version information. */ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui" -import { createMemo, createSignal, Match, onCleanup, onMount, Show, Switch } from "solid-js" +import { createMemo, Match, Show, Switch } from "solid-js" import { Global } from "@opencode-ai/core/global" +import { RemoteIndicator } from "@/kilocode/remote-tui" const id = "internal:kilo-home-footer" -type Status = { - enabled: boolean - connected: boolean -} - -// --------------------------------------------------------------------------- -// RemoteIndicator – adapted from @/kilocode/remote-tui for plugin API usage -// --------------------------------------------------------------------------- - -function RemoteIndicator(props: { api: TuiPluginApi; kilo: boolean }) { - const theme = () => 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 diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 4b22d4bb87..c81de67060 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -547,71 +547,115 @@ 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:} rejected; {file:} confined to the project root +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("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"), "test-user") + yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.txt"), "in-project") 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).toBe("in-project") }), ) -it.instance("handles file inclusion with replacement tokens", () => +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, "included.md"), "const out = await Bun.$`echo hi`") yield* writeConfigEffect(test.directory, { - $schema: "https://app.kilo.ai/config.json", // kilocode_change - username: "{file:included.md}", + $schema: "https://app.kilo.ai/config.json", + username: "{file:/etc/passwd}", }) const config = yield* Config.use.get() - expect(config.username).toBe("const out = await Bun.$`echo hi`") + 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:../creds.txt}" }, + models: { "test-model": { name: "Test Model" } }, + }, + }, + }) + const config = yield* Config.use.get() + expect(JSON.stringify(config.provider ?? {})).not.toContain("leaked-credential") + }), +) + +it.instance("still allows global config to read absolute files", () => + withGlobalConfig({}, ({ dir }) => + Effect.gen(function* () { + const secret = path.join(dir, "secret.txt") + yield* AppFileSystem.use.writeWithDirs(secret, "global-secret") + yield* writeConfigEffect(dir, { + $schema: "https://app.kilo.ai/config.json", + username: `{file:${secret}}`, + }) + const config = yield* Config.use.get() + expect(config.username).toBe("global-secret") + }), + ), +) +// kilocode_change end + const accountTokenIt = configIt({ account: Layer.mock(Account.Service)({ active: () => @@ -1758,8 +1802,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..c58fbc4285 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -678,7 +678,8 @@ it.instance("does not derive tui path from KILO_CONFIG", () => ), ) -it.instance("applies env and file substitutions in tui.json", () => +// 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( "TUI_THEME_TEST", @@ -686,8 +687,9 @@ it.instance("applies env and file substitutions in tui.json", () => 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"), { + // 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}" }, }) @@ -700,14 +702,71 @@ it.instance("applies env and file substitutions in tui.json", () => ), ) +it.instance("does not substitute env references in untrusted project tui.json", () => + withCleanState( + withEnv( + "TUI_THEME_TEST", + "env-theme", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(test.directory, "tui.json"), { + theme: "{env:TUI_THEME_TEST}", + }) + + // {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") + }), + ), + ), +) + +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", () => withCleanState( Effect.gen(function* () { const fs = yield* AppFileSystem.Service const test = yield* TestInstance - yield* fs.writeFileString(path.join(test.directory, "theme.txt"), "resolved-theme") + // 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(test.directory, "tui.jsonc"), + path.join(Global.Path.config, "tui.jsonc"), + // kilocode_change end `{ // "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..617e80fd6d 100644 --- a/packages/opencode/test/kilocode/config/variable.test.ts +++ b/packages/opencode/test/kilocode/config/variable.test.ts @@ -3,35 +3,110 @@ 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() } +const trusted = { ...source, trusted: true } + +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 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}}`, fileScope: { root, source: "test" } }), + ).rejects.toBeInstanceOf(InvalidError) + } finally { + 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 }) + } +}) + +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" } }), + ).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 +115,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,8 +126,67 @@ 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 }) } }) + +// 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 }) + } +}) 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()