File size: 6,409 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 | /**
* Shared permission rule matching utilities for shell tools.
*
* Extracts common logic for:
* - Parsing permission rules (exact, prefix, wildcard)
* - Matching commands against rules
* - Generating permission suggestions
*/
import type { PermissionUpdate } from './PermissionUpdateSchema.js'
// Null-byte sentinel placeholders for wildcard pattern escaping — module-level
// so the RegExp objects are compiled once instead of per permission check.
const ESCAPED_STAR_PLACEHOLDER = '\x00ESCAPED_STAR\x00'
const ESCAPED_BACKSLASH_PLACEHOLDER = '\x00ESCAPED_BACKSLASH\x00'
const ESCAPED_STAR_PLACEHOLDER_RE = new RegExp(ESCAPED_STAR_PLACEHOLDER, 'g')
const ESCAPED_BACKSLASH_PLACEHOLDER_RE = new RegExp(
ESCAPED_BACKSLASH_PLACEHOLDER,
'g',
)
/**
* Parsed permission rule discriminated union.
*/
export type ShellPermissionRule =
| {
type: 'exact'
command: string
}
| {
type: 'prefix'
prefix: string
}
| {
type: 'wildcard'
pattern: string
}
/**
* Extract prefix from legacy :* syntax (e.g., "npm:*" -> "npm")
* This is maintained for backwards compatibility.
*/
export function permissionRuleExtractPrefix(
permissionRule: string,
): string | null {
const match = permissionRule.match(/^(.+):\*$/)
return match?.[1] ?? null
}
/**
* Check if a pattern contains unescaped wildcards (not legacy :* syntax).
* Returns true if the pattern contains * that are not escaped with \ or part of :* at the end.
*/
export function hasWildcards(pattern: string): boolean {
// If it ends with :*, it's legacy prefix syntax, not wildcard
if (pattern.endsWith(':*')) {
return false
}
// Check for unescaped * anywhere in the pattern
// An asterisk is unescaped if it's not preceded by a backslash,
// or if it's preceded by an even number of backslashes (escaped backslashes)
for (let i = 0; i < pattern.length; i++) {
if (pattern[i] === '*') {
// Count backslashes before this asterisk
let backslashCount = 0
let j = i - 1
while (j >= 0 && pattern[j] === '\\') {
backslashCount++
j--
}
// If even number of backslashes (including 0), the asterisk is unescaped
if (backslashCount % 2 === 0) {
return true
}
}
}
return false
}
/**
* Match a command against a wildcard pattern.
* Wildcards (*) match any sequence of characters.
* Use \* to match a literal asterisk character.
* Use \\ to match a literal backslash.
*
* @param pattern - The permission rule pattern with wildcards
* @param command - The command to match against
* @returns true if the command matches the pattern
*/
export function matchWildcardPattern(
pattern: string,
command: string,
caseInsensitive = false,
): boolean {
// Trim leading/trailing whitespace from pattern
const trimmedPattern = pattern.trim()
// Process the pattern to handle escape sequences: \* and \\
let processed = ''
let i = 0
while (i < trimmedPattern.length) {
const char = trimmedPattern[i]
// Handle escape sequences
if (char === '\\' && i + 1 < trimmedPattern.length) {
const nextChar = trimmedPattern[i + 1]
if (nextChar === '*') {
// \* -> literal asterisk placeholder
processed += ESCAPED_STAR_PLACEHOLDER
i += 2
continue
} else if (nextChar === '\\') {
// \\ -> literal backslash placeholder
processed += ESCAPED_BACKSLASH_PLACEHOLDER
i += 2
continue
}
}
processed += char
i++
}
// Escape regex special characters except *
const escaped = processed.replace(/[.+?^${}()|[\]\\'"]/g, '\\$&')
// Convert unescaped * to .* for wildcard matching
const withWildcards = escaped.replace(/\*/g, '.*')
// Convert placeholders back to escaped regex literals
let regexPattern = withWildcards
.replace(ESCAPED_STAR_PLACEHOLDER_RE, '\\*')
.replace(ESCAPED_BACKSLASH_PLACEHOLDER_RE, '\\\\')
// When a pattern ends with ' *' (space + unescaped wildcard) AND the trailing
// wildcard is the ONLY unescaped wildcard, make the trailing space-and-args
// optional so 'git *' matches both 'git add' and bare 'git'.
// This aligns wildcard matching with prefix rule semantics (git:*).
// Multi-wildcard patterns like '* run *' are excluded — making the last
// wildcard optional would incorrectly match 'npm run' (no trailing arg).
const unescapedStarCount = (processed.match(/\*/g) || []).length
if (regexPattern.endsWith(' .*') && unescapedStarCount === 1) {
regexPattern = regexPattern.slice(0, -3) + '( .*)?'
}
// Create regex that matches the entire string.
// The 's' (dotAll) flag makes '.' match newlines, so wildcards match
// commands containing embedded newlines (e.g. heredoc content after splitCommand_DEPRECATED).
const flags = 's' + (caseInsensitive ? 'i' : '')
const regex = new RegExp(`^${regexPattern}$`, flags)
return regex.test(command)
}
/**
* Parse a permission rule string into a structured rule object.
*/
export function parsePermissionRule(
permissionRule: string,
): ShellPermissionRule {
// Check for legacy :* prefix syntax first (backwards compatibility)
const prefix = permissionRuleExtractPrefix(permissionRule)
if (prefix !== null) {
return {
type: 'prefix',
prefix,
}
}
// Check for new wildcard syntax (contains * but not :* at end)
if (hasWildcards(permissionRule)) {
return {
type: 'wildcard',
pattern: permissionRule,
}
}
// Otherwise, it's an exact match
return {
type: 'exact',
command: permissionRule,
}
}
/**
* Generate permission update suggestion for an exact command match.
*/
export function suggestionForExactCommand(
toolName: string,
command: string,
): PermissionUpdate[] {
return [
{
type: 'addRules',
rules: [
{
toolName,
ruleContent: command,
},
],
behavior: 'allow',
destination: 'localSettings',
},
]
}
/**
* Generate permission update suggestion for a prefix match.
*/
export function suggestionForPrefix(
toolName: string,
prefix: string,
): PermissionUpdate[] {
return [
{
type: 'addRules',
rules: [
{
toolName,
ruleContent: `${prefix}:*`,
},
],
behavior: 'allow',
destination: 'localSettings',
},
]
}
|