| import { z } from 'zod/v4' |
| import { mcpInfoFromString } from '../../services/mcp/mcpStringUtils.js' |
| import { lazySchema } from '../lazySchema.js' |
| import { permissionRuleValueFromString } from '../permissions/permissionRuleParser.js' |
| import { capitalize } from '../stringUtils.js' |
| import { |
| getCustomValidation, |
| isBashPrefixTool, |
| isFilePatternTool, |
| } from './toolValidationConfig.js' |
|
|
| |
| |
| |
| function isEscaped(str: string, index: number): boolean { |
| let backslashCount = 0 |
| let j = index - 1 |
| while (j >= 0 && str[j] === '\\') { |
| backslashCount++ |
| j-- |
| } |
| return backslashCount % 2 !== 0 |
| } |
|
|
| |
| |
| |
| |
| function countUnescapedChar(str: string, char: string): number { |
| let count = 0 |
| for (let i = 0; i < str.length; i++) { |
| if (str[i] === char && !isEscaped(str, i)) { |
| count++ |
| } |
| } |
| return count |
| } |
|
|
| |
| |
| |
| |
| function hasUnescapedEmptyParens(str: string): boolean { |
| for (let i = 0; i < str.length - 1; i++) { |
| if (str[i] === '(' && str[i + 1] === ')') { |
| |
| if (!isEscaped(str, i)) { |
| return true |
| } |
| } |
| } |
| return false |
| } |
|
|
| |
| |
| |
| export function validatePermissionRule(rule: string): { |
| valid: boolean |
| error?: string |
| suggestion?: string |
| examples?: string[] |
| } { |
| |
| if (!rule || rule.trim() === '') { |
| return { valid: false, error: 'Permission rule cannot be empty' } |
| } |
|
|
| |
| const openCount = countUnescapedChar(rule, '(') |
| const closeCount = countUnescapedChar(rule, ')') |
| if (openCount !== closeCount) { |
| return { |
| valid: false, |
| error: 'Mismatched parentheses', |
| suggestion: |
| 'Ensure all opening parentheses have matching closing parentheses', |
| } |
| } |
|
|
| |
| if (hasUnescapedEmptyParens(rule)) { |
| const toolName = rule.substring(0, rule.indexOf('(')) |
| if (!toolName) { |
| return { |
| valid: false, |
| error: 'Empty parentheses with no tool name', |
| suggestion: 'Specify a tool name before the parentheses', |
| } |
| } |
| return { |
| valid: false, |
| error: 'Empty parentheses', |
| suggestion: `Either specify a pattern or use just "${toolName}" without parentheses`, |
| examples: [`${toolName}`, `${toolName}(some-pattern)`], |
| } |
| } |
|
|
| |
| const parsed = permissionRuleValueFromString(rule) |
|
|
| |
| const mcpInfo = mcpInfoFromString(parsed.toolName) |
| if (mcpInfo) { |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| if (parsed.ruleContent !== undefined || countUnescapedChar(rule, '(') > 0) { |
| return { |
| valid: false, |
| error: 'MCP rules do not support patterns in parentheses', |
| suggestion: `Use "${parsed.toolName}" without parentheses, or use "mcp__${mcpInfo.serverName}__*" for all tools`, |
| examples: [ |
| `mcp__${mcpInfo.serverName}`, |
| `mcp__${mcpInfo.serverName}__*`, |
| mcpInfo.toolName && mcpInfo.toolName !== '*' |
| ? `mcp__${mcpInfo.serverName}__${mcpInfo.toolName}` |
| : undefined, |
| ].filter(Boolean) as string[], |
| } |
| } |
|
|
| return { valid: true } |
| } |
|
|
| |
| if (!parsed.toolName || parsed.toolName.length === 0) { |
| return { valid: false, error: 'Tool name cannot be empty' } |
| } |
|
|
| |
| if (parsed.toolName[0] !== parsed.toolName[0]?.toUpperCase()) { |
| return { |
| valid: false, |
| error: 'Tool names must start with uppercase', |
| suggestion: `Use "${capitalize(String(parsed.toolName))}"`, |
| } |
| } |
|
|
| |
| const customValidation = getCustomValidation(parsed.toolName) |
| if (customValidation && parsed.ruleContent !== undefined) { |
| const customResult = customValidation(parsed.ruleContent) |
| if (!customResult.valid) { |
| return customResult |
| } |
| } |
|
|
| |
| if (isBashPrefixTool(parsed.toolName) && parsed.ruleContent !== undefined) { |
| const content = parsed.ruleContent |
|
|
| |
| if (content.includes(':*') && !content.endsWith(':*')) { |
| return { |
| valid: false, |
| error: 'The :* pattern must be at the end', |
| suggestion: |
| 'Move :* to the end for prefix matching, or use * for wildcard matching', |
| examples: [ |
| 'Bash(npm run:*) - prefix matching (legacy)', |
| 'Bash(npm run *) - wildcard matching', |
| ], |
| } |
| } |
|
|
| |
| if (content === ':*') { |
| return { |
| valid: false, |
| error: 'Prefix cannot be empty before :*', |
| suggestion: 'Specify a command prefix before :*', |
| examples: ['Bash(npm:*)', 'Bash(git:*)'], |
| } |
| } |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| } |
|
|
| |
| if (isFilePatternTool(parsed.toolName) && parsed.ruleContent !== undefined) { |
| const content = parsed.ruleContent |
|
|
| |
| if (content.includes(':*')) { |
| return { |
| valid: false, |
| error: 'The ":*" syntax is only for Bash prefix rules', |
| suggestion: 'Use glob patterns like "*" or "**" for file matching', |
| examples: [ |
| `${parsed.toolName}(*.ts) - matches .ts files`, |
| `${parsed.toolName}(src/**) - matches all files in src`, |
| `${parsed.toolName}(**/*.test.ts) - matches test files`, |
| ], |
| } |
| } |
|
|
| |
| if ( |
| content.includes('*') && |
| !content.match(/^\*|\*$|\*\*|\/\*|\*\.|\*\)/) && |
| !content.includes('**') |
| ) { |
| |
| |
| return { |
| valid: false, |
| error: 'Wildcard placement might be incorrect', |
| suggestion: 'Wildcards are typically used at path boundaries', |
| examples: [ |
| `${parsed.toolName}(*.js) - all .js files`, |
| `${parsed.toolName}(src/*) - all files directly in src`, |
| `${parsed.toolName}(src/**) - all files recursively in src`, |
| ], |
| } |
| } |
| } |
|
|
| return { valid: true } |
| } |
|
|
| |
| |
| |
| export const PermissionRuleSchema = lazySchema(() => |
| z.string().superRefine((val, ctx) => { |
| const result = validatePermissionRule(val) |
| if (!result.valid) { |
| let message = result.error! |
| if (result.suggestion) { |
| message += `. ${result.suggestion}` |
| } |
| if (result.examples && result.examples.length > 0) { |
| message += `. Examples: ${result.examples.join(', ')}` |
| } |
| ctx.addIssue({ |
| code: z.ZodIssueCode.custom, |
| message, |
| params: { received: val }, |
| }) |
| } |
| }), |
| ) |
|
|