Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/opencode/src/permission/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
9 changes: 7 additions & 2 deletions packages/opencode/src/util/wildcard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>) {
Expand Down
129 changes: 129 additions & 0 deletions packages/opencode/test/permission/next.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
81 changes: 81 additions & 0 deletions packages/opencode/test/util/wildcard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})