Merge branch 'main' into docs-improve-onboarding-flow

This commit is contained in:
Emilie Lima Schario
2026-07-07 07:50:33 -04:00
committed by GitHub
21 changed files with 647 additions and 163 deletions
@@ -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`.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Show the Remote badge in the TUI prompt status area when remote session relay is enabled.
+5
View File
@@ -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.
@@ -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` (1530 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:
@@ -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.
---
@@ -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
@@ -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", () => {
@@ -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 ")
+27 -10
View File
@@ -112,11 +112,20 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
return config
})
const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
// kilocode_change start - trusted gates {env:}; fileScope confines untrusted {file:} reads
const load = (
text: string,
configFilepath: string,
trusted: boolean,
fileScope?: ConfigVariable.FileScope,
): Effect.Effect<Info> =>
// 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<Info> =>
// kilocode_change start - trusted + fileScope threaded to load
const loadFile = (filepath: string, trusted: boolean, fileScope?: ConfigVariable.FileScope): Effect.Effect<Info> =>
// 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 }
@@ -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<RuntimeFlags.Info, "experimentalE
KiloSidebarPr, // kilocode_change
KiloSidebarUsage, // kilocode_change
KiloSandbox, // kilocode_change
KiloRemote, // kilocode_change
HomeFooter,
HomeTips,
SidebarContext,
+15 -3
View File
@@ -134,8 +134,8 @@ export const Info = AgentSchema.pipe(
).annotate({ identifier: "AgentConfig" })
export type Info = Schema.Schema.Type<typeof Info>
// 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<string, Info> = {}
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,
+72 -28
View File
@@ -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<string, string>,
// 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<string, string>) {
const loadFile = Effect.fnUntraced(function* (
filepath: string,
env?: Record<string, string>,
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",
)
}
+69 -21
View File
@@ -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<string, string>
}
@@ -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
@@ -2,6 +2,7 @@ import path from "path"
import { existsSync } from "fs"
import { Schema } from "effect"
import z from "zod"
import * as Log from "@opencode-ai/core/util/log"
import { Global } from "@opencode-ai/core/global"
import { ConfigAgent } from "@/config/agent"
import { Config } from "@/config/config"
@@ -13,6 +14,8 @@ import { KilocodeConfig } from "./config"
import { KilocodeConfigSources } from "./sources"
export namespace KilocodeConfigOverlay {
const log = Log.create({ service: "kilocode.config.overlay" })
export const Scope = z.enum(["global", "project"])
export type Scope = z.infer<typeof Scope>
@@ -119,7 +122,9 @@ export namespace KilocodeConfigOverlay {
export async function project(input: { directory: string; worktree?: string }): Promise<Config.Info> {
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<Result> {
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<Config.Info> {
// kilocode_change start - root confines untrusted agent {file:} reads
async function withAgents(input: Config.Info, dirs: string[], trusted: boolean, root?: string): Promise<Config.Info> {
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<Config.Info> {
// kilocode_change start - a single unsafe/invalid project config file must not break the settings overlay;
// untrusted {env:} and out-of-scope {file:} throw InvalidError here, so skip the offending file like the
// main config loader does rather than failing the whole overlay.
return await loadUnsafe(file, fileScope).catch((err) => {
log.warn("skipping unreadable project config in overlay", { file, err })
return {} as Config.Info
})
}
async function load(file: string): Promise<Config.Info> {
async function loadUnsafe(file: string, fileScope?: ConfigVariable.FileScope): Promise<Config.Info> {
// 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
@@ -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<string>) {
if (process.platform !== "linux") return load(path)
const file = await fs.open(path, "r")
function inside(root: string, file: string) {
const rel = path.relative(root, file)
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))
}
function check(file: string, token: string, scope?: FileScope) {
if (!scope) return
const root = realpathSync.native(scope.root)
if (inside(root, file)) return
throw new 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/<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()
}
@@ -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<Status | null>(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 (
<Show when={props.kilo && status()?.enabled}>
<text fg={status()?.connected ? theme().success : theme().warning}>
Remote{status()?.connected ? "" : " …"}
</text>
</Show>
)
}
// ---------------------------------------------------------------------------
// 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 (
<box
@@ -119,7 +88,12 @@ function View(props: { api: TuiPluginApi }) {
>
<Directory api={props.api} />
<box gap={1} flexDirection="row" flexShrink={0}>
<RemoteIndicator api={props.api} kilo={kilo()} />
<RemoteIndicator
sdk={sdk}
theme={props.api.theme.current}
kilo={kilo()}
event={props.api.event}
/>
<Mcp api={props.api} />
</box>
<box flexGrow={1} />
@@ -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 (
<box flexShrink={0}>
<RemoteIndicator
sdk={{ client: props.api.client }}
theme={props.api.theme.current}
kilo={true}
event={props.api.event}
/>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 51,
slots: {
session_prompt_right() {
return <View api={api} />
},
},
})
}
const plugin: TuiPluginModule & { id: string } = { id, tui }
export default plugin
+83 -36
View File
@@ -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}" } },
+64 -5
View File
@@ -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}"
@@ -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 })
}
})
@@ -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()