From 540f3d3c4a187ad873f27e93d67f7a32be3329a6 Mon Sep 17 00:00:00 2001 From: John Mylchreest Date: Mon, 9 Mar 2026 01:30:19 +0000 Subject: [PATCH 1/2] fix: canonicalize filepath in Instance.containsPath() to handle symlinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instance.containsPath() compared Instance.directory (always canonical after #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 #16660 --- packages/opencode/src/project/instance.ts | 5 +- .../opencode/test/file/path-traversal.test.ts | 87 +++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) 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..d230a867031 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -195,4 +195,91 @@ 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 + await using tmp = await tmpdir({ git: true }) + await Bun.write(path.join(tmp.path, "file.txt"), "content") + // Create a symlink to the project directory from outside + const externalLink = tmp.path + "-ext-symlink" + await fs.symlink(tmp.path, externalLink) + try { + await Instance.provide({ + directory: tmp.path, + fn: () => { + // A path via the external symlink should resolve to the canonical project dir + 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 + await using tmp = await tmpdir({ git: true }) + await using outside = await tmpdir() + await Bun.write(path.join(outside.path, "secret.txt"), "secret") + // Symlink inside project pointing to directory outside project + const link = path.join(tmp.path, "escape-link") + await fs.symlink(outside.path, link) + + await Instance.provide({ + directory: tmp.path, + fn: () => { + // The symlink is inside the project, but its target is outside + 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 + 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() + }, + }) + }) }) From 487ff0c6d8d7754bf6af38b2cad7e9807b9b005e Mon Sep 17 00:00:00 2001 From: John Mylchreest Date: Mon, 9 Mar 2026 01:52:02 +0000 Subject: [PATCH 2/2] test: add symlink-dependent containsPath tests that activate after #16651 Tests for external symlinks, symlinks escaping project, and ELOOP propagation probe at runtime whether Filesystem.resolve() follows symlinks. They skip gracefully on dev (before #16651) and activate once realpathSync lands. --- .../opencode/test/file/path-traversal.test.ts | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index d230a867031..54ff2e8d289 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -215,16 +215,25 @@ describe("Instance.containsPath", () => { 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") - // Create a symlink to the project directory from outside const externalLink = tmp.path + "-ext-symlink" await fs.symlink(tmp.path, externalLink) try { await Instance.provide({ directory: tmp.path, fn: () => { - // A path via the external symlink should resolve to the canonical project dir expect(Instance.containsPath(path.join(externalLink, "file.txt"))).toBe(true) }, }) @@ -235,17 +244,25 @@ describe("Instance.containsPath", () => { 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") - // Symlink inside project pointing to directory outside project const link = path.join(tmp.path, "escape-link") await fs.symlink(outside.path, link) await Instance.provide({ directory: tmp.path, fn: () => { - // The symlink is inside the project, but its target is outside expect(Instance.containsPath(path.join(link, "secret.txt"))).toBe(false) }, }) @@ -269,6 +286,16 @@ describe("Instance.containsPath", () => { 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")