diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index f95aaf34525..7d9dd62fb99 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -44,7 +44,13 @@ export namespace PermissionNext { }) continue } - ruleset.push(...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern, action }))) + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ + permission: key, + pattern: pattern.replace(/\\/g, "/"), // Normalize to forward slashes for cross-platform compatibility + action, + })), + ) } return ruleset } diff --git a/packages/opencode/src/util/wildcard.ts b/packages/opencode/src/util/wildcard.ts index 9b595a0a9ec..373f4964b51 100644 --- a/packages/opencode/src/util/wildcard.ts +++ b/packages/opencode/src/util/wildcard.ts @@ -2,16 +2,21 @@ import { sortBy, pipe } from "remeda" export namespace Wildcard { export function match(str: string, pattern: string) { + // Normalize path separators to forward slashes for cross-platform compatibility + // This allows configs to use either / or \ and work on all platforms + const normalizedStr = str.replace(/\\/g, "/") + const normalizedPattern = pattern.replace(/\\/g, "/") + const regex = new RegExp( "^" + - pattern + normalizedPattern .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape special regex chars .replace(/\*/g, ".*") // * becomes .* .replace(/\?/g, ".") + // ? becomes . "$", "s", // s flag enables multiline matching ) - return regex.test(str) + return regex.test(normalizedStr) } export function all(input: string, patterns: Record) { diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 68dc653de6d..ef88c3bc95d 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -650,3 +650,132 @@ test("ask - allows all patterns when all match allow rules", async () => { }, }) }) + +// Cross-platform path separator tests for permission system +test("fromConfig - normalizes backslashes to forward slashes", () => { + const result = PermissionNext.fromConfig({ + edit: { + "*": "deny", + "openspec\\*": "allow", + "src\\main.ts": "deny", + }, + }) + expect(result).toEqual([ + { permission: "edit", pattern: "*", action: "deny" }, + { permission: "edit", pattern: "openspec/*", action: "allow" }, + { permission: "edit", pattern: "src/main.ts", action: "deny" }, + ]) +}) + +test("fromConfig - handles mixed separators", () => { + const result = PermissionNext.fromConfig({ + read: { + "openspec/*": "allow", + "src\\*": "deny", + "C:/Users/*": "allow", + }, + }) + expect(result).toEqual([ + { permission: "read", pattern: "openspec/*", action: "allow" }, + { permission: "read", pattern: "src/*", action: "deny" }, + { permission: "read", pattern: "C:/Users/*", action: "allow" }, + ]) +}) + +test("evaluate - Windows paths with Unix config patterns", () => { + const ruleset = PermissionNext.fromConfig({ + edit: { + "*": "deny", + "openspec/*": "allow", + }, + }) + + // Windows path should match Unix pattern + expect(PermissionNext.evaluate("edit", "openspec\\api.yaml", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("edit", "openspec\\sub\\file.ts", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("edit", "src\\main.ts", ruleset).action).toBe("deny") +}) + +test("evaluate - Unix paths with Windows config patterns", () => { + const ruleset = PermissionNext.fromConfig({ + edit: { + "*": "deny", + "openspec\\*": "allow", + }, + }) + + // Unix path should match Windows pattern + expect(PermissionNext.evaluate("edit", "openspec/api.yaml", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("edit", "openspec/sub/file.ts", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("edit", "src/main.ts", ruleset).action).toBe("deny") +}) + +test("evaluate - Mixed separators in paths and patterns", () => { + const ruleset = PermissionNext.fromConfig({ + edit: { + "openspec\\*": "allow", + }, + }) + + // Mixed separators should work + expect(PermissionNext.evaluate("edit", "openspec/sub\\file.txt", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("edit", "openspec\\sub/file.txt", ruleset).action).toBe("allow") +}) + +test("evaluate - UNC paths", () => { + const ruleset = PermissionNext.fromConfig({ + read: { + "//server/share/*": "allow", + }, + }) + + // Windows UNC path should match + expect(PermissionNext.evaluate("read", "\\\\server\\share\\file.txt", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("read", "\\\\server\\share\\sub\\file.txt", ruleset).action).toBe("allow") +}) + +test("evaluate - Drive letters", () => { + const ruleset = PermissionNext.fromConfig({ + edit: { + "C:/project/*": "allow", + "D:\\*": "deny", + }, + }) + + expect(PermissionNext.evaluate("edit", "C:\\project\\src\\main.ts", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("edit", "C:/project/src/main.ts", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("edit", "D:\\file.txt", ruleset).action).toBe("deny") + expect(PermissionNext.evaluate("edit", "D:/file.txt", ruleset).action).toBe("deny") +}) + +test("evaluate - Complex Windows path patterns", () => { + const ruleset = PermissionNext.fromConfig({ + edit: { + "*": "ask", + "src\\components\\*.tsx": "allow", + "openspec/**/*.yaml": "allow", + }, + }) + + expect(PermissionNext.evaluate("edit", "src\\components\\Button.tsx", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("edit", "src\\components\\Button.js", ruleset).action).toBe("ask") + expect(PermissionNext.evaluate("edit", "openspec\\api\\v1\\schema.yaml", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("edit", "openspec\\api\\v1\\schema.json", ruleset).action).toBe("ask") +}) + +test("evaluate - Permission precedence with Windows paths", () => { + const ruleset = PermissionNext.fromConfig({ + edit: { + "*": "deny", + "openspec/*": "allow", + "openspec/secret/*": "deny", + "openspec/secret/ok.ts": "allow", + }, + }) + + // Last matching rule wins + expect(PermissionNext.evaluate("edit", "openspec\\api.yaml", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("edit", "openspec\\secret\\password.txt", ruleset).action).toBe("deny") + expect(PermissionNext.evaluate("edit", "openspec\\secret\\ok.ts", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("edit", "src\\main.ts", ruleset).action).toBe("deny") +}) diff --git a/packages/opencode/test/util/wildcard.test.ts b/packages/opencode/test/util/wildcard.test.ts index f7f1e15457b..6de06be411c 100644 --- a/packages/opencode/test/util/wildcard.test.ts +++ b/packages/opencode/test/util/wildcard.test.ts @@ -53,3 +53,84 @@ test("allStructured handles sed flags", () => { expect(Wildcard.allStructured({ head: "sed", tail: ["-n", "1p", "file"] }, rules)).toBe("allow") expect(Wildcard.allStructured({ head: "sed", tail: ["-i", "-n", "/./p", "myfile.txt"] }, rules)).toBe("ask") }) + +// Cross-platform path separator tests +test("match handles Windows paths with forward slash patterns", () => { + // Unix config pattern matching Windows path + expect(Wildcard.match("openspec\\api.yaml", "openspec/*")).toBe(true) + expect(Wildcard.match("src\\main.ts", "src/*")).toBe(true) + expect(Wildcard.match("C:\\Users\\name\\file.txt", "C:/*")).toBe(true) + expect(Wildcard.match("openspec\\sub\\file.txt", "openspec/*")).toBe(true) +}) + +test("match handles Windows paths with backslash patterns", () => { + // Windows config pattern matching Windows path + expect(Wildcard.match("openspec\\api.yaml", "openspec\\*")).toBe(true) + expect(Wildcard.match("src\\main.ts", "src\\*")).toBe(true) + expect(Wildcard.match("C:\\Users\\name\\file.txt", "C:\\*")).toBe(true) +}) + +test("match handles Unix paths with backslash patterns", () => { + // Windows config pattern matching Unix path + expect(Wildcard.match("openspec/api.yaml", "openspec\\*")).toBe(true) + expect(Wildcard.match("src/main.ts", "src\\*")).toBe(true) +}) + +test("match handles mixed separators", () => { + expect(Wildcard.match("openspec\\sub/file.txt", "openspec/*")).toBe(true) + expect(Wildcard.match("src/sub\\file.txt", "src\\*")).toBe(true) +}) + +test("match handles UNC paths", () => { + expect(Wildcard.match("\\\\server\\share\\file.txt", "//server/share/*")).toBe(true) + expect(Wildcard.match("//server/share/file.txt", "\\\\server\\share\\*")).toBe(true) + expect(Wildcard.match("\\\\server\\share\\sub\\file.txt", "//server/share/*")).toBe(true) +}) + +test("match handles drive letters", () => { + expect(Wildcard.match("C:\\Users\\name\\file.txt", "C:/*")).toBe(true) + expect(Wildcard.match("D:\\project\\src\\main.ts", "D:/project/*")).toBe(true) + expect(Wildcard.match("C:/Users/name/file.txt", "C:\\*")).toBe(true) +}) + +test("match handles relative vs absolute paths", () => { + // Relative paths + expect(Wildcard.match("src\\main.ts", "src/*")).toBe(true) + expect(Wildcard.match("src/main.ts", "src\\*")).toBe(true) + + // Absolute paths + expect(Wildcard.match("/home/user/project/src/main.ts", "/home/user/project/src/*")).toBe(true) + expect(Wildcard.match("\\home\\user\\project\\src\\main.ts", "/home/user/project/src/*")).toBe(true) +}) + +test("match handles complex patterns with Windows paths", () => { + expect(Wildcard.match("openspec\\api.yaml", "openspec/*.yaml")).toBe(true) + expect(Wildcard.match("openspec\\sub\\test.ts", "openspec/*/test.ts")).toBe(true) + expect(Wildcard.match("src\\components\\Button.tsx", "src/components/*.tsx")).toBe(true) + expect(Wildcard.match("C:\\project\\src\\test.ts", "C:/project/src/test.ts")).toBe(true) +}) + +test("match handles wildcards with Windows paths", () => { + expect(Wildcard.match("openspec\\file1.txt", "openspec/*")).toBe(true) + expect(Wildcard.match("openspec\\sub\\file2.txt", "openspec/**")).toBe(true) + expect(Wildcard.match("src\\test\\file.ts", "src/*/file.ts")).toBe(true) +}) + +test("match handles question mark with Windows paths", () => { + expect(Wildcard.match("file\\1.txt", "file\\?.txt")).toBe(true) + expect(Wildcard.match("file\\12.txt", "file\\?.txt")).toBe(false) + expect(Wildcard.match("file/1.txt", "file\\?.txt")).toBe(true) +}) + +test("match handles special regex chars in Windows paths", () => { + // These should be escaped properly + expect(Wildcard.match("src\\file+more.txt", "src/file+more.txt")).toBe(true) + expect(Wildcard.match("src\\file[1-3].txt", "src/file[1-3].txt")).toBe(true) + expect(Wildcard.match("src\\file.txt", "src/file.txt")).toBe(true) +}) + +test("match handles empty strings and patterns", () => { + expect(Wildcard.match("", "")).toBe(true) + expect(Wildcard.match("\\", "/")).toBe(true) + expect(Wildcard.match("/", "\\")).toBe(true) +})