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