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:
Alex Alecu
2026-04-09 18:31:52 +03:00
parent 5428997806
commit dbf6783b2d
6 changed files with 118 additions and 6 deletions
+6
View File
@@ -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(/\/\*$/, "")
+19 -2
View File
@@ -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", () => {
+45
View File
@@ -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"))
},
})
})
})