mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-29 17:37:00 +08:00
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:
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user