6.7 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 123 | Support trailing wildcard optionality (`command *` matches bare `command`) |
Trailing wildcard optionality
Problem Statement
A pattern like "git *" only matches commands with arguments (e.g., "git status"), not the bare command "git".
Users must write two rules ("git" and "git *") to cover both cases.
OpenCode treats the trailing * as optional, and users naturally read "git *" as "git with anything, including nothing."
Goals
- Make
compileWildcardPatterntreat a trailing*(space + wildcard) as optional, so"git *"also matches bare"git". - Align with OpenCode's behavior for this specific case.
- Update docs to reflect the new behavior.
Non-Goals
- Supporting the
?single-character wildcard (separate divergence). - Changing non-trailing
*behavior —"g*t"still requires content betweengandt.
Background
The wildcard matcher lives in src/wildcard-matcher.ts.
compileWildcardPattern splits on *, escapes each segment, and joins with .*.
The compiled regex is used across all permission surfaces (bash, tools, MCP, skills, special) via findCompiledWildcardMatch and wildcardMatch.
The change is isolated to one function — compileWildcardPattern — because all surfaces use it.
docs/opencode-compatibility.md explicitly documents this as a divergence (line 44, line 85–105).
docs/configuration.md uses "git *" in examples that would benefit from the new behavior.
Design Overview
In compileWildcardPattern, after building the escaped regex string, check if it ends with .*.
If so, replace the trailing .* with ( .*)? to make the space-and-arguments portion optional.
// After joining escaped segments with ".*":
if (escaped.endsWith(" .*")) {
escaped = escaped.slice(0, -3) + "( .*)?";
}
This matches OpenCode's implementation exactly.
Edge cases:
"git*"(no space before*) — unaffected, still matches"git"and"gitfoo"."*"(lone wildcard) — unaffected, the escaped string is.*not.*." *"(space-only prefix + wildcard) — escaped is.*, becomes( .*)?, matching empty string andanything. This is an unlikely pattern but harmless."git status *"— trailing.*becomes optional, so matches both"git status"and"git status --short". Correct.
This broadens existing patterns.
A user who wrote "rm *": "deny" would now also block bare rm.
This matches user intent and is consistent with the principle that deny should err on the side of blocking more.
Module-Level Changes
src/wildcard-matcher.ts
- In
compileWildcardPattern: after building the escaped regex string, add the trailing.*→( .*)?transformation.
tests/wildcard-matcher.test.ts
- Add tests for trailing wildcard optionality:
"git *"matches"git"(bare command)."git *"matches"git status"(with arguments — existing behavior preserved)."git *"matches"git status --short"(multiple arguments)."git *"does not match"npm install"(different prefix)."git status *"matches bare"git status".- Non-trailing
*is unaffected:"g*t"does not match"g"or"t". "git*"(no space) still matches"git"— unchanged behavior."*"alone still matches everything — unchanged behavior.
- Update existing test
"glob pattern matches with wildcard"assertion for"git *"against"git"(previouslyfalse, nowtrue— or add a new test alongside).
docs/opencode-compatibility.md
- Move "Trailing wildcard optionality" from divergences table to shared concepts.
- Remove the workaround section (lines 85–105) or replace it with a note that the behavior now matches.
docs/configuration.md
- Remove the need for duplicate
"git"+"git *"rules in examples where trailing wildcard optionality applies.
Test Impact Analysis
- New tests enabled: Direct unit tests for the trailing optionality behavior — straightforward additions to the existing
wildcardMatchandfindCompiledWildcardMatchdescribe blocks. - Existing tests that may break: The test
"glob pattern matches with wildcard"currently asserts"git *"does NOT match bare"git"implicitly (the test checks"git status"and"git push origin main"but not"git"). No existing assertion should break since none test"git *"against"git". However, review all tests to confirm. - Existing tests that stay: All other wildcard tests (exact match, last-match-wins, regex escaping, home path expansion, multiline) are unaffected.
TDD Order
-
Red: Add tests for trailing wildcard optionality (
"git *"matches"git","git status *"matches"git status", non-trailing*unaffected). Commit:test: add trailing wildcard optionality cases (#123) -
Green: Update
compileWildcardPatternto apply the( .*)?transformation. Commit:feat: support trailing wildcard optionality (#123) -
Docs: Update
docs/opencode-compatibility.mdanddocs/configuration.mdto reflect the new behavior. Commit:docs: update wildcard optionality docs (#123)
Risks and Mitigations
| Risk | Mitigation |
|---|---|
Broadens existing deny patterns (e.g., "rm *": "deny" now blocks bare rm) |
This matches user intent — bare rm is also dangerous. Aligns with least-privilege principle. |
| Could silently weaken a permission? | No — the change only broadens what a pattern matches, not what decision it produces. A deny pattern matching more commands is more restrictive, not less. An allow pattern matching bare commands is what users expect. |
Breaks users who rely on "cmd *" NOT matching bare "cmd" |
Unlikely — the current behavior is unintuitive and requires a workaround. The workaround ("cmd": "allow" alongside "cmd *": "allow") still works after the change. |
Open Questions
None — the implementation matches OpenCode's approach exactly and the issue is fully specified.