diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 76b7be4b72b..63b5d868fc3 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -277,18 +277,21 @@ export namespace File { const project = Instance.project const full = path.join(Instance.directory, file) - // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. - // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. + // First do lexical check to catch obvious path traversal attempts if (!Instance.containsPath(full)) { throw new Error(`Access denied: path escapes project directory`) } const bunFile = Bun.file(full) - if (!(await bunFile.exists())) { return { type: "text", content: "" } } + // For existing files, also check resolved path to catch symlinks pointing outside + if (!Filesystem.containsResolved(Instance.directory, full)) { + throw new Error(`Access denied: path escapes project directory`) + } + const encode = await shouldEncode(bunFile) if (encode) { @@ -337,9 +340,8 @@ export namespace File { } const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory - // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. - // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. - if (!Instance.containsPath(resolved)) { + // Use containsResolved to handle symlinks that point outside the project + if (!Instance.containsPath(resolved) || !Filesystem.containsResolved(Instance.directory, resolved)) { throw new Error(`Access denied: path escapes project directory`) } diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 7ace4e4a262..2c892e6edae 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -43,6 +43,20 @@ export const EditTool = Tool.define("edit", { const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) await assertExternalDirectory(ctx, filePath) + // Check if path is a symlink pointing outside (even if target doesn't exist) + if (!Filesystem.containsResolved(Instance.directory, filePath)) { + const parentDir = path.dirname(filePath) + await ctx.ask({ + permission: "external_directory", + patterns: [parentDir, path.join(parentDir, "*")], + always: [parentDir + "/*"], + metadata: { + filepath: filePath, + parentDir, + }, + }) + } + let diff = "" let contentOld = "" let contentNew = "" diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts index 08a58bfea9c..ce4b7172b2e 100644 --- a/packages/opencode/src/tool/patch.ts +++ b/packages/opencode/src/tool/patch.ts @@ -8,6 +8,7 @@ import { FileWatcher } from "../file/watcher" import { Instance } from "../project/instance" import { Patch } from "../patch" import { createTwoFilesPatch } from "diff" +import { Filesystem } from "../util/filesystem" import { assertExternalDirectory } from "./external-directory" const PatchParams = z.object({ @@ -51,6 +52,20 @@ export const PatchTool = Tool.define("patch", { const filePath = path.resolve(Instance.directory, hunk.path) await assertExternalDirectory(ctx, filePath) + // Check if path is a symlink pointing outside (even if target doesn't exist) + if (!Filesystem.containsResolved(Instance.directory, filePath)) { + const parentDir = path.dirname(filePath) + await ctx.ask({ + permission: "external_directory", + patterns: [parentDir, path.join(parentDir, "*")], + always: [parentDir + "/*"], + metadata: { + filepath: filePath, + parentDir, + }, + }) + } + switch (hunk.type) { case "add": if (hunk.type === "add") { diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index ce4ab28619d..10249f1b910 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -7,6 +7,7 @@ import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" import { Identifier } from "../id/id" +import { Filesystem } from "../util/filesystem" import { assertExternalDirectory } from "./external-directory" const DEFAULT_READ_LIMIT = 2000 @@ -31,6 +32,21 @@ export const ReadTool = Tool.define("read", { bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), }) + // Check if path is a symlink pointing outside (even if target doesn't exist) + // This must happen BEFORE the exists check to catch broken symlinks + if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.containsResolved(Instance.directory, filepath)) { + const parentDir = path.dirname(filepath) + await ctx.ask({ + permission: "external_directory", + patterns: [parentDir], + always: [parentDir + "/*"], + metadata: { + filepath, + parentDir, + }, + }) + } + await ctx.ask({ permission: "read", patterns: [filepath], @@ -39,7 +55,9 @@ export const ReadTool = Tool.define("read", { }) const file = Bun.file(filepath) - if (!(await file.exists())) { + const exists = await file.exists() + + if (!exists) { const dir = path.dirname(filepath) const base = path.basename(filepath) diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index d621a6e26bf..1cd9b0bcc3a 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -25,6 +25,21 @@ export const WriteTool = Tool.define("write", { const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) await assertExternalDirectory(ctx, filepath) + // Check if path is a symlink pointing outside (even if target doesn't exist) + // This must happen BEFORE the exists check to catch broken symlinks + if (!Filesystem.containsResolved(Instance.directory, filepath)) { + const parentDir = path.dirname(filepath) + await ctx.ask({ + permission: "external_directory", + patterns: [parentDir], + always: [parentDir + "/*"], + metadata: { + filepath, + parentDir, + }, + }) + } + const file = Bun.file(filepath) const exists = await file.exists() const contentOld = exists ? await file.text() : "" diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 472bff83dd3..61295b169c7 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -1,6 +1,6 @@ -import { realpathSync } from "fs" +import { lstatSync, readlinkSync, realpathSync } from "fs" import { exists } from "fs/promises" -import { dirname, join, relative } from "path" +import { dirname, isAbsolute, join, relative, resolve } from "path" export namespace Filesystem { /** @@ -22,10 +22,75 @@ export namespace Filesystem { return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") } + /** + * Lexical containment check - does NOT resolve symlinks. + * Use containsResolved() when the child path may be a symlink pointing outside. + */ export function contains(parent: string, child: string) { return !relative(parent, child).startsWith("..") } + /** + * Containment check with symlink resolution. + * Returns true only if the resolved child path is within the resolved parent. + * Returns false if the path is a symlink pointing outside, even if broken. + * + * Note: There is an inherent TOCTOU (time-of-check-time-of-use) race condition + * between this check and actual file operations. This is acceptable for the + * threat model of protecting against malicious symlinks in user-controlled + * directories, but not against active attackers with concurrent write access. + */ + export function containsResolved(parent: string, child: string): boolean { + try { + const resolvedParent = realpathSync(parent) + + // First, check if the child path is a symlink + try { + const stats = lstatSync(child) + if (stats.isSymbolicLink()) { + // It's a symlink - check where it points + const linkTarget = readlinkSync(child) + const absoluteTarget = isAbsolute(linkTarget) ? linkTarget : resolve(dirname(child), linkTarget) + + // Try to resolve the full path (handles symlink chains) + try { + const resolvedChild = realpathSync(child) + return !relative(resolvedParent, resolvedChild).startsWith("..") + } catch { + // Broken symlink - check if target would be inside parent. + // relative() normalizes paths, so traversal attempts like + // /project/../../../etc are correctly detected as escaping. + return !relative(resolvedParent, absoluteTarget).startsWith("..") + } + } + } catch { + // lstatSync failed - path doesn't exist at all (not even as broken symlink) + } + + // Not a symlink - try to resolve normally + try { + const resolvedChild = realpathSync(child) + return !relative(resolvedParent, resolvedChild).startsWith("..") + } catch { + // Path doesn't exist - check parent directory + const childDir = dirname(child) + if (childDir === child) return false // root directory + + try { + const resolvedChildDir = realpathSync(childDir) + return !relative(resolvedParent, resolvedChildDir).startsWith("..") + } catch { + // Parent directory also doesn't exist - fall back to lexical check. + // This is safe because symlinks can't exist if the parent doesn't. + return contains(parent, child) + } + } + } catch { + // Parent doesn't exist or can't be resolved - deny access + return false + } + } + export async function findUp(target: string, start: string, stop?: string) { let current = start const result = [] diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 44ae8f15435..a80a64c1e71 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -1,35 +1,15 @@ import { test, expect, describe } from "bun:test" +import { $ } from "bun" import path from "path" import fs from "fs/promises" -import { Filesystem } from "../../src/util/filesystem" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" -describe("Filesystem.contains", () => { - test("allows paths within project", () => { - expect(Filesystem.contains("/project", "/project/src")).toBe(true) - expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true) - expect(Filesystem.contains("/project", "/project")).toBe(true) - }) - - test("blocks ../ traversal", () => { - expect(Filesystem.contains("/project", "/project/../etc")).toBe(false) - expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false) - expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) - }) - - test("blocks absolute paths outside project", () => { - expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) - expect(Filesystem.contains("/project", "/tmp/file")).toBe(false) - expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false) - }) - - test("handles prefix collision edge cases", () => { - expect(Filesystem.contains("/project", "/project-other/file")).toBe(false) - expect(Filesystem.contains("/project", "/projectfile")).toBe(false) - }) -}) +/* + * NOTE: Filesystem.contains() and Filesystem.containsResolved() unit tests + * are in test/util/filesystem.test.ts - not duplicated here. + */ /* * Integration tests for File.read() and File.list() path traversal protection. @@ -84,6 +64,43 @@ describe("File.read path traversal protection", () => { }, }) }) + + test("rejects symlink pointing outside project", async () => { + await using tmp = await tmpdir() + + // Create symlink inside project that points to /etc + const symlinkPath = path.join(tmp.path, "escape-link") + await $`ln -s /etc ${symlinkPath}`.quiet() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // The symlink "escape-link" exists inside the project lexically, + // but points to /etc which is outside - should be rejected + await expect(File.read("escape-link/passwd")).rejects.toThrow("Access denied: path escapes project directory") + }, + }) + }) + + test("allows symlink pointing within project", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "target.txt"), "symlink target content") + }, + }) + + // Create symlink pointing to file within same project + const symlinkPath = path.join(tmp.path, "internal-link.txt") + await $`ln -s ${path.join(tmp.path, "target.txt")} ${symlinkPath}`.quiet() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.read("internal-link.txt") + expect(result.content).toBe("symlink target content") + }, + }) + }) }) describe("File.list path traversal protection", () => { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 04ffc80ea67..47b024cf4e1 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { $ } from "bun" import path from "path" import { ReadTool } from "../../src/tool/read" import { Instance } from "../../src/project/instance" @@ -122,6 +123,92 @@ describe("tool.read external_directory permission", () => { }, }) }) + + test("asks for external_directory permission when reading symlink pointing outside project", async () => { + await using outerTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "secret.txt"), "secret data from outside") + }, + }) + await using tmp = await tmpdir({ git: true }) + + // Create symlink inside project pointing to file outside + const symlinkPath = path.join(tmp.path, "escape-link.txt") + await $`ln -s ${path.join(outerTmp.path, "secret.txt")} ${symlinkPath}`.quiet() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await read.execute({ filePath: symlinkPath }, testCtx) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + }, + }) + }) + + test("asks for external_directory permission when reading broken symlink pointing outside", async () => { + await using tmp = await tmpdir({ git: true }) + + // Create broken symlink pointing outside project + const symlinkPath = path.join(tmp.path, "broken-escape.txt") + await $`ln -s /nonexistent/outside/path ${symlinkPath}`.quiet() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + // Will fail because file doesn't exist, but permission should be asked first + await read.execute({ filePath: symlinkPath }, testCtx).catch(() => {}) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + }, + }) + }) + + test("does not ask for external_directory when reading symlink pointing inside project", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "target.txt"), "internal target content") + }, + }) + + // Create symlink inside project pointing to file inside project + const symlinkPath = path.join(tmp.path, "internal-link.txt") + await $`ln -s ${path.join(tmp.path, "target.txt")} ${symlinkPath}`.quiet() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await read.execute({ filePath: symlinkPath }, testCtx) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeUndefined() + }, + }) + }) }) describe("tool.read env file permissions", () => { diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts new file mode 100644 index 00000000000..d4ddb7d6404 --- /dev/null +++ b/packages/opencode/test/tool/write.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from "bun:test" +import { $ } from "bun" +import path from "path" +import { WriteTool } from "../../src/tool/write" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { PermissionNext } from "../../src/permission/next" +import { FileTime } from "../../src/file/time" + +const ctx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.write external_directory permission", () => { + test("allows writing to path inside project directory", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const write = await WriteTool.init() + const result = await write.execute( + { filePath: path.join(tmp.path, "new-file.txt"), content: "hello world" }, + ctx, + ) + expect(result.title).toContain("new-file.txt") + + // Verify file was written + const content = await Bun.file(path.join(tmp.path, "new-file.txt")).text() + expect(content).toBe("hello world") + }, + }) + }) + + test("asks for external_directory permission when writing to path outside project", async () => { + await using outerTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const write = await WriteTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await write.execute( + { filePath: path.join(outerTmp.path, "external.txt"), content: "external content" }, + testCtx, + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns.some((p) => p.includes(outerTmp.path))).toBe(true) + }, + }) + }) + + test("does not ask for external_directory permission when writing inside project", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const write = await WriteTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await write.execute({ filePath: path.join(tmp.path, "internal.txt"), content: "internal" }, testCtx) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeUndefined() + }, + }) + }) +}) + +describe("tool.write symlink protection", () => { + test("asks for external_directory permission when writing to symlink pointing outside project", async () => { + await using outerTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "target.txt"), "original content") + }, + }) + await using tmp = await tmpdir({ git: true }) + + // Create symlink inside project pointing to file outside + const symlinkPath = path.join(tmp.path, "escape-link.txt") + await $`ln -s ${path.join(outerTmp.path, "target.txt")} ${symlinkPath}`.quiet() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Mark file as "read" to satisfy FileTime.assert + FileTime.read(ctx.sessionID, symlinkPath) + + const write = await WriteTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await write.execute({ filePath: symlinkPath, content: "malicious content" }, testCtx) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + }, + }) + }) + + test("asks for external_directory permission when writing to broken symlink pointing outside", async () => { + await using tmp = await tmpdir({ git: true }) + + // Create broken symlink pointing outside project + const symlinkPath = path.join(tmp.path, "broken-escape.txt") + await $`ln -s /tmp/nonexistent-target-outside ${symlinkPath}`.quiet() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Mark file as "read" to satisfy FileTime.assert (broken symlink is treated as new file) + FileTime.read(ctx.sessionID, symlinkPath) + + const write = await WriteTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await write.execute({ filePath: symlinkPath, content: "content" }, testCtx) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + }, + }) + }) + + test("does not ask for external_directory when writing to symlink pointing inside project", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "target.txt"), "original internal content") + }, + }) + + // Create symlink inside project pointing to file inside project + const symlinkPath = path.join(tmp.path, "internal-link.txt") + await $`ln -s ${path.join(tmp.path, "target.txt")} ${symlinkPath}`.quiet() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Mark file as "read" to satisfy FileTime.assert + FileTime.read(ctx.sessionID, symlinkPath) + + const write = await WriteTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await write.execute({ filePath: symlinkPath, content: "new content" }, testCtx) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeUndefined() + }, + }) + }) + + test("asks for external_directory when writing via symlink directory escape", async () => { + await using outerTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + + // Create symlink to directory outside project + const symlinkDir = path.join(tmp.path, "escape-dir") + await $`ln -s ${outerTmp.path} ${symlinkDir}`.quiet() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const write = await WriteTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + // Try to write to a file through the symlinked directory + await write.execute({ filePath: path.join(symlinkDir, "pwned.txt"), content: "malicious" }, testCtx) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + }, + }) + }) +}) diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts new file mode 100644 index 00000000000..72e65a0a140 --- /dev/null +++ b/packages/opencode/test/util/filesystem.test.ts @@ -0,0 +1,222 @@ +import { test, expect, describe } from "bun:test" +import { $ } from "bun" +import path from "path" +import * as fs from "fs/promises" +import { Filesystem } from "../../src/util/filesystem" +import { tmpdir } from "../fixture/fixture" + +describe("Filesystem.contains (lexical)", () => { + test("allows paths within project", () => { + expect(Filesystem.contains("/project", "/project/src")).toBe(true) + expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true) + expect(Filesystem.contains("/project", "/project")).toBe(true) + }) + + test("blocks ../ traversal", () => { + expect(Filesystem.contains("/project", "/project/../etc")).toBe(false) + expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false) + expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) + }) + + test("blocks absolute paths outside project", () => { + expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) + expect(Filesystem.contains("/project", "/tmp/file")).toBe(false) + expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false) + }) + + test("handles prefix collision edge cases", () => { + expect(Filesystem.contains("/project", "/project-other/file")).toBe(false) + expect(Filesystem.contains("/project", "/projectfile")).toBe(false) + }) +}) + +describe("Filesystem.containsResolved (with symlink resolution)", () => { + test("allows regular paths within project", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "file.txt"), "content") + await fs.mkdir(path.join(dir, "subdir"), { recursive: true }) + await Bun.write(path.join(dir, "subdir/nested.txt"), "nested") + }, + }) + + expect(Filesystem.containsResolved(tmp.path, path.join(tmp.path, "file.txt"))).toBe(true) + expect(Filesystem.containsResolved(tmp.path, path.join(tmp.path, "subdir"))).toBe(true) + expect(Filesystem.containsResolved(tmp.path, path.join(tmp.path, "subdir/nested.txt"))).toBe(true) + }) + + test("blocks symlink pointing outside project", async () => { + await using tmp = await tmpdir() + + // Create a symlink inside project pointing to /etc + const symlinkPath = path.join(tmp.path, "escape-link") + await $`ln -s /etc ${symlinkPath}`.quiet() + + // Lexical check would pass (symlink path is inside project) + expect(Filesystem.contains(tmp.path, symlinkPath)).toBe(true) + + // Resolved check should FAIL (symlink resolves to /etc) + expect(Filesystem.containsResolved(tmp.path, symlinkPath)).toBe(false) + }) + + test("blocks symlink to specific file outside project", async () => { + await using tmp = await tmpdir() + + // Create symlink to /etc/passwd + const symlinkPath = path.join(tmp.path, "passwd-link") + await $`ln -s /etc/passwd ${symlinkPath}`.quiet() + + // Lexical passes, resolved fails + expect(Filesystem.contains(tmp.path, symlinkPath)).toBe(true) + expect(Filesystem.containsResolved(tmp.path, symlinkPath)).toBe(false) + }) + + test("allows symlink pointing within project", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "target.txt"), "target content") + }, + }) + + // Create symlink pointing to file within same project + const symlinkPath = path.join(tmp.path, "internal-link") + await $`ln -s ${path.join(tmp.path, "target.txt")} ${symlinkPath}`.quiet() + + // Both should pass + expect(Filesystem.contains(tmp.path, symlinkPath)).toBe(true) + expect(Filesystem.containsResolved(tmp.path, symlinkPath)).toBe(true) + }) + + test("blocks nested symlink escape", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "subdir"), { recursive: true }) + }, + }) + + // Create symlink in subdir pointing outside + const symlinkPath = path.join(tmp.path, "subdir", "escape") + await $`ln -s /tmp ${symlinkPath}`.quiet() + + expect(Filesystem.contains(tmp.path, symlinkPath)).toBe(true) + expect(Filesystem.containsResolved(tmp.path, symlinkPath)).toBe(false) + }) + + test("handles non-existent target (new file in valid dir)", async () => { + await using tmp = await tmpdir() + + // New file that doesn't exist yet, in a valid directory + const newFilePath = path.join(tmp.path, "new-file.txt") + + // Should allow - parent directory exists and is valid + expect(Filesystem.containsResolved(tmp.path, newFilePath)).toBe(true) + }) + + test("handles non-existent target in non-existent subdir", async () => { + await using tmp = await tmpdir() + + // New file in a directory that also doesn't exist + const newFilePath = path.join(tmp.path, "new-dir", "new-file.txt") + + // Falls back to lexical check - safe because symlink can't exist + expect(Filesystem.containsResolved(tmp.path, newFilePath)).toBe(true) + }) + + test("blocks path traversal via ../ even without symlinks", async () => { + await using tmp = await tmpdir() + + const traversalPath = path.join(tmp.path, "..", "etc", "passwd") + expect(Filesystem.containsResolved(tmp.path, traversalPath)).toBe(false) + }) + + test("handles relative symlink that escapes", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "deep", "nested"), { recursive: true }) + }, + }) + + // Create relative symlink that escapes: deep/nested/escape -> ../../../etc + const symlinkPath = path.join(tmp.path, "deep", "nested", "escape") + const result = await $`ln -s ../../../etc ${symlinkPath}`.quiet().nothrow() + + // Skip test if symlink creation failed (some CI environments restrict this) + if (result.exitCode !== 0) { + console.log("Skipping relative symlink test - symlink creation failed") + return + } + + // Symlink should be blocked because it escapes the project + expect(Filesystem.containsResolved(tmp.path, symlinkPath)).toBe(false) + }) +}) + +describe("Filesystem.containsResolved edge cases", () => { + test("handles broken symlink (target doesn't exist)", async () => { + await using tmp = await tmpdir() + + // Create symlink to non-existent path outside project + const symlinkPath = path.join(tmp.path, "broken-link") + await $`ln -s /nonexistent/path/that/does/not/exist ${symlinkPath}`.quiet() + + // realpathSync will throw for broken symlink - should return false + expect(Filesystem.containsResolved(tmp.path, symlinkPath)).toBe(false) + }) + + test("handles circular symlinks", async () => { + await using tmp = await tmpdir() + + // Try to create circular symlink (may fail on some systems) + const symlinkPath = path.join(tmp.path, "circular") + const result = await $`ln -s ${symlinkPath} ${symlinkPath}`.quiet().nothrow() + + // Skip test if symlink creation failed + if (result.exitCode !== 0) { + console.log("Skipping circular symlink test - symlink creation failed") + return + } + + // Should not throw - the key security property is no exceptions + // Note: circular symlinks pointing to themselves are treated as "broken" + // and their target is checked. A self-referential symlink like "circular -> circular" + // resolves to the same directory, so it's considered "contained" (doesn't escape). + expect(() => Filesystem.containsResolved(tmp.path, symlinkPath)).not.toThrow() + }) + + test("handles symlink chain that eventually escapes", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "file.txt"), "content") + }, + }) + + // link1 -> link2 -> /etc + const link2 = path.join(tmp.path, "link2") + const link1 = path.join(tmp.path, "link1") + await $`ln -s /etc ${link2}`.quiet() + await $`ln -s ${link2} ${link1}`.quiet() + + // Both should be blocked - realpathSync follows the full chain + expect(Filesystem.containsResolved(tmp.path, link1)).toBe(false) + expect(Filesystem.containsResolved(tmp.path, link2)).toBe(false) + }) + + test("handles symlink chain that stays internal", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "target.txt"), "content") + }, + }) + + // link1 -> link2 -> target.txt (all internal) + const target = path.join(tmp.path, "target.txt") + const link2 = path.join(tmp.path, "link2") + const link1 = path.join(tmp.path, "link1") + await $`ln -s ${target} ${link2}`.quiet() + await $`ln -s ${link2} ${link1}`.quiet() + + // Both should be allowed + expect(Filesystem.containsResolved(tmp.path, link1)).toBe(true) + expect(Filesystem.containsResolved(tmp.path, link2)).toBe(true) + }) +})