Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export const BashTool = Tool.define("bash", async () => {
const patterns = new Set<string>()
const always = new Set<string>()

// 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 = []
Expand All @@ -112,19 +115,22 @@ 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 =
process.platform === "win32" && resolved.match(/^\/[a-z]\//)
? 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
}
}
}
Expand Down
87 changes: 87 additions & 0 deletions packages/opencode/test/tool/bash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
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<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
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<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
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", () => {
Expand Down