diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index df44a3a229c..7330f24e8b1 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -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(init: () => S, dispose?: (state: Awaited) => Promise): () => S { return State.create(() => Instance.directory, init, dispose) diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 44ae8f15435..54ff2e8d289 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -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() + }, + }) + }) })