π File detail
utils/settings/permissionValidation.ts
π― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes validatePermissionRule and PermissionRuleSchema β mainly functions, hooks, or classes. Dependencies touch schema validation. It composes internal code from services, lazySchema, permissions, stringUtils, and toolValidationConfig (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
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'
π€ Exports (heuristic)
validatePermissionRulePermissionRuleSchema
π External import roots
Package roots from from "β¦" (relative paths omitted).
zod
π₯οΈ Source preview
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 },
})
}
}),
)