diff --git a/src/core/auto-approval/__tests__/commands.spec.ts b/src/core/auto-approval/__tests__/commands.spec.ts index 90d2808ba9c..cccb34a7c64 100644 --- a/src/core/auto-approval/__tests__/commands.spec.ts +++ b/src/core/auto-approval/__tests__/commands.spec.ts @@ -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") + }) +}) diff --git a/src/core/auto-approval/commands.ts b/src/core/auto-approval/commands.ts index d9e88c7ba26..2334601cb0b 100644 --- a/src/core/auto-approval/commands.ts +++ b/src/core/auto-approval/commands.ts @@ -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) }) } diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 3b2497aaee7..5ad2609bcb8 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -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 ')",