diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index d53d12efdfe..b26c2c4445b 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -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 = { @@ -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", diff --git a/packages/opencode/src/kilocode/permission/config-paths.ts b/packages/opencode/src/kilocode/permission/config-paths.ts index 5e2e470c7b5..885d93419a2 100644 --- a/packages/opencode/src/kilocode/permission/config-paths.ts +++ b/packages/opencode/src/kilocode/permission/config-paths.ts @@ -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 }): 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(/\/\*$/, "") diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index a2611246c66..4156bea68ae 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -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) { diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index d4a4e0b6a80..1d7c3cef3bd 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -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({ diff --git a/packages/opencode/test/kilocode/permission/config-paths.test.ts b/packages/opencode/test/kilocode/permission/config-paths.test.ts index e4ab75361e5..d1d67e4c22d 100644 --- a/packages/opencode/test/kilocode/permission/config-paths.test.ts +++ b/packages/opencode/test/kilocode/permission/config-paths.test.ts @@ -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", () => { diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts new file mode 100644 index 00000000000..d2ea679516e --- /dev/null +++ b/packages/opencode/test/tool/glob.test.ts @@ -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")) + }, + }) + }) +})