Skip to content

Commit 540f3d3

Browse files
committed
fix: canonicalize filepath in Instance.containsPath() to handle symlinks
Instance.containsPath() compared Instance.directory (always canonical after anomalyco#16651) against an uncanonicalized filepath argument. When callers passed a symlinked path, the lexical comparison failed even though the path resolved to a location inside the project. This caused false negatives in bash.ts, external-directory.ts, and file/index.ts — triggering unnecessary external_directory permission prompts or rejecting valid file reads via symlinked paths. Fix: resolve the filepath through Filesystem.resolve() before comparing, so both sides use canonical paths. Adds tests for: symlinks inside project, external symlinks to project, symlinks escaping project, dangling symlinks, and symlink cycles. Fixes anomalyco#16660
1 parent d15c2ce commit 540f3d3

2 files changed

Lines changed: 90 additions & 2 deletions

File tree

packages/opencode/src/project/instance.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,12 @@ export const Instance = {
9494
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
9595
*/
9696
containsPath(filepath: string) {
97-
if (Filesystem.contains(Instance.directory, filepath)) return true
97+
const resolved = Filesystem.resolve(filepath)
98+
if (Filesystem.contains(Instance.directory, resolved)) return true
9899
// Non-git projects set worktree to "/" which would match ANY absolute path.
99100
// Skip worktree check in this case to preserve external_directory permissions.
100101
if (Instance.worktree === "/") return false
101-
return Filesystem.contains(Instance.worktree, filepath)
102+
return Filesystem.contains(Instance.worktree, resolved)
102103
},
103104
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
104105
return State.create(() => Instance.directory, init, dispose)

packages/opencode/test/file/path-traversal.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,4 +195,91 @@ describe("Instance.containsPath", () => {
195195
},
196196
})
197197
})
198+
199+
test("returns true for symlinked path that resolves inside project", async () => {
200+
if (process.platform === "win32") return
201+
await using tmp = await tmpdir({ git: true })
202+
const target = path.join(tmp.path, "real-dir")
203+
await fs.mkdir(target)
204+
await Bun.write(path.join(target, "file.txt"), "content")
205+
const link = path.join(tmp.path, "link-dir")
206+
await fs.symlink(target, link)
207+
208+
await Instance.provide({
209+
directory: tmp.path,
210+
fn: () => {
211+
expect(Instance.containsPath(path.join(link, "file.txt"))).toBe(true)
212+
},
213+
})
214+
})
215+
216+
test("returns true when project is accessed via external symlink", async () => {
217+
if (process.platform === "win32") return
218+
await using tmp = await tmpdir({ git: true })
219+
await Bun.write(path.join(tmp.path, "file.txt"), "content")
220+
// Create a symlink to the project directory from outside
221+
const externalLink = tmp.path + "-ext-symlink"
222+
await fs.symlink(tmp.path, externalLink)
223+
try {
224+
await Instance.provide({
225+
directory: tmp.path,
226+
fn: () => {
227+
// A path via the external symlink should resolve to the canonical project dir
228+
expect(Instance.containsPath(path.join(externalLink, "file.txt"))).toBe(true)
229+
},
230+
})
231+
} finally {
232+
await fs.unlink(externalLink).catch(() => {})
233+
}
234+
})
235+
236+
test("returns false for symlink that resolves outside project", async () => {
237+
if (process.platform === "win32") return
238+
await using tmp = await tmpdir({ git: true })
239+
await using outside = await tmpdir()
240+
await Bun.write(path.join(outside.path, "secret.txt"), "secret")
241+
// Symlink inside project pointing to directory outside project
242+
const link = path.join(tmp.path, "escape-link")
243+
await fs.symlink(outside.path, link)
244+
245+
await Instance.provide({
246+
directory: tmp.path,
247+
fn: () => {
248+
// The symlink is inside the project, but its target is outside
249+
expect(Instance.containsPath(path.join(link, "secret.txt"))).toBe(false)
250+
},
251+
})
252+
})
253+
254+
test("handles dangling symlink gracefully", async () => {
255+
if (process.platform === "win32") return
256+
await using tmp = await tmpdir({ git: true })
257+
const link = path.join(tmp.path, "dangling")
258+
await fs.symlink(path.join(tmp.path, "nonexistent"), link)
259+
260+
await Instance.provide({
261+
directory: tmp.path,
262+
fn: () => {
263+
// Dangling symlink: resolve falls back to unresolved path (ENOENT),
264+
// which is still lexically inside the project
265+
expect(Instance.containsPath(link)).toBe(true)
266+
},
267+
})
268+
})
269+
270+
test("propagates ELOOP from symlink cycle", async () => {
271+
if (process.platform === "win32") return
272+
await using tmp = await tmpdir({ git: true })
273+
const a = path.join(tmp.path, "a")
274+
const b = path.join(tmp.path, "b")
275+
await fs.symlink(b, a)
276+
await fs.symlink(a, b)
277+
278+
await Instance.provide({
279+
directory: tmp.path,
280+
fn: () => {
281+
expect(() => Instance.containsPath(a)).toThrow()
282+
},
283+
})
284+
})
198285
})

0 commit comments

Comments
 (0)