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
18 changes: 15 additions & 3 deletions packages/core/src/util/wildcard.ts
Original file line number Diff line number Diff line change
@@ -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)
}
16 changes: 8 additions & 8 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,19 +95,19 @@ 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<string, "allow" | "ask" | "deny">

const defaults = Permission.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
"**": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
question: "deny",
Expand All @@ -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",
},
})

Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
)
Expand Down
26 changes: 17 additions & 9 deletions packages/opencode/src/util/wildcard.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
20 changes: 10 additions & 10 deletions packages/opencode/test/permission/next.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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" },
],
}),
Expand Down Expand Up @@ -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: [
Expand Down
52 changes: 52 additions & 0 deletions packages/opencode/test/util/wildcard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Loading