/ utils / permissions / shellRuleMatching.ts
shellRuleMatching.ts
  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  
 10  import 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.
 14  const ESCAPED_STAR_PLACEHOLDER = '\x00ESCAPED_STAR\x00'
 15  const ESCAPED_BACKSLASH_PLACEHOLDER = '\x00ESCAPED_BACKSLASH\x00'
 16  const ESCAPED_STAR_PLACEHOLDER_RE = new RegExp(ESCAPED_STAR_PLACEHOLDER, 'g')
 17  const ESCAPED_BACKSLASH_PLACEHOLDER_RE = new RegExp(
 18    ESCAPED_BACKSLASH_PLACEHOLDER,
 19    'g',
 20  )
 21  
 22  /**
 23   * Parsed permission rule discriminated union.
 24   */
 25  export 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   */
 43  export 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   */
 54  export 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   */
 90  export 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   */
159  export 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   */
189  export 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   */
211  export 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  }