Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/config-file-substitution-trust.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
Expand Up @@ -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" %}
Comment thread
markijbema marked this conversation as resolved.
`{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:
Expand Down
4 changes: 4 additions & 0 deletions packages/kilo-docs/pages/code-with-ai/platforms/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 27 additions & 10 deletions packages/opencode/src/cli/cmd/tui/config/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 })
Expand Down Expand Up @@ -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
Expand All @@ -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 }
Expand Down
18 changes: 15 additions & 3 deletions packages/opencode/src/config/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down Expand Up @@ -168,15 +168,27 @@ 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
Comment thread
markijbema marked this conversation as resolved.
// 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",
dir: path.dirname(item),
source: item,
missing: "empty",
escapeJson: false,
trusted,
fileScope,
}).catch((err): string | undefined => {
const message =
(ConfigError.InvalidError.isInstance(err) ? err.data.message : undefined) ??

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Soft-fail {env:} instead of throwing. Right now an untrusted {env:VAR} throws InvalidError, which caughtWarning turns into "skip the entire config file." A repo with one {env:} token loses all its project config (agents, MCP servers, everything) with only a warning. Security-wise, leaving the token unresolved (literal {env:VAR} string, or empty) plus a warning is exactly as safe — no secret is read — and strictly better UX. The MCP docs (using-in-cli.md:170) make project-level {env:} in headers a plausible existing pattern, so this failure mode will generate confused bug reports. Note the {file:} out-of-scope case is different: there a hard BlockedError is right, since silently emptying could mask an attack; and it only kills that one substitution path anyway.

`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,
Expand Down
Loading
Loading