diff --git a/src/core/auto-approval/WorkspaceRootCanonicalizer.ts b/src/core/auto-approval/WorkspaceRootCanonicalizer.ts new file mode 100644 index 0000000000..d78579a1f9 --- /dev/null +++ b/src/core/auto-approval/WorkspaceRootCanonicalizer.ts @@ -0,0 +1,44 @@ +import type { WorkspaceRootStrategy } from "./strategies/WorkspaceRootStrategy" + +/** Translate only a verified workspace-root spelling, never a file symlink. */ +export class WorkspaceRootCanonicalizer { + private constructor( + private readonly root: string | undefined, + private readonly canonicalRoot: string | undefined, + private readonly strategy: WorkspaceRootStrategy, + ) {} + + static forStrategy( + cwd: string | undefined, + canonicalCwd: string | undefined, + strategy: WorkspaceRootStrategy, + ): WorkspaceRootCanonicalizer { + const root = strategy.normalizeAbsoluteRoot(cwd) + const canonicalRoot = strategy.normalizeAbsoluteRoot(canonicalCwd) + return new WorkspaceRootCanonicalizer(root, canonicalRoot, strategy) + } + + canonicalize(value: string): string { + if (this.root === undefined || this.canonicalRoot === undefined) return value + + return this.replaceWorkspaceRootPrefix(value, this.root, this.canonicalRoot) + } + + /** Keep both verified spellings so globs spanning the root retain their meaning. */ + equivalentPaths(value: string): string[] { + if (this.root === undefined || this.canonicalRoot === undefined) return [value] + + const canonical = this.canonicalize(value) + const equivalent = + canonical !== value ? canonical : this.replaceWorkspaceRootPrefix(value, this.canonicalRoot, this.root) + return equivalent === value ? [value] : [value, equivalent] + } + + private replaceWorkspaceRootPrefix(value: string, root: string, canonicalRoot: string): string { + const comparableValue = this.strategy.comparisonKey(value) + const comparableRoot = this.strategy.comparisonKey(root) + return comparableValue === comparableRoot || comparableValue.startsWith(`${comparableRoot}/`) + ? `${canonicalRoot}${value.slice(root.length)}` + : value + } +} diff --git a/src/core/auto-approval/__tests__/WorkspaceRootCanonicalizer.spec.ts b/src/core/auto-approval/__tests__/WorkspaceRootCanonicalizer.spec.ts new file mode 100644 index 0000000000..8a46b4b6f4 --- /dev/null +++ b/src/core/auto-approval/__tests__/WorkspaceRootCanonicalizer.spec.ts @@ -0,0 +1,171 @@ +import { PosixWorkspaceRootStrategy } from "../strategies/PosixWorkspaceRootStrategy" +import { WindowsWorkspaceRootStrategy } from "../strategies/WindowsWorkspaceRootStrategy" +import { WorkspaceRootStrategy } from "../strategies/WorkspaceRootStrategy" +import { WorkspaceRootCanonicalizer } from "../WorkspaceRootCanonicalizer" + +it("normalizes roots once in the factory before any path comparisons", () => { + const normalizeAbsoluteRoot = vi + .fn() + .mockReturnValueOnce("/aliases/project") + .mockReturnValueOnce("/storage/project") + const comparisonKey = vi.fn((value: string) => value) + const strategy: WorkspaceRootStrategy = { + normalizeAbsoluteRoot, + comparisonKey, + toMatcherAbsolutePath: vi.fn((value: string) => value), + } + + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy("/aliases/project/", "/storage/project/", strategy) + + expect(normalizeAbsoluteRoot).toHaveBeenCalledTimes(2) + expect(normalizeAbsoluteRoot).toHaveBeenNthCalledWith(1, "/aliases/project/") + expect(normalizeAbsoluteRoot).toHaveBeenNthCalledWith(2, "/storage/project/") + expect(comparisonKey).not.toHaveBeenCalled() + + expect(canonicalizer.canonicalize("/aliases/project/docs/file.md")).toBe("/storage/project/docs/file.md") + expect(canonicalizer.canonicalize("/aliases/project/notes.md")).toBe("/storage/project/notes.md") + expect(normalizeAbsoluteRoot).toHaveBeenCalledTimes(2) +}) + +describe.each([ + { + platform: "POSIX", + isWindows: false, + cwd: "/aliases/project", + canonicalCwd: "/storage/project", + root: "/aliases/project", + canonicalRoot: "/storage/project", + }, + { + platform: "Windows", + isWindows: true, + cwd: "C:\\Aliases\\Project", + canonicalCwd: "D:\\Storage\\Project", + root: "/C/Aliases/Project", + canonicalRoot: "/D/Storage/Project", + }, +])("workspace-root canonicalization on $platform", ({ isWindows, cwd, canonicalCwd, root, canonicalRoot }) => { + const strategy = WorkspaceRootStrategy.forPlatform(isWindows) + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy(cwd, canonicalCwd, strategy) + + it("translates an exact root and preserves descendant spelling and globs", () => { + expect(canonicalizer.canonicalize(root)).toBe(canonicalRoot) + expect(canonicalizer.canonicalize(`${root}/Docs/NOTES.md`)).toBe(`${canonicalRoot}/Docs/NOTES.md`) + expect(canonicalizer.canonicalize(`${root}/docs/**`)).toBe(`${canonicalRoot}/docs/**`) + }) + + it("does not translate similarly prefixed siblings or lexical parents", () => { + for (const value of [`${root}-other/docs/file.md`, `${root}2/file.md`, "/elsewhere/docs/file.md"]) { + expect(canonicalizer.canonicalize(value)).toBe(value) + } + }) + + it("leaves canonical-root paths and relative values unchanged", () => { + for (const value of [canonicalRoot, `${canonicalRoot}/docs/file.md`, "docs/file.md", "../shared/file.md"]) { + expect(canonicalizer.canonicalize(value)).toBe(value) + } + }) + + it.each([undefined, "", "relative/project", "C:", "C:relative"])( + "does not infer equivalence from an unknown or relative root: %s", + (invalidRoot) => { + const value = `${root}/docs/file.md` + expect( + WorkspaceRootCanonicalizer.forStrategy(invalidRoot, canonicalCwd, strategy).canonicalize(value), + ).toBe(value) + expect(WorkspaceRootCanonicalizer.forStrategy(cwd, invalidRoot, strategy).canonicalize(value)).toBe(value) + }, + ) +}) + +describe("POSIX workspace-root strategy", () => { + it("compares case-sensitively and keeps backslashes as filename characters", () => { + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy( + "/aliases/My\\Project", + "/storage/My\\Project", + new PosixWorkspaceRootStrategy(), + ) + expect(canonicalizer.canonicalize("/aliases/My\\Project/docs/file.md")).toBe( + "/storage/My\\Project/docs/file.md", + ) + expect(canonicalizer.canonicalize("/aliases/my\\project/docs/file.md")).toBe( + "/aliases/my\\project/docs/file.md", + ) + expect(canonicalizer.canonicalize("/aliases/My/Project/docs/file.md")).toBe("/aliases/My/Project/docs/file.md") + }) + + it("normalizes root dot segments and trailing separators", () => { + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy( + "/aliases/temporary/../project/", + "/storage/./project/", + new PosixWorkspaceRootStrategy(), + ) + expect(canonicalizer.canonicalize("/aliases/project/docs/file.md")).toBe("/storage/project/docs/file.md") + }) + + it("does not interpret Windows drive paths as absolute roots", () => { + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy( + "C:/aliases/project", + "D:/storage/project", + new PosixWorkspaceRootStrategy(), + ) + expect(canonicalizer.canonicalize("/C/aliases/project/docs/file.md")).toBe("/C/aliases/project/docs/file.md") + }) +}) + +describe("Windows workspace-root strategy", () => { + it("compares case-insensitively across drives without changing descendant case", () => { + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy( + "C:\\Aliases\\Project", + "D:\\Storage\\Project", + new WindowsWorkspaceRootStrategy(), + ) + expect(canonicalizer.canonicalize("/c/ALIASES/project/Docs/NOTES.md")).toBe("/D/Storage/Project/Docs/NOTES.md") + expect(canonicalizer.canonicalize("/E/Aliases/Project/Docs/NOTES.md")).toBe("/E/Aliases/Project/Docs/NOTES.md") + }) + + it("normalizes mixed root separators, dot segments, and trailing separators", () => { + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy( + "C:\\Aliases/temporary/../Project\\", + "D:/Storage/./Project/", + new WindowsWorkspaceRootStrategy(), + ) + expect(canonicalizer.canonicalize("/C/Aliases/Project/docs/file.md")).toBe("/D/Storage/Project/docs/file.md") + }) + + it("supports drive roots and drive-less absolute roots in matcher notation", () => { + const strategy = new WindowsWorkspaceRootStrategy() + expect(WorkspaceRootCanonicalizer.forStrategy("C:/", "D:/", strategy).canonicalize("/C/docs/file.md")).toBe( + "/D/docs/file.md", + ) + expect( + WorkspaceRootCanonicalizer.forStrategy("\\Aliases\\Project", "/Storage/Project", strategy).canonicalize( + "/ROOT/Aliases/Project/docs/file.md", + ), + ).toBe("/ROOT/Storage/Project/docs/file.md") + }) + + it("normalizes network roots into the shared UNC matcher namespace", () => { + const canonicalizer = WorkspaceRootCanonicalizer.forStrategy( + "\\\\server\\share\\project", + "\\\\server\\storage\\project", + new WindowsWorkspaceRootStrategy(), + ) + expect(canonicalizer.canonicalize("/UNC/SERVER/SHARE/project/docs/file.md")).toBe( + "/UNC/server/storage/project/docs/file.md", + ) + }) +}) + +it.each([false, true])("preserves filesystem-root equivalence (Windows=%s)", (isWindows) => { + const prefix = isWindows ? "/ROOT" : "" + const strategy = WorkspaceRootStrategy.forPlatform(isWindows) + expect( + WorkspaceRootCanonicalizer.forStrategy("/", "/storage", strategy).canonicalize(`${prefix}/docs/file.md`), + ).toBe(`${prefix}/storage/docs/file.md`) + expect( + WorkspaceRootCanonicalizer.forStrategy("/aliases", "/", strategy).canonicalize( + `${prefix}/aliases/docs/file.md`, + ), + ).toBe(`${prefix}/docs/file.md`) +}) diff --git a/src/core/auto-approval/__tests__/WorkspaceRootStrategy.spec.ts b/src/core/auto-approval/__tests__/WorkspaceRootStrategy.spec.ts new file mode 100644 index 0000000000..db0c8080cc --- /dev/null +++ b/src/core/auto-approval/__tests__/WorkspaceRootStrategy.spec.ts @@ -0,0 +1,57 @@ +import { isFileMatchedByPatterns } from "../filePatterns" +import { WorkspaceRootStrategy } from "../strategies/WorkspaceRootStrategy" + +describe("WorkspaceRootStrategy.forPlatform", () => { + afterEach(() => vi.restoreAllMocks()) + + it("selects POSIX behavior without interpreting drives, network prefixes, or backslashes", () => { + const strategy = WorkspaceRootStrategy.forPlatform(false) + + expect(strategy.toMatcherAbsolutePath("/tmp/notes.md", "/C")).toBe("/tmp/notes.md") + expect(strategy.toMatcherAbsolutePath("//server/share/notes.md", "/C")).toBe("//server/share/notes.md") + expect(strategy.toMatcherAbsolutePath("/tmp/my\\file.md", "/C")).toBe("/tmp/my\\file.md") + expect(strategy.comparisonKey("/tmp/NOTES.md")).toBe("/tmp/NOTES.md") + }) + + it("selects Windows behavior for drives, rooted paths, and the distinct UNC namespace", () => { + const strategy = WorkspaceRootStrategy.forPlatform(true) + + expect(strategy.toMatcherAbsolutePath("D:/tmp/notes.md", "/C")).toBe("/D/tmp/notes.md") + expect(strategy.toMatcherAbsolutePath("/tmp/notes.md", "/C")).toBe("/C/tmp/notes.md") + expect(strategy.toMatcherAbsolutePath("//server/share/notes.md", "/C")).toBe("/UNC/server/share/notes.md") + expect(strategy.toMatcherAbsolutePath("//C/share/notes.md", "/C")).not.toBe("/C/share/notes.md") + expect(strategy.comparisonKey("/D/Tmp/NOTES.md")).toBe("/d/tmp/notes.md") + }) + + it("uses one selected strategy for candidates, every pattern, and both workspace roots", () => { + const toMatcherAbsolutePath = vi.fn((value: string) => value) + const normalizeAbsoluteRoot = vi + .fn() + .mockReturnValueOnce("/aliases/project") + .mockReturnValueOnce("/storage/project") + const comparisonKey = vi.fn((value: string) => value) + const strategy: WorkspaceRootStrategy = { toMatcherAbsolutePath, normalizeAbsoluteRoot, comparisonKey } + const selectStrategy = vi.spyOn(WorkspaceRootStrategy, "forPlatform").mockReturnValue(strategy) + + expect( + isFileMatchedByPatterns({ + filePath: "/storage/project/docs/notes.md", + cwd: "/aliases/project", + canonicalCwd: "/storage/project", + patterns: ["docs/**", "!/aliases/project/docs/private.md", "/aliases/project/docs/notes.md"], + isWindows: false, + }), + ).toBe(true) + + expect(selectStrategy).toHaveBeenCalledOnce() + expect(selectStrategy).toHaveBeenCalledWith(false) + expect(toMatcherAbsolutePath).toHaveBeenCalledWith("/storage/project/docs/notes.md", "") + expect(toMatcherAbsolutePath).toHaveBeenCalledWith("/aliases/project", "") + expect(toMatcherAbsolutePath).toHaveBeenCalledWith("/aliases/project/docs/private.md", "") + expect(toMatcherAbsolutePath).toHaveBeenCalledWith("/aliases/project/docs/notes.md", "") + expect(normalizeAbsoluteRoot).toHaveBeenCalledTimes(2) + expect(normalizeAbsoluteRoot).toHaveBeenNthCalledWith(1, "/aliases/project") + expect(normalizeAbsoluteRoot).toHaveBeenNthCalledWith(2, "/storage/project") + expect(comparisonKey).toHaveBeenCalled() + }) +}) diff --git a/src/core/auto-approval/__tests__/filePatterns.spec.ts b/src/core/auto-approval/__tests__/filePatterns.spec.ts index 6b1c3ff6b9..23ceaa1089 100644 --- a/src/core/auto-approval/__tests__/filePatterns.spec.ts +++ b/src/core/auto-approval/__tests__/filePatterns.spec.ts @@ -86,7 +86,7 @@ describe("toMatcherPattern", () => { // name, so `C:/tmp/notes.md` names a file inside it, relative to the workspace. describe("on Windows", () => { // The colon is dropped, leaving the drive as an ordinary leading path - // segment; see `toPosixAbsolutePath` for why it cannot stay. + // segment: the Windows strategy avoids drive-rooted paths rejected by `ignore`. it("keeps the drive of an absolute pattern as its first path segment", () => { expect(toWindowsPattern("D:/tmp/notes.md")).toBe("/D/tmp/notes.md") }) diff --git a/src/core/auto-approval/__tests__/posixPaths.spec.ts b/src/core/auto-approval/__tests__/posixPaths.spec.ts index 5e9da6312f..bb697dc0cd 100644 --- a/src/core/auto-approval/__tests__/posixPaths.spec.ts +++ b/src/core/auto-approval/__tests__/posixPaths.spec.ts @@ -20,6 +20,25 @@ describe("matching off Windows", () => { const matches = (filePath: string, patterns: string[]) => matcher().isFileMatchedByPatterns({ filePath, cwd: CWD, patterns }) + it("keeps backslashes in a workspace root literal", () => { + const rootMatch = (filePath: string) => + matcher().isFileMatchedByPatterns({ + filePath, + cwd: "/aliases/my\\project", + canonicalCwd: "/storage/my\\project", + patterns: ["docs/**"], + }) + expect(rootMatch("/aliases/my\\project/docs/notes.md")).toBe(true) + expect(rootMatch("/storage/my\\project/docs/notes.md")).toBe(true) + expect(rootMatch("/aliases/my/project/docs/notes.md")).toBe(false) + }) + + it("matches a relative grant at the filesystem root", () => { + expect(matcher().isFileMatchedByPatterns({ filePath: "/docs/notes.md", cwd: "/", patterns: ["docs/**"] })).toBe( + true, + ) + }) + // A backslash is a legal filename character here, where `my\file` is one file // rather than `file` inside `my`. it("reads a backslash as part of the filename, not as a separator", () => { @@ -39,6 +58,18 @@ describe("matching off Windows", () => { expect(matches("/tmp/notes.md", ["C:/tmp/notes.md"])).toBe(false) }) + it("keeps drive-relative-looking names usable as literal POSIX paths", () => { + expect(matches("C:notes.md", ["C:notes.md"])).toBe(true) + expect(matches("D:docs/notes.md", ["D:docs/**"])).toBe(true) + expect( + matcher().isFileMatchedByPatterns({ + filePath: "docs/notes.md", + cwd: "/workspace/C:", + patterns: ["docs/**"], + }), + ).toBe(true) + }) + it("matches a bare pattern anywhere in the workspace, and nowhere else", () => { expect(matches("notes.md", ["notes.md"])).toBe(true) expect(matches("deeply/nested/notes.md", ["notes.md"])).toBe(true) diff --git a/src/core/auto-approval/__tests__/windowsDriveRelativeApproval.spec.ts b/src/core/auto-approval/__tests__/windowsDriveRelativeApproval.spec.ts new file mode 100644 index 0000000000..990eeb7f59 --- /dev/null +++ b/src/core/auto-approval/__tests__/windowsDriveRelativeApproval.spec.ts @@ -0,0 +1,56 @@ +import * as fs from "fs/promises" +import { baseState } from "./fixtures" +import { matcherForPlatform } from "./matcherForPlatform" + +vi.mock("fs/promises", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, realpath: vi.fn() } +}) + +describe("approval with a drive-relative Windows workspace", () => { + matcherForPlatform("win32") + + beforeEach(() => { + vi.mocked(fs.realpath).mockReset().mockResolvedValue("C:/repo") + }) + + it.each(["readFile", "newFileCreated"] as const)( + "does not approve %s outside the actual workspace through relative grants", + async (tool) => { + const { checkAutoApproval } = await import("../index") + const state = { ...baseState, allowedReadFiles: ["docs/**"], allowedWriteFiles: ["docs/**"] } + for (const path of ["C:/docs/private.md", "../docs/private.md"]) { + expect( + await checkAutoApproval({ + state, + cwd: "C:", + ask: "tool", + text: JSON.stringify({ tool, path, isOutsideWorkspace: true }), + }), + ).toEqual({ decision: "ask" }) + } + + const text = JSON.stringify({ tool, path: "C:/docs/private.md", isOutsideWorkspace: true }) + expect( + await checkAutoApproval({ + state: { ...state, allowedReadFiles: ["C:/docs/**"], allowedWriteFiles: ["C:/docs/**"] }, + cwd: "C:", + ask: "tool", + text, + }), + ).toEqual({ decision: "approve" }) + expect( + await checkAutoApproval({ + state: { + ...state, + allowedReadFiles: ["C:/docs/**", "!C:/docs/private.md"], + allowedWriteFiles: ["C:/docs/**", "!C:/docs/private.md"], + }, + cwd: "C:", + ask: "tool", + text, + }), + ).toEqual({ decision: "ask" }) + }, + ) +}) diff --git a/src/core/auto-approval/__tests__/windowsPaths.spec.ts b/src/core/auto-approval/__tests__/windowsPaths.spec.ts index acedfa726c..fc94221b03 100644 --- a/src/core/auto-approval/__tests__/windowsPaths.spec.ts +++ b/src/core/auto-approval/__tests__/windowsPaths.spec.ts @@ -30,6 +30,190 @@ describe("matching on Windows", () => { const matches = (filePath: string, patterns: string[], cwd: string | undefined = WINDOWS_CWD) => matcher().isFileMatchedByPatterns({ filePath, cwd, patterns, homeDir: WINDOWS_HOME }) + it("keeps share-rooted paths separate from the internal UNC namespace", () => { + const cwd = "\\\\server\\share\\project" + const rooted = "/UNC/server/share/project/docs/**" + expect(matches("docs/private.md", [rooted], cwd)).toBe(false) + expect(matches("//server/share/project/docs/private.md", [rooted], cwd)).toBe(false) + expect(matches("//server/share/UNC/server/share/project/docs/private.md", [rooted], cwd)).toBe(true) + expect(matches("/UNC/server/share/project/docs/private.md", ["docs/**"], cwd)).toBe(false) + expect(matches("docs/private.md", ["docs/**", `!${rooted}`], cwd)).toBe(true) + }) + + it("resolves share-rooted paths against the workspace share", () => { + const cwd = "\\\\server\\share\\project" + expect(matches("//server/share/docs/private.md", ["/docs/**"], cwd)).toBe(true) + expect(matches("/docs/private.md", ["//server/share/docs/**"], cwd)).toBe(true) + expect(matches("//other/share/docs/private.md", ["/docs/**"], cwd)).toBe(false) + }) + + it.each(["C:/", "//server/share", "//server/share/"])("matches relative grants at a volume root: %s", (cwd) => { + const absolutePath = `${cwd.replace(/\/$/, "")}/docs/private.md` + expect(matches("docs/private.md", ["docs/**"], cwd)).toBe(true) + expect(matches(absolutePath, ["docs/**"], cwd)).toBe(true) + expect(matches("docs/private.md", ["docs/**", "!docs/private.md"], cwd)).toBe(false) + expect(matches(absolutePath, ["docs/**", "!docs/private.md"], cwd)).toBe(false) + }) + + it.each(["C:", "c:", "C:repo"])("does not anchor relative grants to a drive-relative workspace: %s", (cwd) => { + expect(matcher().toMatcherPattern("docs/**", cwd)).toBeUndefined() + for (const canonicalCwd of [undefined, "C:/repo"]) { + for (const filePath of [ + "docs/private.md", + "../docs/private.md", + "C:/docs/private.md", + "C:/repo/docs/private.md", + ]) { + expect(matcher().isFileMatchedByPatterns({ filePath, cwd, canonicalCwd, patterns: ["docs/**"] })).toBe( + false, + ) + } + expect( + matcher().isFileMatchedByPatterns({ + filePath: "C:/docs/notes.md", + cwd, + canonicalCwd, + patterns: ["C:/docs/**"], + }), + ).toBe(true) + expect( + matcher().isFileMatchedByPatterns({ + filePath: "C:/docs/private.md", + cwd, + canonicalCwd, + patterns: ["C:/docs/**", "!C:/docs/private.md"], + }), + ).toBe(false) + expect( + matcher().isFileMatchedByPatterns({ + filePath: "../docs/notes.md", + cwd, + canonicalCwd, + patterns: ["C:/docs/**"], + }), + ).toBe(false) + } + }) + + it.each(["C:", "C:docs/private.md", "D:docs/private.md"])( + "does not guess the location of a drive-relative candidate or pattern: %s", + (filePath) => { + expect(matches(filePath, ["**"])).toBe(false) + expect(matcher().toMatcherPattern(filePath, WINDOWS_CWD)).toBeUndefined() + }, + ) + + it("clamps lexical parents at drive and share roots without crossing matcher namespaces", () => { + const cwd = "\\\\server\\share\\project" + expect(matches("//server/share/docs/private.md", ["../../../docs/**"], cwd)).toBe(true) + expect(matches("C:/docs/private.md", ["../../../../C/docs/**"], cwd)).toBe(false) + expect(matches("../../../../C/docs/private.md", ["C:/docs/**"], cwd)).toBe(false) + expect(matches("C:/docs/private.md", ["../../../docs/**"], "C:/project")).toBe(true) + expect(matches("//server/share/docs/private.md", ["../../UNC/server/share/docs/**"], "C:/project")).toBe(false) + }) + + it.each(["//server/sh[a]re/shared/x.md", "\\\\server\\sh[a]re\\shared\\x.md"])( + "keeps UNC share names literal when resolving a lexical-parent grant: %s", + (filePath) => { + const cwd = "\\\\server\\sh[a]re\\project" + const patterns = ["../shared/x.md"] + expect(matches(filePath, patterns, cwd)).toBe(true) + expect(matches("../shared/x.md", patterns, cwd)).toBe(true) + for (const share of ["share", "shaare"]) { + expect(matches(`//server/${share}/shared/x.md`, patterns, cwd)).toBe(false) + } + }, + ) + + it("keeps unknown-volume rooted paths separate from explicit UNC paths", () => { + expect( + matcher().isFileMatchedByPatterns({ + filePath: "//server/share/docs/private.md", + patterns: ["/UNC/server/share/docs/**"], + }), + ).toBe(false) + expect( + matcher().isFileMatchedByPatterns({ + filePath: "/UNC/server/share/docs/private.md", + patterns: ["//server/share/docs/**"], + }), + ).toBe(false) + }) + + it("preserves wildcard exclusions spanning drive and UNC workspace roots", () => { + const rootMatch = (filePath: string, patterns: string[]) => + matcher().isFileMatchedByPatterns({ + filePath, + cwd: "C:/Aliases/Project", + canonicalCwd: "//server/share/Project", + patterns, + }) + for (const filePath of ["docs/private.md", "//server/share/Project/docs/private.md"]) { + for (const exclusion of ["!C:/Aliases/**/private.md", "!//server/share/Proj*/docs/private.md"]) { + expect(rootMatch(filePath, ["docs/**", exclusion])).toBe(false) + expect(rootMatch(filePath, [exclusion, "docs/**"])).toBe(true) + expect(rootMatch(filePath, ["docs/**", exclusion, "C:/Aliases/Project/docs/private.md"])).toBe(true) + } + } + }) + + it.each(["\\\\server\\share\\project\\docs\\notes.md", "//server/share/project/docs/notes.md"])( + "matches an absolute UNC candidate against a relative grant: %s", + (filePath) => { + expect(matches(filePath, ["docs/**"], "\\\\server\\share\\project")).toBe(true) + }, + ) + + it("matches explicit UNC patterns from a local-drive workspace and without a workspace", () => { + const filePath = "\\\\server\\share\\project\\docs\\notes.md" + const patterns = ["//server/share/project/docs/**"] + expect(matches(filePath, patterns)).toBe(true) + expect(matcher().isFileMatchedByPatterns({ filePath, patterns, homeDir: WINDOWS_HOME })).toBe(true) + }) + + it.each([ + { cwd: "\\\\alias\\share\\project", canonicalCwd: "\\\\server\\storage\\project" }, + { cwd: "C:\\Aliases\\Project", canonicalCwd: "\\\\server\\storage\\project" }, + { cwd: "\\\\alias\\share\\project", canonicalCwd: "D:\\Storage\\Project" }, + ])("matches equivalent local and UNC roots ($cwd -> $canonicalCwd)", ({ cwd, canonicalCwd }) => { + const filePath = `${canonicalCwd}\\DOCS\\NOTES.md` + const rootMatch = (patterns: string[]) => + matcher().isFileMatchedByPatterns({ filePath, cwd, canonicalCwd, patterns, homeDir: WINDOWS_HOME }) + expect(rootMatch(["docs/**"])).toBe(true) + expect(rootMatch([`${cwd}/docs/**`])).toBe(true) + expect(rootMatch([`${canonicalCwd}/docs/**`])).toBe(true) + expect(rootMatch(["docs/**", `!${cwd}/docs/notes.md`])).toBe(false) + expect(rootMatch(["docs/**", `!${canonicalCwd}/docs/notes.md`])).toBe(false) + expect(rootMatch(["docs/**", `!${cwd}/docs/notes.md`, `${canonicalCwd}/docs/notes.md`])).toBe(true) + }) + + it("honors UNC exclusions for both relative and absolute candidates", () => { + const cwd = "\\\\server\\share\\project" + const patterns = ["docs/**", "!//server/share/project/docs/private.md"] + expect(matches("docs/private.md", patterns, cwd)).toBe(false) + expect(matches(`${cwd}\\docs\\private.md`, patterns, cwd)).toBe(false) + expect(matches(`${cwd}\\docs\\notes.md`, patterns, cwd)).toBe(true) + expect(matches("docs/private.md", [...patterns, "//server/share/project/docs/private.md"], cwd)).toBe(true) + }) + + it("keeps different UNC servers, shares, and similarly prefixed siblings separate", () => { + const cwd = "\\\\server\\share\\project" + for (const filePath of [ + "\\\\other-server\\share\\project\\docs\\notes.md", + "\\\\server\\other-share\\project\\docs\\notes.md", + "\\\\server\\share\\project-other\\docs\\notes.md", + ]) { + expect(matches(filePath, ["docs/**"], cwd)).toBe(false) + } + }) + + it("does not confuse a UNC server named C with the local C drive", () => { + expect(matches("C:\\share\\project\\docs\\notes.md", ["docs/**"], "\\\\C\\share\\project")).toBe(false) + expect(matches("\\\\C\\share\\project\\docs\\notes.md", ["docs/**"], "C:\\share\\project")).toBe(false) + expect(matches("C:\\share\\project\\docs\\notes.md", ["//C/share/project/docs/**"])).toBe(false) + expect(matches("\\\\C\\share\\project\\docs\\notes.md", ["C:/share/project/docs/**"])).toBe(false) + }) + it("matches a workspace-relative path against a bare pattern", () => { expect(matches("notes.md", ["notes.md"])).toBe(true) expect(matches("docs\\notes.md", ["notes.md"])).toBe(true) @@ -99,4 +283,34 @@ describe("matching on Windows", () => { expect(matches("C:/path/to/repo/etc/passwd", ["passwd"])).toBe(true) expect(matches("C:/etc/passwd", ["passwd"])).toBe(false) }) + + it("matches equivalent workspace roots across drives with Windows case and ordered exclusions", () => { + const rootMatch = (patterns: string[]) => + matcher().isFileMatchedByPatterns({ + filePath: "d:\\storage\\project\\DOCS\\NOTES.md", + cwd: "C:\\Aliases\\Project", + canonicalCwd: "D:\\Storage\\Project", + patterns, + }) + expect(rootMatch(["docs/**"])).toBe(true) + expect(rootMatch(["c:/aliases/PROJECT/docs/**"])).toBe(true) + expect(rootMatch(["d:/STORAGE/project/docs/**"])).toBe(true) + expect(rootMatch(["docs/**", "!c:/aliases/project/docs/notes.md"])).toBe(false) + expect(rootMatch(["docs/**", "!D:/Storage/Project/docs/notes.md"])).toBe(false) + }) + + it("keeps Windows siblings and lexical-parent outside patterns separate from canonical roots", () => { + const rootMatch = (filePath: string, patterns: string[]) => + matcher().isFileMatchedByPatterns({ + filePath, + cwd: "C:\\Aliases\\Project", + canonicalCwd: "D:\\Storage\\Project", + patterns, + }) + expect(rootMatch("C:/Aliases/shared/file.md", ["../shared/file.md"])).toBe(true) + expect(rootMatch("D:/Storage/shared/file.md", ["../shared/file.md"])).toBe(false) + expect(rootMatch("D:/Storage/shared/file.md", ["D:/Storage/shared/file.md"])).toBe(true) + expect(rootMatch("D:/Storage/Project-other/docs/file.md", ["docs/**"])).toBe(false) + expect(rootMatch("D:/Storage/Project-other/docs/file.md", ["C:/Aliases/Project-other/docs/**"])).toBe(false) + }) }) diff --git a/src/core/auto-approval/__tests__/workspaceRootEquivalence.spec.ts b/src/core/auto-approval/__tests__/workspaceRootEquivalence.spec.ts new file mode 100644 index 0000000000..36dafa3f31 --- /dev/null +++ b/src/core/auto-approval/__tests__/workspaceRootEquivalence.spec.ts @@ -0,0 +1,204 @@ +import * as fs from "fs/promises" +import type { ClineSayTool } from "@roo-code/types" +import { checkAutoApproval } from "../index" +import { isFileMatchedByPatterns } from "../filePatterns" +import { baseState, type State } from "./fixtures" + +vi.mock("fs/promises", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, realpath: vi.fn() } +}) + +const CWD = "/aliases/project" +const CANONICAL_CWD = "/storage/project" + +function matches(filePath: string, patterns: string[]) { + return isFileMatchedByPatterns({ filePath, cwd: CWD, canonicalCwd: CANONICAL_CWD, patterns, isWindows: false }) +} + +function approval(tool: ClineSayTool, state: Partial, cwd: string | undefined = CWD, isProtected = false) { + return checkAutoApproval({ + state: { ...baseState, ...state }, + cwd, + ask: "tool", + text: JSON.stringify(tool), + isProtected, + }) +} + +beforeEach(() => { + vi.mocked(fs.realpath).mockReset().mockResolvedValue(CANONICAL_CWD) +}) + +afterEach(() => vi.restoreAllMocks()) + +describe("read matcher workspace-root equivalence", () => { + it.each([CWD, CANONICAL_CWD, "docs/private.md"])( + "preserves wildcard exclusions spanning either root for %s", + (candidateRoot) => { + const candidate = candidateRoot === "docs/private.md" ? candidateRoot : `${candidateRoot}/docs/private.md` + for (const exclusion of ["!/aliases/**/private.md", "!/storage/proj*/docs/private.md"]) { + expect(matches(candidate, ["docs/**", exclusion])).toBe(false) + expect(matches(candidate, [exclusion, "docs/**"])).toBe(true) + expect(matches(candidate, ["docs/**", exclusion, `${CANONICAL_CWD}/docs/private.md`])).toBe(true) + } + }, + ) + + it.each(["project[1]", "project*", "project?"])("keeps the canonical root %s literal", (name) => { + const rootMatch = (filePath: string) => + isFileMatchedByPatterns({ + filePath, + cwd: CWD, + canonicalCwd: `/storage/${name}`, + patterns: ["docs/**"], + isWindows: false, + }) + expect(rootMatch(`/storage/${name}/docs/notes.md`)).toBe(true) + expect(rootMatch(`${CWD}/docs/notes.md`)).toBe(true) + expect(rootMatch("/storage/project1/docs/notes.md")).toBe(false) + }) + + it.each(["project[1]", "project*", "project?"])("keeps the lexical root %s literal", (name) => { + const rootMatch = (filePath: string) => + isFileMatchedByPatterns({ + filePath, + cwd: `/aliases/${name}`, + canonicalCwd: CANONICAL_CWD, + patterns: ["docs/**"], + isWindows: false, + }) + expect(rootMatch(`/aliases/${name}/docs/notes.md`)).toBe(true) + expect(rootMatch(`${CANONICAL_CWD}/docs/notes.md`)).toBe(true) + expect(rootMatch("/aliases/project1/docs/notes.md")).toBe(false) + }) + + it.each([CWD, CANONICAL_CWD])("honors %s exclusions and later re-inclusion in one ordered matcher", (root) => { + const candidate = `${CANONICAL_CWD}/docs/private.md` + const exclusion = `!${root}/docs/private.md` + + expect(matches(candidate, ["docs/**", exclusion])).toBe(false) + expect(matches(candidate, [exclusion, "docs/**"])).toBe(true) + expect(matches(candidate, ["docs/**", exclusion, `${CWD}/docs/private.md`])).toBe(true) + }) + + it("resolves escaping patterns against the lexical parent, not the canonical parent", () => { + expect(matches("/aliases/shared/file.md", ["../shared/file.md"])).toBe(true) + expect(matches("/storage/shared/file.md", ["../shared/file.md"])).toBe(false) + expect(matches("/storage/shared/file.md", ["/storage/shared/file.md"])).toBe(true) + expect(matches("/aliases/shared/file.md", ["/storage/shared/file.md"])).toBe(false) + expect(matches(`${CANONICAL_CWD}/docs/file.md`, ["../project/docs/file.md"])).toBe(true) + }) + + it("does not translate similarly prefixed siblings or permit relative rules to cover other roots", () => { + expect(matches(`${CANONICAL_CWD}-other/docs/file.md`, ["docs/**"])).toBe(false) + expect(matches(`${CWD}-other/docs/file.md`, ["docs/**"])).toBe(false) + expect(matches(`${CANONICAL_CWD}-other/docs/file.md`, [`${CWD}-other/docs/**`])).toBe(false) + expect(matches(`${CANONICAL_CWD}/docs/file.md`, [`${CWD}-other/docs/**`])).toBe(false) + expect(matches(`${CWD}-other/docs/file.md`, [`${CWD}-other/docs/**`])).toBe(true) + }) +}) + +describe("read-only root equivalence approval", () => { + it("keeps read and write allowlist exclusions independent while writes still imply reads", async () => { + const tool: ClineSayTool = { tool: "readFile", path: `${CANONICAL_CWD}/docs/private.md` } + const excludedRead = ["docs/**", `!${CWD}/docs/private.md`] + const excludedWrite = ["docs/**", `!${CANONICAL_CWD}/docs/private.md`] + + expect(await approval(tool, { allowedReadFiles: excludedRead, allowedWriteFiles: excludedWrite })).toEqual({ + decision: "ask", + }) + expect(await approval(tool, { allowedReadFiles: excludedRead, allowedWriteFiles: ["docs/**"] })).toEqual({ + decision: "approve", + }) + expect(await approval(tool, { allowedReadFiles: ["docs/**"], allowedWriteFiles: excludedWrite })).toEqual({ + decision: "approve", + }) + }) + + it("does not extend write matching or protected-file permission when roots are equivalent", async () => { + const canonicalTool: ClineSayTool = { tool: "newFileCreated", path: `${CANONICAL_CWD}/AGENTS.md` } + expect(await approval(canonicalTool, { allowedReadFiles: ["*.md"], allowedWriteFiles: ["*.md"] })).toEqual({ + decision: "ask", + }) + expect(await approval(canonicalTool, { allowedWriteFiles: [`${CANONICAL_CWD}/AGENTS.md`] }, CWD, true)).toEqual( + { decision: "ask" }, + ) + expect( + await approval({ tool: "newFileCreated", path: "AGENTS.md" }, { allowedWriteFiles: ["*.md"] }, CWD, true), + ).toEqual({ decision: "ask" }) + expect( + await approval( + { tool: "listFilesRecursive", path: `${CANONICAL_CWD}/docs` }, + { allowedReadFiles: ["docs/**"] }, + ), + ).toEqual({ decision: "ask" }) + expect(fs.realpath).not.toHaveBeenCalled() + }) + + it("requires every named batch target to match and refuses unnamed targets", async () => { + const state = { allowedReadFiles: ["docs/**", `!${CWD}/docs/private.md`] } + const listed = { path: `${CANONICAL_CWD}/docs/notes.md`, lineSnippet: "", key: "notes" } + const excluded = { path: `${CANONICAL_CWD}/docs/private.md`, lineSnippet: "", key: "private" } + const outside = { path: "/elsewhere/docs/notes.md", lineSnippet: "", key: "outside" } + + expect( + await approval( + { + tool: "readFile", + batchFiles: [listed, { path: `${CWD}/docs/other.md`, lineSnippet: "", key: "other" }], + }, + state, + ), + ).toEqual({ decision: "approve" }) + expect(await approval({ tool: "readFile", batchFiles: [listed, excluded] }, state)).toEqual({ decision: "ask" }) + expect(await approval({ tool: "readFile", batchFiles: [listed, outside] }, state)).toEqual({ decision: "ask" }) + expect(await approval({ tool: "readFile", batchFiles: [listed], additionalFileCount: 1 }, state)).toEqual({ + decision: "ask", + }) + }) + + it.each(["ENOENT", "EACCES"])( + "does not infer root equivalence when task root resolution fails with %s", + async (code) => { + const error = Object.assign(new Error("root unavailable"), { code }) + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.mocked(fs.realpath).mockRejectedValue(error) + const tool: ClineSayTool = { + tool: "readFile", + path: `${CANONICAL_CWD}/docs/notes.md`, + isOutsideWorkspace: true, + } + + expect(await approval(tool, { allowedReadFiles: ["docs/**"] })).toEqual({ decision: "ask" }) + expect(await approval(tool, { allowedReadFiles: [`${CWD}/docs/**`] })).toEqual({ decision: "ask" }) + expect(await approval(tool, { allowedReadFiles: [`${CANONICAL_CWD}/docs/**`] })).toEqual({ + decision: "approve", + }) + expect(errorLog).toHaveBeenCalledTimes(3) + expect(errorLog).toHaveBeenCalledWith(`[auto-approval] Failed to resolve workspace root ${CWD}:`, error) + }, + ) + + it.each([undefined, ""])("does not resolve workspace-relative grants without a task root: %s", async (cwd) => { + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}) + const text = JSON.stringify({ + tool: "readFile", + path: `${CANONICAL_CWD}/docs/notes.md`, + isOutsideWorkspace: true, + }) + expect( + await checkAutoApproval({ state: { ...baseState, allowedReadFiles: ["docs/**"] }, cwd, ask: "tool", text }), + ).toEqual({ decision: "ask" }) + expect( + await checkAutoApproval({ + state: { ...baseState, allowedReadFiles: [`${CANONICAL_CWD}/docs/**`] }, + cwd, + ask: "tool", + text, + }), + ).toEqual({ decision: "approve" }) + expect(fs.realpath).not.toHaveBeenCalled() + expect(errorLog).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/auto-approval/__tests__/workspaceRootSafety.integration.spec.ts b/src/core/auto-approval/__tests__/workspaceRootSafety.integration.spec.ts new file mode 100644 index 0000000000..4d0643d336 --- /dev/null +++ b/src/core/auto-approval/__tests__/workspaceRootSafety.integration.spec.ts @@ -0,0 +1,41 @@ +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import { checkAutoApproval } from "../index" +import { baseState } from "./fixtures" + +it("keeps permissions and wildcard exclusions scoped to a real symlinked workspace", async () => { + const temp = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-workspace-approval-")) + try { + const canonicalRoot = path.join(temp, "storage", "project[1]") + const lexicalParent = path.join(temp, "aliases") + const cwd = path.join(lexicalParent, "project") + const inside = path.join(canonicalRoot, "docs", "private.md") + const outside = path.join(temp, "storage", "project1", "docs", "private.md") + await fs.mkdir(path.dirname(inside), { recursive: true }) + await fs.mkdir(path.dirname(outside), { recursive: true }) + await fs.mkdir(lexicalParent) + await fs.writeFile(inside, "allowed workspace") + await fs.writeFile(outside, "unrelated workspace") + // A directory junction also works on Windows without symlink privileges. + await fs.symlink(canonicalRoot, cwd, "junction") + + const approval = async (filePath: string, patterns: string[]) => + checkAutoApproval({ + cwd, + state: { ...baseState, allowedReadFiles: patterns }, + ask: "tool", + text: JSON.stringify({ tool: "readFile", path: filePath, isOutsideWorkspace: true }), + }) + const canonicalInside = await fs.realpath(inside) + const canonicalOutside = await fs.realpath(outside) + expect(await approval(canonicalInside, ["docs/**"])).toEqual({ decision: "approve" }) + expect(await approval(canonicalOutside, ["docs/**"])).toEqual({ decision: "ask" }) + const exclusion = `!${lexicalParent}/**/private.md` + expect(await approval(canonicalInside, ["docs/**", exclusion])).toEqual({ decision: "ask" }) + expect(await approval("docs/private.md", ["docs/**", exclusion])).toEqual({ decision: "ask" }) + expect(await approval(canonicalInside, [exclusion, "docs/**"])).toEqual({ decision: "approve" }) + } finally { + await fs.rm(temp, { recursive: true, force: true }) + } +}) diff --git a/src/core/auto-approval/filePatterns.ts b/src/core/auto-approval/filePatterns.ts index 923d29815d..0df97d8981 100644 --- a/src/core/auto-approval/filePatterns.ts +++ b/src/core/auto-approval/filePatterns.ts @@ -3,6 +3,9 @@ import path from "path" import ignore from "ignore" +import { WorkspaceRootCanonicalizer } from "./WorkspaceRootCanonicalizer" +import { WorkspaceRootStrategy } from "./strategies/WorkspaceRootStrategy" + /** * Matching of file paths against user-configured file patterns. * @@ -72,7 +75,7 @@ import ignore from "ignore" * Both the configured patterns and the path being checked are therefore * rewritten into a single form the library accepts: a path relative to the * filesystem root (`/`), with any Windows drive as its first segment, in the - * spelling `toPosixAbsolutePath` gives it. + * spelling supplied by the selected path strategy. * * An alternative implementation would be to remember for each pattern whether * it's a workspace-scoped pattern or an absolute-filesystem pattern, and then @@ -155,56 +158,37 @@ function pathsepsToPosix(value: string, isWindows: boolean): string { } /** - * A Windows drive at the root of a path: the `C:` in `C:/tmp/x`, or `C:` alone. + * A Windows drive at the root of an absolute path: the `C:` in `C:/tmp/x`. * - * The lookahead is what limits this to a drive *root*. Windows also reads `C:tmp` - * as naming a drive, but relative to the current directory on it, which is not a - * place this module can resolve. + * Require a separator after the colon. Both `C:` and `C:tmp` are relative to + * the current directory on that drive, which this module cannot safely infer. */ -const DRIVE_PREFIX = /^([a-zA-Z]):(?=\/|$)/ +const DRIVE_PREFIX = /^([a-zA-Z]):(?=\/)/ /** - * Rewrite an absolute path into the plain POSIX form the rest of this module - * works in, where a Windows drive is an ordinary first path segment. - * - * - `"C:/tmp/x"` -> `"/C/tmp/x"` - * - `"/tmp/x"` with `workspaceDrive = "/D"` -> `"/D/tmp/x"`, since Windows reads a - * path written without a drive as being on the current one - * - `"/tmp/x"` off Windows -> `"/tmp/x"`, where there are no drives to place it on - * - * The colon has to go, working around `ignore` behaviour: - * On Windows, `ignore` rejects any path that looks drive-rooted - * (it tests `/^[a-z]:\//i` and throws `RangeError`, - * "path should be a `path.relative()`d string"). - * - * The bare letter cannot be mistaken for a directory of the same name, because it - * only ever goes where Windows puts the drive, at the very root of the path: a - * directory called `C` always sits in a later segment. - * - * Its case is left as typed, since a drive only occurs on Windows, where the - * matcher ignores case anyway (see the case-sensitivity note at the top), so - * `C:/x` and `c:/x` already agree. - * - * A drive is only read as one when reading paths as Windows does. Elsewhere `C:` - * is an ordinary directory name (`mkdir 'C:'` succeeds on Linux), so `C:/notes.md` - * is a *relative* path naming a file in it, and taking it for a drive would anchor - * it to the filesystem root instead of the workspace. + * The Windows volume in matcher notation: /C for a drive, /UNC/server/share + * for a network share. Empty when no volume is known, and always off Windows. */ -function toPosixAbsolutePath(absolutePath: string, workspaceDrive: string, isWindows: boolean): string { +function volumeSegmentOf(absolutePath: string, isWindows: boolean): string { + if (isWindows) { + const unc = /^\/\/([^/]+)\/([^/]+)/.exec(absolutePath) + if (unc) return `/UNC/${unc[1]}/${unc[2]}` + } const drive = isWindows ? DRIVE_PREFIX.exec(absolutePath) : null - return drive ? `/${drive[1]}${absolutePath.slice(drive[0].length)}` : `${workspaceDrive}${absolutePath}` + return drive ? `/${drive[1]}` : "" } -/** - * The drive a path is on, spelled as the path segment that `toPosixAbsolutePath` - * gives it: `"/C"` for `"C:/tmp/x"`. Empty when the path names no drive, as is - * always the case off Windows. - */ -function driveSegmentOf(absolutePath: string, isWindows: boolean): string { - const drive = isWindows ? DRIVE_PREFIX.exec(absolutePath) : null +/** Filesystem-provided prefixes are literal names, not user-authored globs. */ +function escapePatternLiteral(value: string): string { + // `ignore` expands ? even when escaped or inside a character class. A hex + // character class preserves a literal ? in filesystem-provided names. + return value.replace(/[\\*?[\]]/g, (character) => (character === "?" ? "[\\x3f]" : `\\${character}`)) +} - return drive ? `/${drive[1]}` : "" +/** A Windows drive/share is a filesystem root; .. cannot climb above it. */ +function resolveMatcherPath(root: string, relativePath: string, volume: string): string { + return `${volume}${path.posix.resolve(root.slice(volume.length) || "/", relativePath)}`.replace(/\/$/, "") || "/" } /** @@ -215,6 +199,11 @@ function isAbsolutePath(value: string, isWindows: boolean): boolean { return value.startsWith("/") || (isWindows && DRIVE_PREFIX.test(value)) } +/** Windows drive-relative paths depend on per-drive process state, not the task workspace. */ +function isDriveRelativePath(value: string, isWindows: boolean): boolean { + return isWindows && /^[a-zA-Z]:(?!\/)/.test(value) +} + function escapesWorkspace(posixPath: string): boolean { return posixPath.split("/").includes("..") } @@ -270,7 +259,7 @@ function toRootRelativePath(posixAbsolutePath: string): string { * - `"../shared/notes.md"` -> `"/path/to/shared/notes.md"` * resolved against the workspace root, so it lands outside the workspace * - `"~/notes.md"` -> `"/home/me/notes.md"` - * - `"C:/tmp/notes.md"` on Windows -> `"/C/tmp/notes.md"`, see `toPosixAbsolutePath()` + * - `"C:/tmp/notes.md"` on Windows -> `"/C/tmp/notes.md"`, using the path strategy * - `"!docs/secret.md"` -> `"!/path/to/repo/docs/secret.md"` * - `"mydir/"` -> `undefined` (because it names a dir) * - `"~"` -> `undefined` (because it names a dir) @@ -297,6 +286,16 @@ export function toMatcherPattern( cwd?: string, isWindows = runningOnWindows(), homeDir = os.homedir(), +): string | undefined { + return toMatcherPatternWithStrategy(pattern, cwd, isWindows, homeDir, WorkspaceRootStrategy.forPlatform(isWindows)) +} + +function toMatcherPatternWithStrategy( + pattern: string, + cwd: string | undefined, + isWindows: boolean, + homeDir: string, + strategy: WorkspaceRootStrategy, ): string | undefined { // Set gitignore's negation aside so the path is rewritten on its own merits, // then restore it, so that a negation is anchored exactly like the pattern it @@ -307,14 +306,20 @@ export function toMatcherPattern( // as gitignore's escape character and a legal filename character. let normalized = pathsepsToPosix(pattern.slice(negation.length), isWindows) - if (!normalized.trim() || normalized === "." || normalized === "~" || normalized.endsWith("/")) { + if ( + !normalized.trim() || + isDriveRelativePath(normalized, isWindows) || + normalized === "." || + normalized === "~" || + normalized.endsWith("/") + ) { return undefined } const workspacePosix = pathsepsToPosix(cwd ?? "", isWindows) - // A path written without a drive is read as being on the workspace's, which is - // the drive the tool call it came from would resolve it against. - const workspaceDrive = driveSegmentOf(workspacePosix, isWindows) + // A rooted Windows path belongs to the workspace's drive or network share. + const workspaceVolume = volumeSegmentOf(workspacePosix, isWindows) + const matcherVolume = strategy.toMatcherAbsolutePath("/", workspaceVolume).replace(/\/$/, "") if (normalized.startsWith("~/")) { // Left in the user's own spelling, drive and all, for the absolute branch @@ -323,21 +328,23 @@ export function toMatcherPattern( } if (isAbsolutePath(normalized, isWindows)) { - return `${negation}${toPosixAbsolutePath(normalized, workspaceDrive, isWindows)}` + return `${negation}${strategy.toMatcherAbsolutePath(normalized, workspaceVolume)}` } - if (!cwd) { - // No workspace for a relative pattern to be relative to; see the note above + if (!cwd || isDriveRelativePath(workspacePosix, isWindows)) { + // An unknown or drive-relative workspace cannot anchor a relative pattern; see the note above // on why this is not matched loosely instead. return undefined } // `resolve` also normalises, so that a `cwd` with a trailing slash or a `..` // segment does not reach the patterns below. - const workspaceRoot = path.posix.resolve(toPosixAbsolutePath(workspacePosix, workspaceDrive, isWindows)) + const workspaceRoot = escapePatternLiteral( + resolveMatcherPath(strategy.toMatcherAbsolutePath(workspacePosix, workspaceVolume), ".", matcherVolume), + ) if (escapesWorkspace(normalized)) { - return `${negation}${path.posix.resolve(workspaceRoot, normalized)}` + return `${negation}${resolveMatcherPath(workspaceRoot, normalized, escapePatternLiteral(matcherVolume))}` } // "./notes.md" names the workspace root explicitly. @@ -355,7 +362,7 @@ export function toMatcherPattern( // number of directories including none, restores its reach. const matchesInAnyDirectory = !isWorkspaceRootAnchored && !normalized.includes("/") - return `${negation}${workspaceRoot}/${matchesInAnyDirectory ? "**/" : ""}${normalized}` + return `${negation}${workspaceRoot.replace(/\/$/, "")}/${matchesInAnyDirectory ? "**/" : ""}${normalized}` } /** @@ -365,31 +372,37 @@ export function toMatcherPattern( * @returns The rewritten path, or `undefined` when it names no file, or when it * is relative and there is no workspace root to resolve it against. */ -function toMatcherPath(filePath: string, cwd: string | undefined, isWindows: boolean): string | undefined { +function toMatcherPath( + filePath: string, + cwd: string | undefined, + isWindows: boolean, + strategy: WorkspaceRootStrategy, +): string | undefined { // Not trimmed: whitespace can be part of a filename. const normalized = pathsepsToPosix(filePath, isWindows) - if (!normalized.trim() || normalized === ".") { + if (!normalized.trim() || isDriveRelativePath(normalized, isWindows) || normalized === ".") { return undefined } const workspacePosix = pathsepsToPosix(cwd ?? "", isWindows) - const workspaceDrive = driveSegmentOf(workspacePosix, isWindows) + const workspaceVolume = volumeSegmentOf(workspacePosix, isWindows) + const matcherVolume = strategy.toMatcherAbsolutePath("/", workspaceVolume).replace(/\/$/, "") if (isAbsolutePath(normalized, isWindows)) { - return toRootRelativePath(toPosixAbsolutePath(normalized, workspaceDrive, isWindows)) + return toRootRelativePath(strategy.toMatcherAbsolutePath(normalized, workspaceVolume)) } - if (!cwd) { - // A relative path cannot be placed on the filesystem without a root, and + if (!cwd || isDriveRelativePath(workspacePosix, isWindows)) { + // A relative path cannot be placed without a known, non-drive-relative root, and // the only patterns that survive without one are absolute, which such a // path could never match anyway. return undefined } - const workspaceRoot = toPosixAbsolutePath(workspacePosix, workspaceDrive, isWindows) + const workspaceRoot = strategy.toMatcherAbsolutePath(workspacePosix, workspaceVolume) - return toRootRelativePath(path.posix.resolve(workspaceRoot, normalized)) + return toRootRelativePath(resolveMatcherPath(workspaceRoot, normalized, matcherVolume)) } /** @@ -411,12 +424,15 @@ function toMatcherPath(filePath: string, cwd: string | undefined, isWindows: boo export function isFileMatchedByPatterns({ filePath, cwd, + canonicalCwd, patterns, isWindows = runningOnWindows(), homeDir = os.homedir(), }: { filePath?: string cwd?: string + /** Verified realpath of cwd, used only by read approval. */ + canonicalCwd?: string patterns?: string[] isWindows?: boolean homeDir?: string @@ -425,14 +441,17 @@ export function isFileMatchedByPatterns({ return false } - const candidate = toMatcherPath(filePath, cwd, isWindows) + const strategy = WorkspaceRootStrategy.forPlatform(isWindows) + const candidate = toMatcherPath(filePath, cwd, isWindows, strategy) if (!candidate) { return false } + const workspaceRoot = WorkspaceRootCanonicalizer.forStrategy(cwd, canonicalCwd, strategy) + const candidates = workspaceRoot.equivalentPaths(`/${candidate}`).map(toRootRelativePath) const matcherPatterns = patterns - .map((pattern) => toMatcherPattern(pattern, cwd, isWindows, homeDir)) + .map((pattern) => toMatcherPatternWithStrategy(pattern, cwd, isWindows, homeDir, strategy)) .filter((pattern): pattern is string => !!pattern) if (!matcherPatterns.length) { @@ -440,9 +459,18 @@ export function isFileMatchedByPatterns({ } try { - return ignore({ ignoreCase: isWindows }) - .add([...matcherPatterns, MATCH_DIRECTORIES_ONLY_PATTERN]) - .ignores(candidate) + let matched = false + for (const pattern of matcherPatterns) { + const negated = pattern.startsWith("!") + const matcher = ignore({ ignoreCase: isWindows }).add([ + negated ? pattern.slice(1) : pattern, + MATCH_DIRECTORIES_ONLY_PATTERN, + ]) + // Apply each rule to both spellings BEFORE advancing to the next rule. + // OR-ing two complete matchers would let one spelling bypass an exclusion. + if (candidates.some((value) => matcher.ignores(value))) matched = !negated + } + return matched } catch (error) { // A path the matcher rejects cannot be confirmed as matching, so treat it // as unmatched. diff --git a/src/core/auto-approval/index.ts b/src/core/auto-approval/index.ts index 2aa5008fc8..e92e609032 100644 --- a/src/core/auto-approval/index.ts +++ b/src/core/auto-approval/index.ts @@ -9,6 +9,7 @@ import { } from "@roo-code/types" import type { DcgDecision } from "../../services/destructive-command-guard/runner" +import { realpath } from "fs/promises" import { ClineAskResponse } from "../../shared/WebviewMessage" @@ -87,6 +88,17 @@ function areAllNamedFilesMatched(tool: ClineSayTool, matchFun: (filePath: string return paths.every((filePath) => matchFun(filePath)) } +async function resolveCanonicalWorkspaceRoot(cwd: string | undefined): Promise { + if (!cwd) return undefined + + try { + return await realpath(cwd) + } catch (error) { + console.error(`[auto-approval] Failed to resolve workspace root ${cwd}:`, error) + return undefined + } +} + /** * Whether a read-only tool action is fully covered by the read allowlist patterns. * @@ -105,20 +117,23 @@ function areAllNamedFilesMatched(tool: ClineSayTool, matchFun: (filePath: string * narrows the list it appears in. This also means that negating a read is * ineffective while a non-negated write pattern still matches the file. */ -function isReadAllowedByPatterns( +async function isReadAllowedByPatterns( tool: ClineSayTool, cwd: string | undefined, state: Pick, -): boolean { - if (tool.tool !== "readFile") { +): Promise { + if (tool.tool !== "readFile" || (!state.allowedReadFiles?.length && !state.allowedWriteFiles?.length)) { return false } + // Task-local root only. Unknown roots keep lexical matching; never resolve + // individual file aliases here, since those can point outside the workspace. + const canonicalCwd = await resolveCanonicalWorkspaceRoot(cwd) return areAllNamedFilesMatched( tool, (filePath) => - isFileMatchedByPatterns({ filePath, cwd, patterns: state.allowedReadFiles }) || - isFileMatchedByPatterns({ filePath, cwd, patterns: state.allowedWriteFiles }), + isFileMatchedByPatterns({ filePath, cwd, canonicalCwd, patterns: state.allowedReadFiles }) || + isFileMatchedByPatterns({ filePath, cwd, canonicalCwd, patterns: state.allowedWriteFiles }), ) } @@ -392,7 +407,7 @@ export async function checkAutoApproval({ // `alwaysAllowReadOnly` permission. Such a pattern names its // location, including outside the workspace, so it also stands in for // `alwaysAllowReadOnlyOutsideWorkspace`. - const isAllowedReadFile = isReadAllowedByPatterns(tool, cwd, state) + const isAllowedReadFile = await isReadAllowedByPatterns(tool, cwd, state) const isReadAllowed = isAllowedReadFile || diff --git a/src/core/auto-approval/strategies/PosixWorkspaceRootStrategy.ts b/src/core/auto-approval/strategies/PosixWorkspaceRootStrategy.ts new file mode 100644 index 0000000000..799f3e75c8 --- /dev/null +++ b/src/core/auto-approval/strategies/PosixWorkspaceRootStrategy.ts @@ -0,0 +1,19 @@ +import path from "path" + +import type { WorkspaceRootStrategy } from "./WorkspaceRootStrategy" + +export class PosixWorkspaceRootStrategy implements WorkspaceRootStrategy { + toMatcherAbsolutePath(absolutePath: string): string { + return absolutePath + } + + normalizeAbsoluteRoot(root: string | undefined): string | undefined { + if (!root?.startsWith("/")) return undefined + + return path.posix.resolve(this.toMatcherAbsolutePath(root)).replace(/\/$/, "") + } + + comparisonKey(value: string): string { + return value + } +} diff --git a/src/core/auto-approval/strategies/WindowsWorkspaceRootStrategy.ts b/src/core/auto-approval/strategies/WindowsWorkspaceRootStrategy.ts new file mode 100644 index 0000000000..06d10e9eab --- /dev/null +++ b/src/core/auto-approval/strategies/WindowsWorkspaceRootStrategy.ts @@ -0,0 +1,32 @@ +import path from "path" + +import type { WorkspaceRootStrategy } from "./WorkspaceRootStrategy" + +export class WindowsWorkspaceRootStrategy implements WorkspaceRootStrategy { + /** Drop drive colons rejected by the Windows matcher while keeping UNC paths in their own namespace. */ + toMatcherAbsolutePath(absolutePath: string, workspaceVolume: string): string { + // Keep UNC paths independent of the current drive and distinct from drive letters. + if (absolutePath.startsWith("//")) return `/UNC/${absolutePath.replace(/^\/+/, "")}` + + const drive = /^([a-zA-Z]):(?=\/)/.exec(absolutePath) + // Rooted paths belong to the workspace drive/share. Without a known volume, + // keep them in a separate namespace so /UNC/... cannot impersonate a share. + return drive + ? `/${drive[1]}${absolutePath.slice(drive[0].length)}` + : `${workspaceVolume || "/ROOT"}${absolutePath}` + } + + normalizeAbsoluteRoot(root: string | undefined): string | undefined { + const normalized = (root ?? "").replace(/\\/g, "/") + const drive = /^([a-zA-Z]):(?=\/)/.exec(normalized) + if (!normalized.startsWith("/") && !drive) return undefined + + // Match the allowlist representation: a Windows drive is a POSIX path segment. + const absolutePath = this.toMatcherAbsolutePath(normalized, "") + return path.posix.resolve(absolutePath).replace(/\/$/, "") + } + + comparisonKey(value: string): string { + return value.toLowerCase() + } +} diff --git a/src/core/auto-approval/strategies/WorkspaceRootStrategy.ts b/src/core/auto-approval/strategies/WorkspaceRootStrategy.ts new file mode 100644 index 0000000000..301a3f0768 --- /dev/null +++ b/src/core/auto-approval/strategies/WorkspaceRootStrategy.ts @@ -0,0 +1,13 @@ +import { PosixWorkspaceRootStrategy } from "./PosixWorkspaceRootStrategy" +import { WindowsWorkspaceRootStrategy } from "./WindowsWorkspaceRootStrategy" + +export abstract class WorkspaceRootStrategy { + static forPlatform(isWindows: boolean): WorkspaceRootStrategy { + return isWindows ? new WindowsWorkspaceRootStrategy() : new PosixWorkspaceRootStrategy() + } + + /** Convert a slash-normalized absolute path into the matcher's platform-specific namespace. */ + abstract toMatcherAbsolutePath(absolutePath: string, workspaceVolume: string): string + abstract normalizeAbsoluteRoot(root: string | undefined): string | undefined + abstract comparisonKey(value: string): string +}