permissionValidation.ts
utils/settings/permissionValidation.ts
263
Lines
8657
Bytes
2
Exports
6
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with shell-safety, permissions. It contains 263 lines, 6 detected imports, and 2 detected exports.
Important relationships
Detected exports
validatePermissionRulePermissionRuleSchema
Keywords
parsedtoolnamevalidruleparenthesescontentsuggestionexamplescheckmatching
Detected imports
zod/v4../../services/mcp/mcpStringUtils.js../lazySchema.js../permissions/permissionRuleParser.js../stringUtils.js./toolValidationConfig.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
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 },
})
}
}),
)