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