mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
fix(cli): allow reading global config without prompts, fix absolute glob patterns
File-tool reads under ~/.config/kilo/ no longer trigger a permission prompt. Edits and bash access still require approval. The glob tool now handles absolute patterns like /path/to/*.md by splitting them into a search directory and a relative glob. Also whitelist global config directories in default agent permissions so the external_directory rule evaluates to allow for reads.
This commit is contained in:
@@ -22,6 +22,7 @@ import { NamedError } from "@opencode-ai/util/error" // kilocode_change
|
||||
import { Glob } from "../util/glob" // kilocode_change
|
||||
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||
import { Global } from "@/global"
|
||||
import { KilocodePaths } from "@/kilocode/paths"
|
||||
import path from "path"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Skill } from "../skill"
|
||||
@@ -63,6 +64,10 @@ export namespace Agent {
|
||||
|
||||
const skillDirs = await Skill.dirs()
|
||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||
const readableDirs = [
|
||||
path.join(Global.Path.config, "*"),
|
||||
...KilocodePaths.globalDirs().map((dir) => path.join(dir, "*")),
|
||||
]
|
||||
// kilocode_change start — safe bash commands that don't need user approval.
|
||||
// only commands that cannot execute arbitrary code or subprocesses.
|
||||
const bash: Record<string, "allow" | "ask" | "deny"> = {
|
||||
@@ -190,6 +195,7 @@ export namespace Agent {
|
||||
recall: "ask", // kilocode_change
|
||||
external_directory: {
|
||||
"*": "ask",
|
||||
...Object.fromEntries(readableDirs.map((dir) => [dir, "allow"])), // kilocode_change
|
||||
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
||||
},
|
||||
question: "deny",
|
||||
|
||||
@@ -81,7 +81,7 @@ export namespace ConfigProtection {
|
||||
/**
|
||||
* Determine if a permission request targets config files.
|
||||
* Gates `edit` permissions and bash-originated `external_directory` requests.
|
||||
* Read access is not restricted.
|
||||
* File-tool reads are not restricted.
|
||||
*/
|
||||
export function isRequest(request: {
|
||||
permission: string
|
||||
@@ -89,9 +89,8 @@ export namespace ConfigProtection {
|
||||
metadata?: Record<string, any>
|
||||
}): boolean {
|
||||
if (request.permission === "external_directory") {
|
||||
// Only gate bash-originated requests (no filepath metadata).
|
||||
// File tools use assertExternalDirectory() which includes metadata.filepath —
|
||||
// those are independently protected via the edit permission path.
|
||||
// File tools include metadata.filepath. They may read global config
|
||||
// without prompting, but edits are still protected separately via `edit`.
|
||||
if (request.metadata?.filepath) return false
|
||||
for (const pattern of request.patterns) {
|
||||
const dir = pattern.replace(/\/\*$/, "")
|
||||
|
||||
@@ -7,6 +7,22 @@ import { Ripgrep } from "../file/ripgrep"
|
||||
import { Instance } from "../project/instance"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
|
||||
function normalize(p: string) {
|
||||
return p.replaceAll("\\", "/")
|
||||
}
|
||||
|
||||
function split(pattern: string) {
|
||||
const normalized = normalize(pattern)
|
||||
if (!path.isAbsolute(normalized)) return
|
||||
const index = normalized.search(/[*?{[]/)
|
||||
if (index === -1) return { dir: normalized, pattern: "*" }
|
||||
const slice = normalized.slice(0, index)
|
||||
const cut = slice.lastIndexOf("/")
|
||||
const dir = cut > 0 ? slice.slice(0, cut) : "/"
|
||||
const next = normalized.slice(cut + 1)
|
||||
return { dir, pattern: next || "*" }
|
||||
}
|
||||
|
||||
export const GlobTool = Tool.define("glob", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
@@ -19,6 +35,7 @@ export const GlobTool = Tool.define("glob", {
|
||||
),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const absolute = split(params.pattern)
|
||||
await ctx.ask({
|
||||
permission: "glob",
|
||||
patterns: [params.pattern],
|
||||
@@ -29,7 +46,7 @@ export const GlobTool = Tool.define("glob", {
|
||||
},
|
||||
})
|
||||
|
||||
let search = params.path ?? Instance.directory
|
||||
let search = absolute?.dir ?? params.path ?? Instance.directory
|
||||
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
||||
await assertExternalDirectory(ctx, search, { kind: "directory" })
|
||||
|
||||
@@ -38,7 +55,7 @@ export const GlobTool = Tool.define("glob", {
|
||||
let truncated = false
|
||||
for await (const file of Ripgrep.files({
|
||||
cwd: search,
|
||||
glob: [params.pattern],
|
||||
glob: [absolute?.pattern ?? params.pattern],
|
||||
signal: ctx.abort,
|
||||
})) {
|
||||
if (files.length >= limit) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { PermissionNext } from "../../src/permission/next"
|
||||
import { Global } from "../../src/global"
|
||||
|
||||
// Helper to evaluate permission for a tool with wildcard pattern
|
||||
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
|
||||
@@ -158,6 +159,20 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy
|
||||
})
|
||||
})
|
||||
|
||||
test("code agent allows global config directory reads by default", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const code = await Agent.get("code")
|
||||
expect(code).toBeDefined()
|
||||
expect(PermissionNext.evaluate("external_directory", `${Global.Path.config}/*`, code!.permission).action).toBe(
|
||||
"allow",
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("general agent denies todo tools", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
|
||||
@@ -51,6 +51,36 @@ describe("ConfigProtection.isRequest", () => {
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for file-tool external_directory targeting global config root dir", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "external_directory",
|
||||
patterns: [config + "/*"],
|
||||
metadata: { filepath: config, parentDir: config },
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for file-tool external_directory targeting readable global command dir", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "external_directory",
|
||||
patterns: [path.join(config, "command") + "/*"],
|
||||
metadata: { filepath: path.join(config, "command", "foo.md"), parentDir: path.join(config, "command") },
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for file-tool external_directory targeting readable global skill dir", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "external_directory",
|
||||
patterns: [path.join(config, "skills") + "/*"],
|
||||
metadata: {
|
||||
filepath: path.join(config, "skills", "my-skill", "SKILL.md"),
|
||||
parentDir: path.join(config, "skills"),
|
||||
},
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
// --- external_directory: non-config dirs ---
|
||||
|
||||
test("returns false for bash external_directory targeting non-config dir", () => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { GlobTool } from "../../src/tool/glob"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("tool.glob", () => {
|
||||
const ctx = {
|
||||
sessionID: "test",
|
||||
messageID: "",
|
||||
callID: "",
|
||||
agent: "code",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
test("supports absolute glob patterns outside the project", async () => {
|
||||
await using outer = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "one.md"), "one")
|
||||
await Bun.write(path.join(dir, "two.md"), "two")
|
||||
await Bun.write(path.join(dir, "three.txt"), "three")
|
||||
},
|
||||
})
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const glob = await GlobTool.init()
|
||||
const result = await glob.execute(
|
||||
{
|
||||
pattern: path.join(outer.path, "*.md"),
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.output).toContain(path.join(outer.path, "one.md"))
|
||||
expect(result.output).toContain(path.join(outer.path, "two.md"))
|
||||
expect(result.output).not.toContain(path.join(outer.path, "three.txt"))
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user