Skip to content
Open
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
9 changes: 8 additions & 1 deletion packages/opencode/src/tool/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ function unquote(text: string) {
return text
}

// Bash treats `\<char>` as a literal escape in unquoted words (e.g. `/tmp/my\ project/x`),
// but the tree-sitter word still carries the backslash. Strip it before resolving paths so
// `mkdir -p /tmp/my\ project/sub` and `mkdir -p "/tmp/my project/sub"` resolve identically.
function unescape(text: string) {
return text.replace(/\\(.)/g, "$1")
}

function home(text: string) {
if (text === "~") return os.homedir()
if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2))
Expand Down Expand Up @@ -367,7 +374,7 @@ export const ShellTool = Tool.define(
})

const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {
const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
const text = ps ? expand(arg, cwd, shell) : home(unescape(unquote(arg)))
const file = text && prefix(text)
if (!file || dynamic(file, ps)) return
const next = ps ? provider(file) : file
Expand Down
33 changes: 33 additions & 0 deletions packages/opencode/test/tool/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,39 @@ describe("tool.shell permissions", () => {
),
)

if (process.platform !== "win32") {
it.live("treats backslash-escaped path args as in-project (regression #49671)", () =>
Effect.gen(function* () {
// Project directory contains a space. The agent writes the same in-project path with
// backslash-escaped spaces (`/var/folders/.../opencode-test-xyz/my\ project/sub`) instead
// of quoting. Without unescaping, the resolved path literalises the backslash and
// `path.relative()` reports it as outside the project root, so the tool wrongly asks for
// `external_directory` permission.
const outer = yield* tmpdirScoped()
const project = path.join(outer, "my project").replaceAll("\\", "/")
yield* Effect.promise(async () => {
await Bun.write(path.join(project, "x.txt"), "ok")
})
yield* runIn(
project,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
const inner = `${project}/x.txt`.replaceAll(" ", "\\ ")
const result = yield* run(
{
command: `cat ${inner}`,
},
capture(requests),
)
expect(result.metadata.exit).toBe(0)
expect(result.output).toContain("ok")
expect(requests.find((r) => r.permission === "external_directory")).toBeUndefined()
}),
)
}),
)
}

if (process.platform === "win32") {
if (bash) {
it.live("asks for nested bash command permissions [bash]", () =>
Expand Down
Loading