mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 11:05:31 +08:00
Merge branch 'main' into docs-improve-onboarding-flow
This commit is contained in:
@@ -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`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Show the Remote badge in the TUI prompt status area when remote session relay is enabled.
|
||||
@@ -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` (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:
|
||||
|
||||
@@ -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 ")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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}" } },
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user