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 )