/ utils / settings / permissionValidation.ts
permissionValidation.ts
  1  import { z } from 'zod/v4'
  2  import { mcpInfoFromString } from '../../services/mcp/mcpStringUtils.js'
  3  import { lazySchema } from '../lazySchema.js'
  4  import { permissionRuleValueFromString } from '../permissions/permissionRuleParser.js'
  5  import { capitalize } from '../stringUtils.js'
  6  import {
  7    getCustomValidation,
  8    isBashPrefixTool,
  9    isFilePatternTool,
 10  } from './toolValidationConfig.js'
 11  
 12  /**
 13   * Checks if a character at a given index is escaped (preceded by odd number of backslashes).
 14   */
 15  function isEscaped(str: string, index: number): boolean {
 16    let backslashCount = 0
 17    let j = index - 1
 18    while (j >= 0 && str[j] === '\\') {
 19      backslashCount++
 20      j--
 21    }
 22    return backslashCount % 2 !== 0
 23  }
 24  
 25  /**
 26   * Counts unescaped occurrences of a character in a string.
 27   * A character is considered escaped if preceded by an odd number of backslashes.
 28   */
 29  function countUnescapedChar(str: string, char: string): number {
 30    let count = 0
 31    for (let i = 0; i < str.length; i++) {
 32      if (str[i] === char && !isEscaped(str, i)) {
 33        count++
 34      }
 35    }
 36    return count
 37  }
 38  
 39  /**
 40   * Checks if a string contains unescaped empty parentheses "()".
 41   * Returns true only if both the "(" and ")" are unescaped and adjacent.
 42   */
 43  function hasUnescapedEmptyParens(str: string): boolean {
 44    for (let i = 0; i < str.length - 1; i++) {
 45      if (str[i] === '(' && str[i + 1] === ')') {
 46        // Check if the opening paren is unescaped
 47        if (!isEscaped(str, i)) {
 48          return true
 49        }
 50      }
 51    }
 52    return false
 53  }
 54  
 55  /**
 56   * Validates permission rule format and content
 57   */
 58  export function validatePermissionRule(rule: string): {
 59    valid: boolean
 60    error?: string
 61    suggestion?: string
 62    examples?: string[]
 63  } {
 64    // Empty rule check
 65    if (!rule || rule.trim() === '') {
 66      return { valid: false, error: 'Permission rule cannot be empty' }
 67    }
 68  
 69    // Check parentheses matching first (only count unescaped parens)
 70    const openCount = countUnescapedChar(rule, '(')
 71    const closeCount = countUnescapedChar(rule, ')')
 72    if (openCount !== closeCount) {
 73      return {
 74        valid: false,
 75        error: 'Mismatched parentheses',
 76        suggestion:
 77          'Ensure all opening parentheses have matching closing parentheses',
 78      }
 79    }
 80  
 81    // Check for empty parentheses (escape-aware)
 82    if (hasUnescapedEmptyParens(rule)) {
 83      const toolName = rule.substring(0, rule.indexOf('('))
 84      if (!toolName) {
 85        return {
 86          valid: false,
 87          error: 'Empty parentheses with no tool name',
 88          suggestion: 'Specify a tool name before the parentheses',
 89        }
 90      }
 91      return {
 92        valid: false,
 93        error: 'Empty parentheses',
 94        suggestion: `Either specify a pattern or use just "${toolName}" without parentheses`,
 95        examples: [`${toolName}`, `${toolName}(some-pattern)`],
 96      }
 97    }
 98  
 99    // Parse the rule
100    const parsed = permissionRuleValueFromString(rule)
101  
102    // MCP validation - must be done before general tool validation
103    const mcpInfo = mcpInfoFromString(parsed.toolName)
104    if (mcpInfo) {
105      // MCP rules support server-level, tool-level, and wildcard permissions
106      // Valid formats:
107      // - mcp__server (server-level, all tools)
108      // - mcp__server__* (wildcard, all tools - equivalent to server-level)
109      // - mcp__server__tool (specific tool)
110  
111      // MCP rules cannot have any pattern/content (parentheses)
112      // Check both parsed content and raw string since the parser normalizes
113      // standalone wildcards (e.g., "mcp__server(*)") to undefined ruleContent
114      if (parsed.ruleContent !== undefined || countUnescapedChar(rule, '(') > 0) {
115        return {
116          valid: false,
117          error: 'MCP rules do not support patterns in parentheses',
118          suggestion: `Use "${parsed.toolName}" without parentheses, or use "mcp__${mcpInfo.serverName}__*" for all tools`,
119          examples: [
120            `mcp__${mcpInfo.serverName}`,
121            `mcp__${mcpInfo.serverName}__*`,
122            mcpInfo.toolName && mcpInfo.toolName !== '*'
123              ? `mcp__${mcpInfo.serverName}__${mcpInfo.toolName}`
124              : undefined,
125          ].filter(Boolean) as string[],
126        }
127      }
128  
129      return { valid: true } // Valid MCP rule
130    }
131  
132    // Tool name validation (for non-MCP tools)
133    if (!parsed.toolName || parsed.toolName.length === 0) {
134      return { valid: false, error: 'Tool name cannot be empty' }
135    }
136  
137    // Check tool name starts with uppercase (standard tools)
138    if (parsed.toolName[0] !== parsed.toolName[0]?.toUpperCase()) {
139      return {
140        valid: false,
141        error: 'Tool names must start with uppercase',
142        suggestion: `Use "${capitalize(String(parsed.toolName))}"`,
143      }
144    }
145  
146    // Check for custom validation rules first
147    const customValidation = getCustomValidation(parsed.toolName)
148    if (customValidation && parsed.ruleContent !== undefined) {
149      const customResult = customValidation(parsed.ruleContent)
150      if (!customResult.valid) {
151        return customResult
152      }
153    }
154  
155    // Bash-specific validation
156    if (isBashPrefixTool(parsed.toolName) && parsed.ruleContent !== undefined) {
157      const content = parsed.ruleContent
158  
159      // Check for common :* mistakes - :* must be at the end (legacy prefix syntax)
160      if (content.includes(':*') && !content.endsWith(':*')) {
161        return {
162          valid: false,
163          error: 'The :* pattern must be at the end',
164          suggestion:
165            'Move :* to the end for prefix matching, or use * for wildcard matching',
166          examples: [
167            'Bash(npm run:*) - prefix matching (legacy)',
168            'Bash(npm run *) - wildcard matching',
169          ],
170        }
171      }
172  
173      // Check for :* without a prefix
174      if (content === ':*') {
175        return {
176          valid: false,
177          error: 'Prefix cannot be empty before :*',
178          suggestion: 'Specify a command prefix before :*',
179          examples: ['Bash(npm:*)', 'Bash(git:*)'],
180        }
181      }
182  
183      // Note: We don't validate quote balancing because bash quoting rules are complex.
184      // A command like `grep '"'` has valid unbalanced double quotes.
185      // Users who create patterns with unintended quote mismatches will discover
186      // the issue when matching doesn't work as expected.
187  
188      // Wildcards are now allowed at any position for flexible pattern matching
189      // Examples of valid wildcard patterns:
190      // - "npm *" matches "npm install", "npm run test", etc.
191      // - "* install" matches "npm install", "yarn install", etc.
192      // - "git * main" matches "git checkout main", "git push main", etc.
193      // - "npm * --save" matches "npm install foo --save", etc.
194      //
195      // Legacy :* syntax continues to work for backwards compatibility:
196      // - "npm:*" matches "npm" or "npm <anything>" (prefix matching with word boundary)
197    }
198  
199    // File tool validation
200    if (isFilePatternTool(parsed.toolName) && parsed.ruleContent !== undefined) {
201      const content = parsed.ruleContent
202  
203      // Check for :* in file patterns (common mistake from Bash patterns)
204      if (content.includes(':*')) {
205        return {
206          valid: false,
207          error: 'The ":*" syntax is only for Bash prefix rules',
208          suggestion: 'Use glob patterns like "*" or "**" for file matching',
209          examples: [
210            `${parsed.toolName}(*.ts) - matches .ts files`,
211            `${parsed.toolName}(src/**) - matches all files in src`,
212            `${parsed.toolName}(**/*.test.ts) - matches test files`,
213          ],
214        }
215      }
216  
217      // Warn about wildcards not at boundaries
218      if (
219        content.includes('*') &&
220        !content.match(/^\*|\*$|\*\*|\/\*|\*\.|\*\)/) &&
221        !content.includes('**')
222      ) {
223        // This is a loose check - wildcards in the middle might be valid in some cases
224        // but often indicate confusion
225        return {
226          valid: false,
227          error: 'Wildcard placement might be incorrect',
228          suggestion: 'Wildcards are typically used at path boundaries',
229          examples: [
230            `${parsed.toolName}(*.js) - all .js files`,
231            `${parsed.toolName}(src/*) - all files directly in src`,
232            `${parsed.toolName}(src/**) - all files recursively in src`,
233          ],
234        }
235      }
236    }
237  
238    return { valid: true }
239  }
240  
241  /**
242   * Custom Zod schema for permission rule arrays
243   */
244  export const PermissionRuleSchema = lazySchema(() =>
245    z.string().superRefine((val, ctx) => {
246      const result = validatePermissionRule(val)
247      if (!result.valid) {
248        let message = result.error!
249        if (result.suggestion) {
250          message += `. ${result.suggestion}`
251        }
252        if (result.examples && result.examples.length > 0) {
253          message += `. Examples: ${result.examples.join(', ')}`
254        }
255        ctx.addIssue({
256          code: z.ZodIssueCode.custom,
257          message,
258          params: { received: val },
259        })
260      }
261    }),
262  )