init claude-code
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
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 },
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
Reference in New Issue
Block a user