fix: support trailing wildcards in auto-approve command matching

Strip trailing asterisk(s) from command prefixes before performing
startsWith checks. This allows patterns like "git*" and "git *" to
work as users expect when configuring allowed auto-execute commands.

- "git*" normalizes to "git" and matches any command starting with "git"
- "git *" normalizes to "git " and matches "git commit" but not "gitk"
- Standalone "*" (match-all) behavior remains unchanged
- Original prefix length preserved for longest-prefix-match comparisons
- Updated UI description to clarify prefix and wildcard behavior
- Added comprehensive tests for trailing wildcard patterns

Fixes #11756
This commit is contained in:
Roo Code 2026-02-26 14:27:40 +00:00
parent e25b1f2768
commit ab259d0956
3 changed files with 98 additions and 6 deletions

View file

@ -1,4 +1,9 @@
import { containsDangerousSubstitution, getCommandDecision } from "../commands"
import {
containsDangerousSubstitution,
getCommandDecision,
findLongestPrefixMatch,
isAutoApprovedSingleCommand,
} from "../commands"
describe("containsDangerousSubstitution", () => {
describe("zsh array assignments (should NOT be flagged)", () => {
@ -99,3 +104,70 @@ describe("getCommandDecision — integration with dangerous substitution checks"
expect(getCommandDecision('echo "${var@P}"', allowedCommands)).toBe("ask_user")
})
})
describe("findLongestPrefixMatch — trailing wildcard support", () => {
it("should match 'git*' against 'git commit -m \"Fix nav bar\"'", () => {
expect(findLongestPrefixMatch('git commit -m "Fix nav bar"', ["git*"])).toBe("git*")
})
it("should match 'git *' against 'git commit' but not 'gitk'", () => {
// "git *" normalizes to "git " (with trailing space)
expect(findLongestPrefixMatch("git commit", ["git *"])).toBe("git *")
expect(findLongestPrefixMatch("gitk", ["git *"])).toBeNull()
})
it("should match 'git*' against 'gitk' (no space required)", () => {
expect(findLongestPrefixMatch("gitk", ["git*"])).toBe("git*")
})
it("should strip multiple trailing asterisks", () => {
expect(findLongestPrefixMatch("git status", ["git**"])).toBe("git**")
})
it("should prefer a longer trailing-wildcard prefix over a shorter one", () => {
expect(findLongestPrefixMatch("git push origin", ["git*", "git push*"])).toBe("git push*")
})
it("should prefer a specific trailing-wildcard prefix over standalone '*'", () => {
expect(findLongestPrefixMatch("git status", ["*", "git*"])).toBe("git*")
})
it("should still match standalone '*' when no other prefix matches", () => {
expect(findLongestPrefixMatch("unknown command", ["*", "git*"])).toBe("*")
})
it("should return null when no prefix matches and no wildcard present", () => {
expect(findLongestPrefixMatch("npm install", ["git*"])).toBeNull()
})
})
describe("isAutoApprovedSingleCommand — trailing wildcard support", () => {
it("should auto-approve 'git commit -m ...' when allowedCommands has 'git*'", () => {
expect(isAutoApprovedSingleCommand('git commit -m "Fix dark mode"', ["git*"])).toBe(true)
})
it("should not auto-approve 'npm install' when allowedCommands has 'git*'", () => {
expect(isAutoApprovedSingleCommand("npm install", ["git*"])).toBe(false)
})
it("should auto-approve 'git commit' with 'git *' but not 'gitk'", () => {
expect(isAutoApprovedSingleCommand("git commit", ["git *"])).toBe(true)
expect(isAutoApprovedSingleCommand("gitk", ["git *"])).toBe(false)
})
})
describe("getCommandDecision — trailing wildcard with deny list", () => {
it("should auto-approve 'git status' with 'git*' in allowlist", () => {
expect(getCommandDecision("git status", ["git*"], [])).toBe("auto_approve")
})
it("should deny 'git push' when denied and allowed with equal-length prefixes", () => {
// "git*" (length 4) vs "git push" (length 8) in denylist -> deny wins (longer)
expect(getCommandDecision("git push origin", ["git*"], ["git push"])).toBe("auto_deny")
})
it("should approve 'git status' when denied prefix is less specific", () => {
// "git status*" (length 11) vs "git*" (length 4) in denylist -> allow wins (longer)
expect(getCommandDecision("git status", ["git status*"], ["git*"])).toBe("auto_approve")
})
})

View file

@ -99,13 +99,30 @@ export function findLongestPrefixMatch(command: string, prefixes: string[]): str
const trimmedCommand = command.trim().toLowerCase()
let longestMatch: string | null = null
let longestMatchLength = 0
for (const prefix of prefixes) {
const lowerPrefix = prefix.toLowerCase()
// Handle wildcard "*" - it matches any command
if (lowerPrefix === "*" || trimmedCommand.startsWith(lowerPrefix)) {
if (!longestMatch || lowerPrefix.length > longestMatch.length) {
// Handle standalone wildcard "*" - it matches any command
if (lowerPrefix === "*") {
if (!longestMatch) {
longestMatch = lowerPrefix
longestMatchLength = 1
}
continue
}
// Strip trailing asterisk(s) to support trailing-wildcard patterns
// e.g. "git*" -> "git", "git *" -> "git " (note the space is preserved)
const normalizedPrefix = lowerPrefix.replace(/\*+$/, "")
if (trimmedCommand.startsWith(normalizedPrefix)) {
// Use the original prefix length for comparison so that
// "git*" (4 chars) beats a standalone "*" (1 char)
if (lowerPrefix.length > longestMatchLength) {
longestMatch = lowerPrefix
longestMatchLength = lowerPrefix.length
}
}
}
@ -145,7 +162,10 @@ export function isAutoApprovedSingleCommand(
return allowedCommands.some((prefix) => {
const lowerPrefix = prefix.toLowerCase()
// Handle wildcard "*" - it matches any command
return lowerPrefix === "*" || trimmedCommand.startsWith(lowerPrefix)
if (lowerPrefix === "*") return true
// Strip trailing asterisk(s) to support trailing-wildcard patterns
const normalizedPrefix = lowerPrefix.replace(/\*+$/, "")
return trimmedCommand.startsWith(normalizedPrefix)
})
}

View file

@ -327,7 +327,7 @@
"label": "Execute",
"description": "Automatically execute allowed terminal commands without requiring approval",
"allowedCommands": "Allowed Auto-Execute Commands",
"allowedCommandsDescription": "Command prefixes that can be auto-executed when \"Always approve execute operations\" is enabled. Add * to allow all commands (use with caution).",
"allowedCommandsDescription": "Command prefixes that can be auto-executed when \"Always approve execute operations\" is enabled. For example, \"git\" matches all git commands. Trailing wildcards like \"git*\" are also supported. Add * to allow all commands (use with caution).",
"deniedCommands": "Denied Commands",
"deniedCommandsDescription": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.",
"commandPlaceholder": "Enter command prefix (e.g., 'git ')",