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
5 changes: 3 additions & 2 deletions packages/opencode/src/project/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,12 @@ export const Instance = {
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/
containsPath(filepath: string) {
if (Filesystem.contains(Instance.directory, filepath)) return true
const resolved = Filesystem.resolve(filepath)
if (Filesystem.contains(Instance.directory, resolved)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
if (Instance.worktree === "/") return false
return Filesystem.contains(Instance.worktree, filepath)
return Filesystem.contains(Instance.worktree, resolved)
},
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
return State.create(() => Instance.directory, init, dispose)
Expand Down
114 changes: 114 additions & 0 deletions packages/opencode/test/file/path-traversal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,4 +195,118 @@ describe("Instance.containsPath", () => {
},
})
})

test("returns true for symlinked path that resolves inside project", async () => {
if (process.platform === "win32") return
await using tmp = await tmpdir({ git: true })
const target = path.join(tmp.path, "real-dir")
await fs.mkdir(target)
await Bun.write(path.join(target, "file.txt"), "content")
const link = path.join(tmp.path, "link-dir")
await fs.symlink(target, link)

await Instance.provide({
directory: tmp.path,
fn: () => {
expect(Instance.containsPath(path.join(link, "file.txt"))).toBe(true)
},
})
})

test("returns true when project is accessed via external symlink", async () => {
if (process.platform === "win32") return
// This test requires Filesystem.resolve() to resolve symlinks (realpathSync, from #16651).
// Skip if resolve() does not follow symlinks yet.
const probe = path.join(require("os").tmpdir(), `oc-symlink-probe-${Date.now()}`)
const probeTarget = path.join(require("os").tmpdir(), `oc-symlink-probe-target-${Date.now()}`)
await fs.mkdir(probeTarget)
await fs.symlink(probeTarget, probe)
const resolvesSymlinks = Filesystem.resolve(probe) === Filesystem.resolve(probeTarget)
await fs.unlink(probe).catch(() => {})
await fs.rm(probeTarget, { recursive: true }).catch(() => {})
if (!resolvesSymlinks) return

await using tmp = await tmpdir({ git: true })
await Bun.write(path.join(tmp.path, "file.txt"), "content")
const externalLink = tmp.path + "-ext-symlink"
await fs.symlink(tmp.path, externalLink)
try {
await Instance.provide({
directory: tmp.path,
fn: () => {
expect(Instance.containsPath(path.join(externalLink, "file.txt"))).toBe(true)
},
})
} finally {
await fs.unlink(externalLink).catch(() => {})
}
})

test("returns false for symlink that resolves outside project", async () => {
if (process.platform === "win32") return
// Requires Filesystem.resolve() with realpathSync (#16651)
const probe = path.join(require("os").tmpdir(), `oc-symlink-probe-${Date.now()}`)
const probeTarget = path.join(require("os").tmpdir(), `oc-symlink-probe-target-${Date.now()}`)
await fs.mkdir(probeTarget)
await fs.symlink(probeTarget, probe)
const resolvesSymlinks = Filesystem.resolve(probe) === Filesystem.resolve(probeTarget)
await fs.unlink(probe).catch(() => {})
await fs.rm(probeTarget, { recursive: true }).catch(() => {})
if (!resolvesSymlinks) return

await using tmp = await tmpdir({ git: true })
await using outside = await tmpdir()
await Bun.write(path.join(outside.path, "secret.txt"), "secret")
const link = path.join(tmp.path, "escape-link")
await fs.symlink(outside.path, link)

await Instance.provide({
directory: tmp.path,
fn: () => {
expect(Instance.containsPath(path.join(link, "secret.txt"))).toBe(false)
},
})
})

test("handles dangling symlink gracefully", async () => {
if (process.platform === "win32") return
await using tmp = await tmpdir({ git: true })
const link = path.join(tmp.path, "dangling")
await fs.symlink(path.join(tmp.path, "nonexistent"), link)

await Instance.provide({
directory: tmp.path,
fn: () => {
// Dangling symlink: resolve falls back to unresolved path (ENOENT),
// which is still lexically inside the project
expect(Instance.containsPath(link)).toBe(true)
},
})
})

test("propagates ELOOP from symlink cycle", async () => {
if (process.platform === "win32") return
// Requires Filesystem.resolve() with realpathSync and narrowed catch (#16651)
const probe = path.join(require("os").tmpdir(), `oc-symlink-probe-${Date.now()}`)
const probeTarget = path.join(require("os").tmpdir(), `oc-symlink-probe-target-${Date.now()}`)
await fs.mkdir(probeTarget)
await fs.symlink(probeTarget, probe)
const resolvesSymlinks = Filesystem.resolve(probe) === Filesystem.resolve(probeTarget)
await fs.unlink(probe).catch(() => {})
await fs.rm(probeTarget, { recursive: true }).catch(() => {})
if (!resolvesSymlinks) return

await using tmp = await tmpdir({ git: true })
const a = path.join(tmp.path, "a")
const b = path.join(tmp.path, "b")
await fs.symlink(b, a)
await fs.symlink(a, b)

await Instance.provide({
directory: tmp.path,
fn: () => {
expect(() => Instance.containsPath(a)).toThrow()
},
})
})
})
Loading