diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 1e4423e01774..3be93bf1eb7d 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -132,6 +132,13 @@ function unquote(text: string) { return text } +// Bash treats `\` 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)) @@ -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 diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index a970f85d468f..d4a4f20e6218 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -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> = [] + 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]", () =>