/** * 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', }, ] }