diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 72e7f8985da..0eaa410e0f6 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -55,7 +55,6 @@ export namespace Agent { doom_loop: "ask", external_directory: { "*": "ask", - [Truncate.DIR]: "allow", [Truncate.GLOB]: "allow", }, question: "deny", @@ -140,7 +139,6 @@ export namespace Agent { codesearch: "allow", read: "allow", external_directory: { - [Truncate.DIR]: "allow", [Truncate.GLOB]: "allow", }, }), @@ -229,19 +227,19 @@ export namespace Agent { item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) } - // Ensure Truncate.DIR is allowed unless explicitly configured + // Ensure Truncate.GLOB is allowed unless explicitly configured for (const name in result) { const agent = result[name] const explicit = agent.permission.some((r) => { if (r.permission !== "external_directory") return false if (r.action !== "deny") return false - return r.pattern === Truncate.DIR || r.pattern === Truncate.GLOB + return r.pattern === Truncate.GLOB }) if (explicit) continue result[name].permission = PermissionNext.merge( result[name].permission, - PermissionNext.fromConfig({ external_directory: { [Truncate.DIR]: "allow", [Truncate.GLOB]: "allow" } }), + PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), ) } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 5d073fd6815..ff208ff3fae 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -128,7 +128,10 @@ export const BashTool = Tool.define("bash", async () => { process.platform === "win32" && resolved.match(/^\/[a-z]\//) ? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\") : resolved - if (!Instance.containsPath(normalized)) directories.add(normalized) + if (!Instance.containsPath(normalized)) { + const dir = (await Filesystem.isDir(normalized)) ? normalized : path.dirname(normalized) + directories.add(dir) + } } } } @@ -141,10 +144,11 @@ export const BashTool = Tool.define("bash", async () => { } if (directories.size > 0) { + const globs = Array.from(directories).map((dir) => path.join(dir, "*")) await ctx.ask({ permission: "external_directory", - patterns: Array.from(directories), - always: Array.from(directories).map((x) => path.dirname(x) + "*"), + patterns: globs, + always: globs, metadata: {}, }) } diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 1ff303b7662..05b8427394b 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -447,7 +447,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a }) }) -test("Truncate.DIR is allowed even when user denies external_directory globally", async () => { +test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => { const { Truncate } = await import("../../src/tool/truncation") await using tmp = await tmpdir({ config: { @@ -460,14 +460,14 @@ test("Truncate.DIR is allowed even when user denies external_directory globally" directory: tmp.path, fn: async () => { const build = await Agent.get("build") - expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow") expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") + expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") }, }) }) -test("Truncate.DIR is allowed even when user denies external_directory per-agent", async () => { +test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => { const { Truncate } = await import("../../src/tool/truncation") await using tmp = await tmpdir({ config: { @@ -484,21 +484,21 @@ test("Truncate.DIR is allowed even when user denies external_directory per-agent directory: tmp.path, fn: async () => { const build = await Agent.get("build") - expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow") expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") + expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny") }, }) }) -test("explicit Truncate.DIR deny is respected", async () => { +test("explicit Truncate.GLOB deny is respected", async () => { const { Truncate } = await import("../../src/tool/truncation") await using tmp = await tmpdir({ config: { permission: { external_directory: { "*": "deny", - [Truncate.DIR]: "deny", + [Truncate.GLOB]: "deny", }, }, }, @@ -507,8 +507,8 @@ test("explicit Truncate.DIR deny is respected", async () => { directory: tmp.path, fn: async () => { const build = await Agent.get("build") - expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny") + expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") }, }) }) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index ff12c4368f6..fd03b7f9803 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -144,7 +144,42 @@ describe("tool.bash permissions", () => { ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain("/tmp") + expect(extDirReq!.patterns).toContain("/tmp/*") + }, + }) + }) + + test("asks for external_directory permission when file arg is outside project", async () => { + await using outerTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "outside.txt"), "x") + }, + }) + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + const filepath = path.join(outerTmp.path, "outside.txt") + await bash.execute( + { + command: `cat ${filepath}`, + description: "Read external file", + }, + testCtx, + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + const expected = path.join(outerTmp.path, "*") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(expected) + expect(extDirReq!.always).toContain(expected) }, }) })