forked from liuup/claude-code-analysis
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpathValidation.ts
More file actions
485 lines (441 loc) · 15.9 KB
/
pathValidation.ts
File metadata and controls
485 lines (441 loc) · 15.9 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
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
import memoize from 'lodash-es/memoize.js'
import { homedir } from 'os'
import { dirname, isAbsolute, resolve } from 'path'
import type { ToolPermissionContext } from '../../Tool.js'
import { getPlatform } from '../../utils/platform.js'
import {
getFsImplementation,
getPathsForPermissionCheck,
safeResolvePath,
} from '../fsOperations.js'
import { containsPathTraversal } from '../path.js'
import { SandboxManager } from '../sandbox/sandbox-adapter.js'
import { containsVulnerableUncPath } from '../shell/readOnlyCommandValidation.js'
import {
checkEditableInternalPath,
checkPathSafetyForAutoEdit,
checkReadableInternalPath,
matchingRuleForInput,
pathInAllowedWorkingPath,
pathInWorkingPath,
} from './filesystem.js'
import type { PermissionDecisionReason } from './PermissionResult.js'
const MAX_DIRS_TO_LIST = 5
const GLOB_PATTERN_REGEX = /[*?[\]{}]/
export type FileOperationType = 'read' | 'write' | 'create'
export type PathCheckResult = {
allowed: boolean
decisionReason?: PermissionDecisionReason
}
export type ResolvedPathCheckResult = PathCheckResult & {
resolvedPath: string
}
export function formatDirectoryList(directories: string[]): string {
const dirCount = directories.length
if (dirCount <= MAX_DIRS_TO_LIST) {
return directories.map(dir => `'${dir}'`).join(', ')
}
const firstDirs = directories
.slice(0, MAX_DIRS_TO_LIST)
.map(dir => `'${dir}'`)
.join(', ')
return `${firstDirs}, and ${dirCount - MAX_DIRS_TO_LIST} more`
}
/**
* Extracts the base directory from a glob pattern for validation.
* For example: "/path/to/*.txt" returns "/path/to"
*/
export function getGlobBaseDirectory(path: string): string {
const globMatch = path.match(GLOB_PATTERN_REGEX)
if (!globMatch || globMatch.index === undefined) {
return path
}
// Get everything before the first glob character
const beforeGlob = path.substring(0, globMatch.index)
// Find the last directory separator
const lastSepIndex =
getPlatform() === 'windows'
? Math.max(beforeGlob.lastIndexOf('/'), beforeGlob.lastIndexOf('\\'))
: beforeGlob.lastIndexOf('/')
if (lastSepIndex === -1) return '.'
return beforeGlob.substring(0, lastSepIndex) || '/'
}
/**
* Expands tilde (~) at the start of a path to the user's home directory.
* Note: ~username expansion is not supported for security reasons.
*/
export function expandTilde(path: string): string {
if (
path === '~' ||
path.startsWith('~/') ||
(process.platform === 'win32' && path.startsWith('~\\'))
) {
return homedir() + path.slice(1)
}
return path
}
/**
* Checks if a resolved path is writable according to the sandbox write allowlist.
* When the sandbox is enabled, the user has explicitly configured which directories
* are writable. We treat these as additional allowed write directories for path
* validation purposes, so commands like `echo foo > /tmp/claude/x.txt` don't
* prompt for permission when /tmp/claude/ is already in the sandbox allowlist.
*
* Respects the deny-within-allow list: paths in denyWithinAllow (like
* .claude/settings.json) are still blocked even if their parent is in allowOnly.
*/
export function isPathInSandboxWriteAllowlist(resolvedPath: string): boolean {
if (!SandboxManager.isSandboxingEnabled()) {
return false
}
const { allowOnly, denyWithinAllow } = SandboxManager.getFsWriteConfig()
// Resolve symlinks on both sides so comparisons are symmetric (matching
// pathInAllowedWorkingPath). Without this, an allowlist entry that is a
// symlink (e.g. /home/user/proj -> /data/proj) would not match a write to
// its resolved target, causing an unnecessary prompt. Over-conservative,
// not a security issue. All resolved input representations must be allowed
// and none may be denied. Config paths are session-stable, so memoize
// their resolution to avoid N × config.length redundant syscalls per
// command with N write targets (matching getResolvedWorkingDirPaths).
const pathsToCheck = getPathsForPermissionCheck(resolvedPath)
const resolvedAllow = allowOnly.flatMap(getResolvedSandboxConfigPath)
const resolvedDeny = denyWithinAllow.flatMap(getResolvedSandboxConfigPath)
return pathsToCheck.every(p => {
for (const denyPath of resolvedDeny) {
if (pathInWorkingPath(p, denyPath)) return false
}
return resolvedAllow.some(allowPath => pathInWorkingPath(p, allowPath))
})
}
// Sandbox config paths are session-stable; memoize their resolved forms to
// avoid repeated lstat/realpath syscalls on every write-target check.
// Matches the getResolvedWorkingDirPaths pattern in filesystem.ts.
const getResolvedSandboxConfigPath = memoize(getPathsForPermissionCheck)
/**
* Checks if a resolved path is allowed for the given operation type.
*
* @param precomputedPathsToCheck - Optional cached result of
* `getPathsForPermissionCheck(resolvedPath)`. When `resolvedPath` is the
* output of `realpathSync` (canonical path, all symlinks resolved), this
* is trivially `[resolvedPath]` and passing it here skips 5 redundant
* syscalls per inner check. Do NOT pass this for non-canonical paths
* (nonexistent files, UNC paths, etc.) — parent-directory symlink
* resolution is still required for those.
*/
export function isPathAllowed(
resolvedPath: string,
context: ToolPermissionContext,
operationType: FileOperationType,
precomputedPathsToCheck?: readonly string[],
): PathCheckResult {
// Determine which permission type to check based on operation
const permissionType = operationType === 'read' ? 'read' : 'edit'
// 1. Check deny rules first (they take precedence)
const denyRule = matchingRuleForInput(
resolvedPath,
context,
permissionType,
'deny',
)
if (denyRule !== null) {
return {
allowed: false,
decisionReason: { type: 'rule', rule: denyRule },
}
}
// 2. For write/create operations, check internal editable paths (plan files, scratchpad, agent memory, job dirs)
// This MUST come before checkPathSafetyForAutoEdit since .claude is a dangerous directory
// and internal editable paths live under ~/.claude/ — matching the ordering in
// checkWritePermissionForTool (filesystem.ts step 1.5)
if (operationType !== 'read') {
const internalEditResult = checkEditableInternalPath(resolvedPath, {})
if (internalEditResult.behavior === 'allow') {
return {
allowed: true,
decisionReason: internalEditResult.decisionReason,
}
}
}
// 2.5. For write/create operations, check comprehensive safety validations
// This MUST come before checking working directory to prevent bypass via acceptEdits mode
// Checks: Windows patterns, Claude config files, dangerous files (on original + symlink paths)
if (operationType !== 'read') {
const safetyCheck = checkPathSafetyForAutoEdit(
resolvedPath,
precomputedPathsToCheck,
)
if (!safetyCheck.safe) {
return {
allowed: false,
decisionReason: {
type: 'safetyCheck',
reason: safetyCheck.message,
classifierApprovable: safetyCheck.classifierApprovable,
},
}
}
}
// 3. Check if path is in allowed working directory
// For write/create operations, require acceptEdits mode to auto-allow
// This is consistent with checkWritePermissionForTool in filesystem.ts
const isInWorkingDir = pathInAllowedWorkingPath(
resolvedPath,
context,
precomputedPathsToCheck,
)
if (isInWorkingDir) {
if (operationType === 'read' || context.mode === 'acceptEdits') {
return { allowed: true }
}
// Write/create without acceptEdits mode falls through to check allow rules
}
// 3.5. For read operations, check internal readable paths (project temp dir, session memory, etc.)
// This allows reading agent output files without explicit permission
if (operationType === 'read') {
const internalReadResult = checkReadableInternalPath(resolvedPath, {})
if (internalReadResult.behavior === 'allow') {
return {
allowed: true,
decisionReason: internalReadResult.decisionReason,
}
}
}
// 3.7. For write/create operations to paths OUTSIDE the working directory,
// check the sandbox write allowlist. When the sandbox is enabled, users
// have explicitly configured writable directories (e.g. /tmp/claude/) —
// treat these as additional allowed write directories so redirects/touch/
// mkdir don't prompt unnecessarily. Safety checks (step 2) already ran.
// Paths IN the working directory are intentionally excluded: the sandbox
// allowlist always seeds '.' (cwd, see sandbox-adapter.ts), which would
// bypass the acceptEdits gate at step 3. Step 3 handles those.
if (
operationType !== 'read' &&
!isInWorkingDir &&
isPathInSandboxWriteAllowlist(resolvedPath)
) {
return {
allowed: true,
decisionReason: {
type: 'other',
reason: 'Path is in sandbox write allowlist',
},
}
}
// 4. Check allow rules for the operation type
const allowRule = matchingRuleForInput(
resolvedPath,
context,
permissionType,
'allow',
)
if (allowRule !== null) {
return {
allowed: true,
decisionReason: { type: 'rule', rule: allowRule },
}
}
// 5. Path is not allowed
return { allowed: false }
}
/**
* Validates a glob pattern by checking its base directory.
* Returns the validation result for the base path where the glob would expand.
*/
export function validateGlobPattern(
cleanPath: string,
cwd: string,
toolPermissionContext: ToolPermissionContext,
operationType: FileOperationType,
): ResolvedPathCheckResult {
if (containsPathTraversal(cleanPath)) {
// For patterns with path traversal, resolve the full path
const absolutePath = isAbsolute(cleanPath)
? cleanPath
: resolve(cwd, cleanPath)
const { resolvedPath, isCanonical } = safeResolvePath(
getFsImplementation(),
absolutePath,
)
const result = isPathAllowed(
resolvedPath,
toolPermissionContext,
operationType,
isCanonical ? [resolvedPath] : undefined,
)
return {
allowed: result.allowed,
resolvedPath,
decisionReason: result.decisionReason,
}
}
const basePath = getGlobBaseDirectory(cleanPath)
const absoluteBasePath = isAbsolute(basePath)
? basePath
: resolve(cwd, basePath)
const { resolvedPath, isCanonical } = safeResolvePath(
getFsImplementation(),
absoluteBasePath,
)
const result = isPathAllowed(
resolvedPath,
toolPermissionContext,
operationType,
isCanonical ? [resolvedPath] : undefined,
)
return {
allowed: result.allowed,
resolvedPath,
decisionReason: result.decisionReason,
}
}
const WINDOWS_DRIVE_ROOT_REGEX = /^[A-Za-z]:\/?$/
const WINDOWS_DRIVE_CHILD_REGEX = /^[A-Za-z]:\/[^/]+$/
/**
* Checks if a resolved path is dangerous for removal operations (rm/rmdir).
* Dangerous paths are:
* - Wildcard '*' (removes all files in directory)
* - Any path ending with '/*' or '\*' (e.g., /path/to/dir/*, C:\foo\*)
* - Root directory (/)
* - Home directory (~)
* - Direct children of root (/usr, /tmp, /etc, etc.)
* - Windows drive root (C:\, D:\) and direct children (C:\Windows, C:\Users)
*/
export function isDangerousRemovalPath(resolvedPath: string): boolean {
// Callers pass both slash forms; collapse runs so C:\\Windows (valid in
// PowerShell) doesn't bypass the drive-child check.
const forwardSlashed = resolvedPath.replace(/[\\/]+/g, '/')
if (forwardSlashed === '*' || forwardSlashed.endsWith('/*')) {
return true
}
const normalizedPath =
forwardSlashed === '/' ? forwardSlashed : forwardSlashed.replace(/\/$/, '')
if (normalizedPath === '/') {
return true
}
if (WINDOWS_DRIVE_ROOT_REGEX.test(normalizedPath)) {
return true
}
const normalizedHome = homedir().replace(/[\\/]+/g, '/')
if (normalizedPath === normalizedHome) {
return true
}
// Direct children of root: /usr, /tmp, /etc (but not /usr/local)
const parentDir = dirname(normalizedPath)
if (parentDir === '/') {
return true
}
if (WINDOWS_DRIVE_CHILD_REGEX.test(normalizedPath)) {
return true
}
return false
}
/**
* Validates a file system path, handling tilde expansion and glob patterns.
* Returns whether the path is allowed and the resolved path for error messages.
*/
export function validatePath(
path: string,
cwd: string,
toolPermissionContext: ToolPermissionContext,
operationType: FileOperationType,
): ResolvedPathCheckResult {
// Remove surrounding quotes if present
const cleanPath = expandTilde(path.replace(/^['"]|['"]$/g, ''))
// SECURITY: Block UNC paths that could leak credentials
if (containsVulnerableUncPath(cleanPath)) {
return {
allowed: false,
resolvedPath: cleanPath,
decisionReason: {
type: 'other',
reason: 'UNC network paths require manual approval',
},
}
}
// SECURITY: Reject tilde variants (~user, ~+, ~-, ~N) that expandTilde doesn't handle.
// expandTilde resolves ~ and ~/ to $HOME, but ~root, ~+, ~- etc. are left as literal
// text and resolved as relative paths (e.g., /cwd/~root/.ssh/id_rsa).
// The shell expands these differently (~root → /var/root, ~+ → $PWD, ~- → $OLDPWD),
// creating a TOCTOU gap: we validate /cwd/~root/... but bash reads /var/root/...
// This check is safe from false positives because expandTilde already converted
// ~ and ~/ to absolute paths starting with /, so only unexpanded variants remain.
if (cleanPath.startsWith('~')) {
return {
allowed: false,
resolvedPath: cleanPath,
decisionReason: {
type: 'other',
reason:
'Tilde expansion variants (~user, ~+, ~-) in paths require manual approval',
},
}
}
// SECURITY: Reject paths containing ANY shell expansion syntax ($ or % characters,
// or paths starting with = which triggers Zsh equals expansion)
// - $VAR (Unix/Linux environment variables like $HOME, $PWD)
// - ${VAR} (brace expansion)
// - $(cmd) (command substitution)
// - %VAR% (Windows environment variables like %TEMP%, %USERPROFILE%)
// - Nested combinations like $(echo $HOME)
// - =cmd (Zsh equals expansion, e.g. =rg expands to /usr/bin/rg)
// All of these are preserved as literal strings during validation but expanded
// by the shell during execution, creating a TOCTOU vulnerability
if (
cleanPath.includes('$') ||
cleanPath.includes('%') ||
cleanPath.startsWith('=')
) {
return {
allowed: false,
resolvedPath: cleanPath,
decisionReason: {
type: 'other',
reason: 'Shell expansion syntax in paths requires manual approval',
},
}
}
// SECURITY: Block glob patterns in write/create operations
// Write tools don't expand globs - they use paths literally.
// Allowing globs in write operations could bypass security checks.
// Example: /allowed/dir/*.txt would only validate /allowed/dir,
// but the actual write would use the literal path with the *
if (GLOB_PATTERN_REGEX.test(cleanPath)) {
if (operationType === 'write' || operationType === 'create') {
return {
allowed: false,
resolvedPath: cleanPath,
decisionReason: {
type: 'other',
reason:
'Glob patterns are not allowed in write operations. Please specify an exact file path.',
},
}
}
// For read operations, validate the base directory where the glob would expand
return validateGlobPattern(
cleanPath,
cwd,
toolPermissionContext,
operationType,
)
}
// Resolve path
const absolutePath = isAbsolute(cleanPath)
? cleanPath
: resolve(cwd, cleanPath)
const { resolvedPath, isCanonical } = safeResolvePath(
getFsImplementation(),
absolutePath,
)
const result = isPathAllowed(
resolvedPath,
toolPermissionContext,
operationType,
isCanonical ? [resolvedPath] : undefined,
)
return {
allowed: result.allowed,
resolvedPath,
decisionReason: result.decisionReason,
}
}