diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index f3a1b04d431..6ec217fb9da 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -89,6 +89,9 @@ export const BashTool = Tool.define("bash", async () => { const patterns = new Set() const always = new Set() + // track effective working directory as cd commands change it + let effectiveCwd = cwd + for (const node of tree.rootNode.descendantsOfType("command")) { if (!node) continue const command = [] @@ -112,12 +115,12 @@ export const BashTool = Tool.define("bash", async () => { for (const arg of command.slice(1)) { if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue const resolved = await $`realpath ${arg}` - .cwd(cwd) + .cwd(effectiveCwd) .quiet() .nothrow() .text() .then((x) => x.trim()) - log.info("resolved path", { arg, resolved }) + log.info("resolved path", { arg, resolved, effectiveCwd }) if (resolved) { // Git Bash on Windows returns Unix-style paths like /c/Users/... const normalized = @@ -125,6 +128,9 @@ export const BashTool = Tool.define("bash", async () => { ? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\") : resolved if (!Instance.containsPath(normalized)) directories.add(normalized) + + // update effective cwd after cd command + if (command[0] === "cd") effectiveCwd = normalized } } } diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 750ff8193e9..14a0b6464a9 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -230,6 +230,93 @@ describe("tool.bash permissions", () => { }, }) }) + + test("does not ask for external_directory permission when cd into subdir then cd back", async () => { + await using tmp = await tmpdir({ git: true }) + const subdir = path.join(tmp.path, "subdir") + await Bun.write(path.join(subdir, ".keep"), "") + + 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) + }, + } + await bash.execute( + { + command: "cd subdir && ls && cd ..", + description: "cd into subdir and back", + }, + testCtx, + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeUndefined() + }, + }) + }) + + test("does not ask for external_directory when multiple cd commands stay within project", async () => { + await using tmp = await tmpdir({ git: true }) + const nested = path.join(tmp.path, "a", "b", "c") + await Bun.write(path.join(nested, ".keep"), "") + + 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) + }, + } + await bash.execute( + { + command: "cd a && cd b && cd c && cd .. && cd .. && cd ..", + description: "navigate through nested dirs and back to root", + }, + testCtx, + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeUndefined() + }, + }) + }) + + test("asks for external_directory when cd actually leaves project", async () => { + await using tmp = await tmpdir({ git: true }) + const subdir = path.join(tmp.path, "subdir") + await Bun.write(path.join(subdir, ".keep"), "") + + 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) + }, + } + await bash.execute( + { + command: "cd subdir && cd ../.. && ls", + description: "cd into subdir then escape project", + }, + testCtx, + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + }, + }) + }) }) describe("tool.bash truncation", () => {