forked from ultraworkers/claw-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpermissionValidation.ts
More file actions
262 lines (239 loc) · 8.45 KB
/
Copy pathpermissionValidation.ts
File metadata and controls
262 lines (239 loc) · 8.45 KB
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
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 },
})
}
}),
)