Skip to content
Draft
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
74 changes: 73 additions & 1 deletion src/core/auto-approval/__tests__/commands.spec.ts
Original file line number Diff line number Diff line change
@@ -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)", () => {
Expand Down Expand Up @@ -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")
})
})
28 changes: 24 additions & 4 deletions src/core/auto-approval/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand Down Expand Up @@ -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)
})
}

Expand Down
2 changes: 1 addition & 1 deletion webview-ui/src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ')",
Expand Down
Loading