diff --git a/packages/core/src/util/wildcard.ts b/packages/core/src/util/wildcard.ts index 0a67817bcb58..6d37378af2b9 100644 --- a/packages/core/src/util/wildcard.ts +++ b/packages/core/src/util/wildcard.ts @@ -1,14 +1,26 @@ export * as Wildcard from "./wildcard" +const GS = "__OC_GS__" +const GSS = "__OC_GSS__" +const Q = "__OC_Q__" +const STAR = "__OC_STAR__" +const GSS_REPL = "(?:" + ".+/)" + "\x3F" + export function match(input: string, pattern: string) { const normalized = input.replaceAll("\\", "/") let escaped = pattern .replaceAll("\\", "/") + .replace(/\*\*\//g, GSS) + .replace(/\*\*/g, GS) + .replace(/\*/g, STAR) + .replace(/\?/g, Q) .replace(/[.+^${}()|[\]\\]/g, "\\$&") - .replace(/\*/g, ".*") - .replace(/\?/g, ".") + .replace(new RegExp(GSS, "g"), GSS_REPL) + .replace(new RegExp(GS, "g"), ".*") + .replace(new RegExp(STAR, "g"), "[^/]*") + .replace(new RegExp(Q, "g"), "[^/]") - if (escaped.endsWith(" .*")) escaped = escaped.slice(0, -3) + "( .*)?" + if (escaped.endsWith(" [^/]*")) escaped = escaped.slice(0, -6) + "( [^/]*)?" return new RegExp("^" + escaped + "$", process.platform === "win32" ? "si" : "s").test(normalized) } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index e78304e4e758..9bb7ee79f607 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -95,11 +95,11 @@ export const layer = Layer.effect( const skillDirs = yield* skill.dirs() const whitelistedDirs = [ Truncate.GLOB, - path.join(Global.Path.tmp, "*"), - ...skillDirs.map((dir) => path.join(dir, "*")), + path.join(Global.Path.tmp, "**"), + ...skillDirs.map((dir) => path.join(dir, "**")), ] const readonlyExternalDirectory = { - "*": "ask", + "**": "ask", ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), } satisfies Record @@ -107,7 +107,7 @@ export const layer = Layer.effect( "*": "allow", doom_loop: "ask", external_directory: { - "*": "ask", + "**": "ask", ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), }, question: "deny", @@ -117,10 +117,10 @@ export const layer = Layer.effect( repo_overview: "deny", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { - "*": "allow", - "*.env": "ask", - "*.env.*": "ask", - "*.env.example": "allow", + "**": "allow", + "**/*.env": "ask", + "**/*.env.*": "ask", + "**/*.env.example": "allow", }, }) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index cb4d9eef696e..c2137a800523 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -288,6 +288,10 @@ export function fromConfig(permission: ConfigPermission.Info) { ruleset.push({ permission: key, action: value, pattern: "*" }) continue } + if (typeof value !== "object" || value === null || Array.isArray(value)) { + log.warn("invalid permission config", { key, value }) + continue + } ruleset.push( ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })), ) diff --git a/packages/opencode/src/util/wildcard.ts b/packages/opencode/src/util/wildcard.ts index b2affbf84998..311d29848005 100644 --- a/packages/opencode/src/util/wildcard.ts +++ b/packages/opencode/src/util/wildcard.ts @@ -1,18 +1,26 @@ import { sortBy, pipe } from "remeda" +const GS = "__OC_GS__" +const GSS = "__OC_GSS__" +const Q = "__OC_Q__" +const STAR = "__OC_STAR__" +const GSS_REPL = "(?:" + ".+/)" + "\x3F" + export function match(str: string, pattern: string) { if (str) str = str.replaceAll("\\", "/") if (pattern) pattern = pattern.replaceAll("\\", "/") let escaped = pattern - .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape special regex chars - .replace(/\*/g, ".*") // * becomes .* - .replace(/\?/g, ".") // ? becomes . - - // If pattern ends with " *" (space + wildcard), make the trailing part optional - // This allows "ls *" to match both "ls" and "ls -la" - if (escaped.endsWith(" .*")) { - escaped = escaped.slice(0, -3) + "( .*)?" - } + .replace(/\*\*\//g, GSS) + .replace(/\*\*/g, GS) + .replace(/\*/g, STAR) + .replace(/\?/g, Q) + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(new RegExp(GSS, "g"), GSS_REPL) + .replace(new RegExp(GS, "g"), ".*") + .replace(new RegExp(STAR, "g"), "[^/]*") + .replace(new RegExp(Q, "g"), "[^/]") + + if (escaped.endsWith(" [^/]*")) escaped = escaped.slice(0, -6) + "( [^/]*)?" const flags = process.platform === "win32" ? "si" : "s" return new RegExp("^" + escaped + "$", flags).test(str) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 1b09c36afdf3..2cef802c5b27 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -318,9 +318,9 @@ test("evaluate - last matching glob wins", () => { }) test("evaluate - order matters for specificity", () => { - const result = Permission.evaluate("edit", "src/components/Button.tsx", [ - { permission: "edit", pattern: "src/components/*", action: "allow" }, - { permission: "edit", pattern: "src/*", action: "deny" }, + const result = Permission.evaluate("edit", "src/Button.tsx", [ + { permission: "edit", pattern: "src/*", action: "allow" }, + { permission: "edit", pattern: "src/Button.tsx", action: "deny" }, ]) expect(result.action).toBe("deny") }) @@ -374,8 +374,8 @@ test("evaluate - exact match at end wins over earlier wildcard", () => { }) test("evaluate - wildcard at end overrides earlier exact match", () => { - const result = Permission.evaluate("bash", "/bin/rm", [ - { permission: "bash", pattern: "/bin/rm", action: "deny" }, + const result = Permission.evaluate("bash", "rm", [ + { permission: "bash", pattern: "rm", action: "deny" }, { permission: "bash", pattern: "*", action: "allow" }, ]) expect(result.action).toBe("allow") @@ -581,10 +581,10 @@ it.instance( ask({ sessionID: SessionID.make("session_test"), permission: "bash", - patterns: ["rm -rf /"], + patterns: ["rm -rf"], metadata: {}, always: [], - ruleset: [{ permission: "bash", pattern: "*", action: "deny" }], + ruleset: [{ permission: "bash", pattern: "rm *", action: "deny" }], }), ) expect(err).toBeInstanceOf(Permission.DeniedError) @@ -1074,11 +1074,11 @@ it.instance( ask({ sessionID: SessionID.make("session_test"), permission: "bash", - patterns: ["echo hello", "rm -rf /"], + patterns: ["echo hello", "rm -rf"], metadata: {}, always: [], ruleset: [ - { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "**", action: "allow" }, { permission: "bash", pattern: "rm *", action: "deny" }, ], }), @@ -1113,7 +1113,7 @@ it.instance( ask({ sessionID: SessionID.make("session_test"), permission: "bash", - patterns: ["echo hello", "rm -rf /"], + patterns: ["echo hello", "rm -rf"], metadata: {}, always: [], ruleset: [ diff --git a/packages/opencode/test/util/wildcard.test.ts b/packages/opencode/test/util/wildcard.test.ts index 2f7b26328762..de831a4572bd 100644 --- a/packages/opencode/test/util/wildcard.test.ts +++ b/packages/opencode/test/util/wildcard.test.ts @@ -88,3 +88,55 @@ test("match handles case-insensitivity on Windows", () => { expect(Wildcard.match("/users/test/file", "/Users/test/*")).toBe(false) } }) + +test("* does not match path separator /", () => { + expect(Wildcard.match("secrets.env", "*.env")).toBe(true) + expect(Wildcard.match(".env", "*.env")).toBe(true) + expect(Wildcard.match("src/.env", "*.env")).toBe(false) + expect(Wildcard.match("a/b/.env", "*.env")).toBe(false) + expect(Wildcard.match("src/config.env", "*.env")).toBe(false) +}) + +test("** matches across path separators (globstar)", () => { + expect(Wildcard.match(".env", "**/*.env")).toBe(true) + expect(Wildcard.match("src/.env", "**/*.env")).toBe(true) + expect(Wildcard.match("a/b/c/.env", "**/*.env")).toBe(true) + expect(Wildcard.match("src/config/.env", "**/*.env")).toBe(true) + expect(Wildcard.match("src/secrets.env", "**/*.env")).toBe(true) +}) + +test("** matches zero or more directory segments", () => { + expect(Wildcard.match("file.txt", "**/file.txt")).toBe(true) + expect(Wildcard.match("src/file.txt", "**/file.txt")).toBe(true) + expect(Wildcard.match("src/components/file.txt", "**/file.txt")).toBe(true) +}) + +test("src/*.env matches only direct children of src/", () => { + expect(Wildcard.match("src/.env", "src/*.env")).toBe(true) + expect(Wildcard.match("src/secrets.env", "src/*.env")).toBe(true) + expect(Wildcard.match("src/a/.env", "src/*.env")).toBe(false) + expect(Wildcard.match("src/a/b/.env", "src/*.env")).toBe(false) + expect(Wildcard.match(".env", "src/*.env")).toBe(false) +}) + +test("src/**/*.env matches nested files under src/", () => { + expect(Wildcard.match("src/.env", "src/**/*.env")).toBe(true) + expect(Wildcard.match("src/a/.env", "src/**/*.env")).toBe(true) + expect(Wildcard.match("src/a/b/.env", "src/**/*.env")).toBe(true) + expect(Wildcard.match("lib/.env", "src/**/*.env")).toBe(false) +}) + +test("? does not match path separator", () => { + expect(Wildcard.match("a", "?")).toBe(true) + expect(Wildcard.match("x", "?")).toBe(true) + expect(Wildcard.match("/", "?")).toBe(false) + expect(Wildcard.match("ab", "?")).toBe(false) +}) + +test("trailing space+wildcard with path-aware * still works for commands", () => { + expect(Wildcard.match("ls", "ls *")).toBe(true) + expect(Wildcard.match("ls -la", "ls *")).toBe(true) + expect(Wildcard.match("git status", "git *")).toBe(true) + expect(Wildcard.match("git", "git *")).toBe(true) + expect(Wildcard.match("lstmeval", "ls *")).toBe(false) +})