refactor(cli): root-scope project {file:} instead of blocking; keep {env:} block

Adopt the file-scoping approach from #11883: untrusted project config may still read {file:...} as long as the target stays inside the project root (absolute paths, ../ traversal, and symlink escapes are rejected via realpath). Keep {env:} fully blocked in project config (no safe scoped form). Make the /proc/self/fd guard cross-platform. Thread fileScope through config.ts, agent.ts, tui.ts, and overlay.ts. Update docs and changeset.
This commit is contained in:
Mark IJbema
2026-07-02 14:37:42 +02:00
parent 47da23170d
commit 6dfbc54c85
12 changed files with 264 additions and 83 deletions
+65 -11
View File
@@ -547,7 +547,7 @@ it.instance("prefers .kilo directory config over legacy .kilocode", () =>
)
// kilocode_change end
// kilocode_change start - project config is untrusted: {env:}/{file:} references must not resolve
// kilocode_change start - project config is untrusted: {env:} rejected; {file:} confined to the project root
it.instance("rejects environment variable substitution in project config", () =>
withProcessEnv(
"TEST_VAR",
@@ -566,40 +566,94 @@ it.instance("rejects environment variable substitution in project config", () =>
),
)
it.instance("does not leak file contents via {file:} in project config", () =>
it.instance("allows {file:} that stays inside the project root", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.txt"), "top-secret")
yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.txt"), "in-project")
yield* writeConfigEffect(test.directory, {
$schema: "https://app.kilo.ai/config.json",
username: "{file:included.txt}",
})
const config = yield* Config.use.get()
expect(config.username).not.toBe("top-secret")
const issues = yield* Config.Service.use((svc) => svc.warnings())
expect(issues.length).toBeGreaterThan(0)
expect(config.username).toBe("in-project")
}),
)
it.instance("rejects {file:} exfiltration of provider apiKey in project config", () =>
it.instance("rejects {file:} that reads an absolute path from project config", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "secret.txt"), "leaked-credential")
yield* writeConfigEffect(test.directory, {
$schema: "https://app.kilo.ai/config.json",
username: "{file:/etc/passwd}",
})
const config = yield* Config.use.get()
expect(config.username ?? "").not.toContain("root:")
}),
)
it.instance("rejects {file:} that escapes the project root with parent directories", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const outside = path.join(path.dirname(test.directory), "secret.txt")
yield* AppFileSystem.use.writeWithDirs(outside, "outside-secret")
yield* writeConfigEffect(test.directory, {
$schema: "https://app.kilo.ai/config.json",
username: "{file:../secret.txt}",
})
const config = yield* Config.use.get()
expect(config.username).not.toBe("outside-secret")
}),
)
it.instance("rejects {file:} that escapes the project root through a symlink", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const outside = path.join(path.dirname(test.directory), "secret.txt")
const link = path.join(test.directory, "secret-link")
yield* AppFileSystem.use.writeWithDirs(outside, "outside-secret")
yield* Effect.promise(() => fs.symlink(outside, link))
yield* writeConfigEffect(test.directory, {
$schema: "https://app.kilo.ai/config.json",
username: "{file:secret-link}",
})
const config = yield* Config.use.get()
expect(config.username).not.toBe("outside-secret")
}),
)
it.instance("blocks provider apiKey {file:} exfiltration that escapes the project root", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const outside = path.join(path.dirname(test.directory), "creds.txt")
yield* AppFileSystem.use.writeWithDirs(outside, "leaked-credential")
yield* writeConfigEffect(test.directory, {
$schema: "https://app.kilo.ai/config.json",
provider: {
"openai-compatible": {
options: { baseURL: "http://127.0.0.1:4444/v1", apiKey: "{file:secret.txt}" },
options: { baseURL: "http://127.0.0.1:4444/v1", apiKey: "{file:../creds.txt}" },
models: { "test-model": { name: "Test Model" } },
},
},
})
const config = yield* Config.use.get()
expect(JSON.stringify(config.provider ?? {})).not.toContain("leaked-credential")
const issues = yield* Config.Service.use((svc) => svc.warnings())
expect(issues.length).toBeGreaterThan(0)
}),
)
it.instance("still allows global config to read absolute files", () =>
withGlobalConfig({}, ({ dir }) =>
Effect.gen(function* () {
const secret = path.join(dir, "secret.txt")
yield* AppFileSystem.use.writeWithDirs(secret, "global-secret")
yield* writeConfigEffect(dir, {
$schema: "https://app.kilo.ai/config.json",
username: `{file:${secret}}`,
})
const config = yield* Config.use.get()
expect(config.username).toBe("global-secret")
}),
),
)
// kilocode_change end
const accountTokenIt = configIt({
+36 -5
View File
@@ -702,7 +702,7 @@ it.instance("applies env and file substitutions in global tui.json", () =>
),
)
it.instance("does not substitute env or file references in untrusted project tui.json", () =>
it.instance("does not substitute env references in untrusted project tui.json", () =>
withCleanState(
withEnv(
"TUI_THEME_TEST",
@@ -710,20 +710,51 @@ it.instance("does not substitute env or file references in untrusted project tui
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const test = yield* TestInstance
yield* fs.writeFileString(path.join(test.directory, "keybind.txt"), "ctrl+q")
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
theme: "{env:TUI_THEME_TEST}",
keybinds: { app_exit: "{file:keybind.txt}" },
})
// Project tui config with references is rejected and skipped, so nothing is substituted.
// {env:} in project config is rejected, so the file is skipped and the theme is not applied.
const config = yield* getTuiConfig(test.directory)
expect(config.theme).not.toBe("env-theme")
expect(config.keybinds.get("app.exit")?.[0]?.key).not.toBe("ctrl+q")
}),
),
),
)
it.instance("applies in-project file references in project tui.json", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const test = yield* TestInstance
// {file:} that stays inside the project root is allowed even in untrusted project config.
yield* fs.writeFileString(path.join(test.directory, "keybind.txt"), "ctrl+q")
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
keybinds: { app_exit: "{file:keybind.txt}" },
})
const config = yield* getTuiConfig(test.directory)
expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
}),
),
)
it.instance("rejects project tui.json file references that escape the project root", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const test = yield* TestInstance
const outside = path.join(path.dirname(test.directory), "keybind.txt")
yield* fs.writeFileString(outside, "ctrl+q")
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
keybinds: { app_exit: "{file:../keybind.txt}" },
})
const config = yield* getTuiConfig(test.directory)
expect(config.keybinds.get("app.exit")?.[0]?.key).not.toBe("ctrl+q")
}),
),
)
// kilocode_change end
it.instance("applies file substitutions when first identical token is in a commented line", () =>
@@ -8,22 +8,42 @@ import { InvalidError } from "@/config/error"
const source = { type: "virtual" as const, source: "test", dir: process.cwd() }
const trusted = { ...source, trusted: true }
test("rejects file references in untrusted (project) config", async () => {
await expect(
ConfigVariable.substitute({ ...source, text: "apiKey={file:/etc/passwd}" }),
).rejects.toBeInstanceOf(InvalidError)
test("rejects file references in untrusted config without a fileScope", async () => {
await expect(ConfigVariable.substitute({ ...source, text: "apiKey={file:/etc/passwd}" })).rejects.toBeInstanceOf(
InvalidError,
)
})
test("rejects file references when trusted is omitted (secure by default)", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-untrusted-"))
const file = path.join(dir, "secret")
test("rejects untrusted file references that escape the scope root", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-root-"))
const outside = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-outside-"))
const file = path.join(outside, "secret")
await fs.writeFile(file, "top-secret")
try {
await expect(ConfigVariable.substitute({ ...source, text: `{file:${file}}` })).rejects.toBeInstanceOf(
InvalidError,
)
await expect(
ConfigVariable.substitute({ ...source, text: `{file:${file}}`, fileScope: { root, source: "test" } }),
).rejects.toBeInstanceOf(InvalidError)
} finally {
await fs.rm(dir, { recursive: true, force: true })
await fs.rm(root, { recursive: true, force: true })
await fs.rm(outside, { recursive: true, force: true })
}
})
test("allows untrusted file references that stay inside the scope root", async () => {
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "kilo-config-variable-inside-")))
const file = path.join(root, "value")
await fs.writeFile(file, "allowed")
try {
expect(
await ConfigVariable.substitute({
...source,
dir: root,
text: "{file:value}",
fileScope: { root, source: path.join(root, "kilo.json") },
}),
).toBe("allowed")
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
@@ -86,9 +106,7 @@ test.skipIf(process.platform !== "linux")("does not substitute an environment fi
const link = path.join(dir, "value")
await fs.symlink("/proc/self/environ", link)
try {
await expect(ConfigVariable.substitute({ ...trusted, text: `{file:${link}}` })).rejects.toBeInstanceOf(
InvalidError,
)
await expect(ConfigVariable.substitute({ ...trusted, text: `{file:${link}}` })).rejects.toBeInstanceOf(InvalidError)
} finally {
await fs.rm(dir, { recursive: true, force: true })
}