All permission pattern matching flows through a single function: Wildcard.match().
The conversion steps are:
- Normalize backslashes —
\→/on both input and pattern (lines 5–6) - Escape all regex metacharacters —
.+^${}()|[]\are escaped so they match literally (line 8) - Convert
*→.*— matches zero or more of any character, including/(line 9) - Convert
?→.— matches exactly one character (line 10) - Trailing space+wildcard optimization — a trailing
.*(from*in the pattern) becomes( .*)?, making trailing arguments optional. This lets"ls *"match both"ls"and"ls -la"(lines 14–16) - Anchor the regex — the result is wrapped as
^...$withs(dotAll) flag; on Windows, theiflag is also added (line 19)
There is no raw-regex escape hatch. Because step 2 escapes all regex metacharacters before step 3 converts wildcards, there is no way to pass through raw regex syntax. The only special characters are * and ?.
Rules are evaluated via .findLast() on the merged ruleset. Both the permission type and the pattern are wildcard-matched:
const match = merged.findLast(
(rule) =>
Wildcard.match(permission, rule.permission) &&
Wildcard.match(pattern, rule.pattern),
)User config is merged after defaults, so user rules always take priority.
PermissionNext.fromConfig() converts config entries into a ruleset:
- A string value (e.g.
"allow") becomes{pattern: "*", action: value} - An object value creates one rule per
(pattern, action)pair
Before matching, expand() expands ~/ and $HOME/ prefixes to the actual home directory.
Default rules are constructed per-agent. Notable defaults include:
"*": "allow"— allow everything by default"read" > "*.env": "ask"and"*.env.*": "ask"— prompt for.envfiles"external_directory" > "*": "ask"— prompt for paths outside the project
The value being matched against your permission pattern differs depending on which tool is requesting permission:
| Tool | Permission | Pattern value | Source |
|---|---|---|---|
| read | "read" |
Absolute file path | read.ts:47 |
| edit | "edit" |
Relative path from worktree | edit.ts:57, edit.ts:88 |
| write | "edit" |
Relative path from worktree | write.ts:36 |
| apply_patch | "edit" |
Relative paths from worktree | apply_patch.ts:175–178 |
| bash | "bash" |
Command text | bash.ts:159–161 |
| external_directory | "external_directory" |
parentDir/* glob |
bash.ts:150–153 |
| glob | "glob" |
The glob pattern | glob.ts:24 |
| grep | "grep" |
The search pattern | grep.ts:29 |
| webfetch | "webfetch" |
The URL | webfetch.ts:29 |
edit/write/apply_patch convert absolute paths to relative paths via path.relative(Instance.worktree, filePath) before permission matching. So edit permission patterns should match paths relative to the worktree root (e.g. src/*.ts), not absolute paths.
read, on the other hand, passes the absolute file path. So read permission patterns need to match absolute paths (e.g. /home/user/project/*.env), or use * liberally to cover the prefix.
- edit.ts line 57 — new file creation branch:
path.relative(Instance.worktree, filePath) - edit.ts line 88 — replace-in-existing-file branch:
path.relative(Instance.worktree, filePath) - write.ts line 36 —
path.relative(Instance.worktree, filepath) - apply_patch.ts line 175 —
fileChanges.map((c) => path.relative(Instance.worktree, c.filePath))
- Only
*(any characters) and?(one character) are special in patterns - All regex metacharacters are escaped — no raw regex mode exists
~/and$HOME/are expanded before matching- Last matching rule wins (user config overrides defaults)
editpatterns match relative paths;readpatterns match absolute paths- Linked to commit
2b8acfaonanomalyco/opencode