/ utils / permissions / pathValidation.ts
pathValidation.ts
  1  import memoize from 'lodash-es/memoize.js'
  2  import { homedir } from 'os'
  3  import { dirname, isAbsolute, resolve } from 'path'
  4  import type { ToolPermissionContext } from '../../Tool.js'
  5  import { getPlatform } from '../../utils/platform.js'
  6  import {
  7    getFsImplementation,
  8    getPathsForPermissionCheck,
  9    safeResolvePath,
 10  } from '../fsOperations.js'
 11  import { containsPathTraversal } from '../path.js'
 12  import { SandboxManager } from '../sandbox/sandbox-adapter.js'
 13  import { containsVulnerableUncPath } from '../shell/readOnlyCommandValidation.js'
 14  import {
 15    checkEditableInternalPath,
 16    checkPathSafetyForAutoEdit,
 17    checkReadableInternalPath,
 18    matchingRuleForInput,
 19    pathInAllowedWorkingPath,
 20    pathInWorkingPath,
 21  } from './filesystem.js'
 22  import type { PermissionDecisionReason } from './PermissionResult.js'
 23  
 24  const MAX_DIRS_TO_LIST = 5
 25  const GLOB_PATTERN_REGEX = /[*?[\]{}]/
 26  
 27  export type FileOperationType = 'read' | 'write' | 'create'
 28  
 29  export type PathCheckResult = {
 30    allowed: boolean
 31    decisionReason?: PermissionDecisionReason
 32  }
 33  
 34  export type ResolvedPathCheckResult = PathCheckResult & {
 35    resolvedPath: string
 36  }
 37  
 38  export function formatDirectoryList(directories: string[]): string {
 39    const dirCount = directories.length
 40  
 41    if (dirCount <= MAX_DIRS_TO_LIST) {
 42      return directories.map(dir => `'${dir}'`).join(', ')
 43    }
 44  
 45    const firstDirs = directories
 46      .slice(0, MAX_DIRS_TO_LIST)
 47      .map(dir => `'${dir}'`)
 48      .join(', ')
 49  
 50    return `${firstDirs}, and ${dirCount - MAX_DIRS_TO_LIST} more`
 51  }
 52  
 53  /**
 54   * Extracts the base directory from a glob pattern for validation.
 55   * For example: "/path/to/*.txt" returns "/path/to"
 56   */
 57  export function getGlobBaseDirectory(path: string): string {
 58    const globMatch = path.match(GLOB_PATTERN_REGEX)
 59    if (!globMatch || globMatch.index === undefined) {
 60      return path
 61    }
 62  
 63    // Get everything before the first glob character
 64    const beforeGlob = path.substring(0, globMatch.index)
 65  
 66    // Find the last directory separator
 67    const lastSepIndex =
 68      getPlatform() === 'windows'
 69        ? Math.max(beforeGlob.lastIndexOf('/'), beforeGlob.lastIndexOf('\\'))
 70        : beforeGlob.lastIndexOf('/')
 71    if (lastSepIndex === -1) return '.'
 72  
 73    return beforeGlob.substring(0, lastSepIndex) || '/'
 74  }
 75  
 76  /**
 77   * Expands tilde (~) at the start of a path to the user's home directory.
 78   * Note: ~username expansion is not supported for security reasons.
 79   */
 80  export function expandTilde(path: string): string {
 81    if (
 82      path === '~' ||
 83      path.startsWith('~/') ||
 84      (process.platform === 'win32' && path.startsWith('~\\'))
 85    ) {
 86      return homedir() + path.slice(1)
 87    }
 88    return path
 89  }
 90  
 91  /**
 92   * Checks if a resolved path is writable according to the sandbox write allowlist.
 93   * When the sandbox is enabled, the user has explicitly configured which directories
 94   * are writable. We treat these as additional allowed write directories for path
 95   * validation purposes, so commands like `echo foo > /tmp/claude/x.txt` don't
 96   * prompt for permission when /tmp/claude/ is already in the sandbox allowlist.
 97   *
 98   * Respects the deny-within-allow list: paths in denyWithinAllow (like
 99   * .claude/settings.json) are still blocked even if their parent is in allowOnly.
100   */
101  export function isPathInSandboxWriteAllowlist(resolvedPath: string): boolean {
102    if (!SandboxManager.isSandboxingEnabled()) {
103      return false
104    }
105    const { allowOnly, denyWithinAllow } = SandboxManager.getFsWriteConfig()
106    // Resolve symlinks on both sides so comparisons are symmetric (matching
107    // pathInAllowedWorkingPath). Without this, an allowlist entry that is a
108    // symlink (e.g. /home/user/proj -> /data/proj) would not match a write to
109    // its resolved target, causing an unnecessary prompt. Over-conservative,
110    // not a security issue. All resolved input representations must be allowed
111    // and none may be denied. Config paths are session-stable, so memoize
112    // their resolution to avoid N × config.length redundant syscalls per
113    // command with N write targets (matching getResolvedWorkingDirPaths).
114    const pathsToCheck = getPathsForPermissionCheck(resolvedPath)
115    const resolvedAllow = allowOnly.flatMap(getResolvedSandboxConfigPath)
116    const resolvedDeny = denyWithinAllow.flatMap(getResolvedSandboxConfigPath)
117    return pathsToCheck.every(p => {
118      for (const denyPath of resolvedDeny) {
119        if (pathInWorkingPath(p, denyPath)) return false
120      }
121      return resolvedAllow.some(allowPath => pathInWorkingPath(p, allowPath))
122    })
123  }
124  
125  // Sandbox config paths are session-stable; memoize their resolved forms to
126  // avoid repeated lstat/realpath syscalls on every write-target check.
127  // Matches the getResolvedWorkingDirPaths pattern in filesystem.ts.
128  const getResolvedSandboxConfigPath = memoize(getPathsForPermissionCheck)
129  
130  /**
131   * Checks if a resolved path is allowed for the given operation type.
132   *
133   * @param precomputedPathsToCheck - Optional cached result of
134   *   `getPathsForPermissionCheck(resolvedPath)`. When `resolvedPath` is the
135   *   output of `realpathSync` (canonical path, all symlinks resolved), this
136   *   is trivially `[resolvedPath]` and passing it here skips 5 redundant
137   *   syscalls per inner check. Do NOT pass this for non-canonical paths
138   *   (nonexistent files, UNC paths, etc.) — parent-directory symlink
139   *   resolution is still required for those.
140   */
141  export function isPathAllowed(
142    resolvedPath: string,
143    context: ToolPermissionContext,
144    operationType: FileOperationType,
145    precomputedPathsToCheck?: readonly string[],
146  ): PathCheckResult {
147    // Determine which permission type to check based on operation
148    const permissionType = operationType === 'read' ? 'read' : 'edit'
149  
150    // 1. Check deny rules first (they take precedence)
151    const denyRule = matchingRuleForInput(
152      resolvedPath,
153      context,
154      permissionType,
155      'deny',
156    )
157    if (denyRule !== null) {
158      return {
159        allowed: false,
160        decisionReason: { type: 'rule', rule: denyRule },
161      }
162    }
163  
164    // 2. For write/create operations, check internal editable paths (plan files, scratchpad, agent memory, job dirs)
165    // This MUST come before checkPathSafetyForAutoEdit since .claude is a dangerous directory
166    // and internal editable paths live under ~/.claude/ — matching the ordering in
167    // checkWritePermissionForTool (filesystem.ts step 1.5)
168    if (operationType !== 'read') {
169      const internalEditResult = checkEditableInternalPath(resolvedPath, {})
170      if (internalEditResult.behavior === 'allow') {
171        return {
172          allowed: true,
173          decisionReason: internalEditResult.decisionReason,
174        }
175      }
176    }
177  
178    // 2.5. For write/create operations, check comprehensive safety validations
179    // This MUST come before checking working directory to prevent bypass via acceptEdits mode
180    // Checks: Windows patterns, Claude config files, dangerous files (on original + symlink paths)
181    if (operationType !== 'read') {
182      const safetyCheck = checkPathSafetyForAutoEdit(
183        resolvedPath,
184        precomputedPathsToCheck,
185      )
186      if (!safetyCheck.safe) {
187        return {
188          allowed: false,
189          decisionReason: {
190            type: 'safetyCheck',
191            reason: safetyCheck.message,
192            classifierApprovable: safetyCheck.classifierApprovable,
193          },
194        }
195      }
196    }
197  
198    // 3. Check if path is in allowed working directory
199    // For write/create operations, require acceptEdits mode to auto-allow
200    // This is consistent with checkWritePermissionForTool in filesystem.ts
201    const isInWorkingDir = pathInAllowedWorkingPath(
202      resolvedPath,
203      context,
204      precomputedPathsToCheck,
205    )
206    if (isInWorkingDir) {
207      if (operationType === 'read' || context.mode === 'acceptEdits') {
208        return { allowed: true }
209      }
210      // Write/create without acceptEdits mode falls through to check allow rules
211    }
212  
213    // 3.5. For read operations, check internal readable paths (project temp dir, session memory, etc.)
214    // This allows reading agent output files without explicit permission
215    if (operationType === 'read') {
216      const internalReadResult = checkReadableInternalPath(resolvedPath, {})
217      if (internalReadResult.behavior === 'allow') {
218        return {
219          allowed: true,
220          decisionReason: internalReadResult.decisionReason,
221        }
222      }
223    }
224  
225    // 3.7. For write/create operations to paths OUTSIDE the working directory,
226    // check the sandbox write allowlist. When the sandbox is enabled, users
227    // have explicitly configured writable directories (e.g. /tmp/claude/) —
228    // treat these as additional allowed write directories so redirects/touch/
229    // mkdir don't prompt unnecessarily. Safety checks (step 2) already ran.
230    // Paths IN the working directory are intentionally excluded: the sandbox
231    // allowlist always seeds '.' (cwd, see sandbox-adapter.ts), which would
232    // bypass the acceptEdits gate at step 3. Step 3 handles those.
233    if (
234      operationType !== 'read' &&
235      !isInWorkingDir &&
236      isPathInSandboxWriteAllowlist(resolvedPath)
237    ) {
238      return {
239        allowed: true,
240        decisionReason: {
241          type: 'other',
242          reason: 'Path is in sandbox write allowlist',
243        },
244      }
245    }
246  
247    // 4. Check allow rules for the operation type
248    const allowRule = matchingRuleForInput(
249      resolvedPath,
250      context,
251      permissionType,
252      'allow',
253    )
254    if (allowRule !== null) {
255      return {
256        allowed: true,
257        decisionReason: { type: 'rule', rule: allowRule },
258      }
259    }
260  
261    // 5. Path is not allowed
262    return { allowed: false }
263  }
264  
265  /**
266   * Validates a glob pattern by checking its base directory.
267   * Returns the validation result for the base path where the glob would expand.
268   */
269  export function validateGlobPattern(
270    cleanPath: string,
271    cwd: string,
272    toolPermissionContext: ToolPermissionContext,
273    operationType: FileOperationType,
274  ): ResolvedPathCheckResult {
275    if (containsPathTraversal(cleanPath)) {
276      // For patterns with path traversal, resolve the full path
277      const absolutePath = isAbsolute(cleanPath)
278        ? cleanPath
279        : resolve(cwd, cleanPath)
280      const { resolvedPath, isCanonical } = safeResolvePath(
281        getFsImplementation(),
282        absolutePath,
283      )
284      const result = isPathAllowed(
285        resolvedPath,
286        toolPermissionContext,
287        operationType,
288        isCanonical ? [resolvedPath] : undefined,
289      )
290      return {
291        allowed: result.allowed,
292        resolvedPath,
293        decisionReason: result.decisionReason,
294      }
295    }
296  
297    const basePath = getGlobBaseDirectory(cleanPath)
298    const absoluteBasePath = isAbsolute(basePath)
299      ? basePath
300      : resolve(cwd, basePath)
301    const { resolvedPath, isCanonical } = safeResolvePath(
302      getFsImplementation(),
303      absoluteBasePath,
304    )
305    const result = isPathAllowed(
306      resolvedPath,
307      toolPermissionContext,
308      operationType,
309      isCanonical ? [resolvedPath] : undefined,
310    )
311    return {
312      allowed: result.allowed,
313      resolvedPath,
314      decisionReason: result.decisionReason,
315    }
316  }
317  
318  const WINDOWS_DRIVE_ROOT_REGEX = /^[A-Za-z]:\/?$/
319  const WINDOWS_DRIVE_CHILD_REGEX = /^[A-Za-z]:\/[^/]+$/
320  
321  /**
322   * Checks if a resolved path is dangerous for removal operations (rm/rmdir).
323   * Dangerous paths are:
324   * - Wildcard '*' (removes all files in directory)
325   * - Any path ending with '/*' or '\*' (e.g., /path/to/dir/*, C:\foo\*)
326   * - Root directory (/)
327   * - Home directory (~)
328   * - Direct children of root (/usr, /tmp, /etc, etc.)
329   * - Windows drive root (C:\, D:\) and direct children (C:\Windows, C:\Users)
330   */
331  export function isDangerousRemovalPath(resolvedPath: string): boolean {
332    // Callers pass both slash forms; collapse runs so C:\\Windows (valid in
333    // PowerShell) doesn't bypass the drive-child check.
334    const forwardSlashed = resolvedPath.replace(/[\\/]+/g, '/')
335  
336    if (forwardSlashed === '*' || forwardSlashed.endsWith('/*')) {
337      return true
338    }
339  
340    const normalizedPath =
341      forwardSlashed === '/' ? forwardSlashed : forwardSlashed.replace(/\/$/, '')
342  
343    if (normalizedPath === '/') {
344      return true
345    }
346  
347    if (WINDOWS_DRIVE_ROOT_REGEX.test(normalizedPath)) {
348      return true
349    }
350  
351    const normalizedHome = homedir().replace(/[\\/]+/g, '/')
352    if (normalizedPath === normalizedHome) {
353      return true
354    }
355  
356    // Direct children of root: /usr, /tmp, /etc (but not /usr/local)
357    const parentDir = dirname(normalizedPath)
358    if (parentDir === '/') {
359      return true
360    }
361  
362    if (WINDOWS_DRIVE_CHILD_REGEX.test(normalizedPath)) {
363      return true
364    }
365  
366    return false
367  }
368  
369  /**
370   * Validates a file system path, handling tilde expansion and glob patterns.
371   * Returns whether the path is allowed and the resolved path for error messages.
372   */
373  export function validatePath(
374    path: string,
375    cwd: string,
376    toolPermissionContext: ToolPermissionContext,
377    operationType: FileOperationType,
378  ): ResolvedPathCheckResult {
379    // Remove surrounding quotes if present
380    const cleanPath = expandTilde(path.replace(/^['"]|['"]$/g, ''))
381  
382    // SECURITY: Block UNC paths that could leak credentials
383    if (containsVulnerableUncPath(cleanPath)) {
384      return {
385        allowed: false,
386        resolvedPath: cleanPath,
387        decisionReason: {
388          type: 'other',
389          reason: 'UNC network paths require manual approval',
390        },
391      }
392    }
393  
394    // SECURITY: Reject tilde variants (~user, ~+, ~-, ~N) that expandTilde doesn't handle.
395    // expandTilde resolves ~ and ~/ to $HOME, but ~root, ~+, ~- etc. are left as literal
396    // text and resolved as relative paths (e.g., /cwd/~root/.ssh/id_rsa).
397    // The shell expands these differently (~root → /var/root, ~+ → $PWD, ~- → $OLDPWD),
398    // creating a TOCTOU gap: we validate /cwd/~root/... but bash reads /var/root/...
399    // This check is safe from false positives because expandTilde already converted
400    // ~ and ~/ to absolute paths starting with /, so only unexpanded variants remain.
401    if (cleanPath.startsWith('~')) {
402      return {
403        allowed: false,
404        resolvedPath: cleanPath,
405        decisionReason: {
406          type: 'other',
407          reason:
408            'Tilde expansion variants (~user, ~+, ~-) in paths require manual approval',
409        },
410      }
411    }
412  
413    // SECURITY: Reject paths containing ANY shell expansion syntax ($ or % characters,
414    // or paths starting with = which triggers Zsh equals expansion)
415    // - $VAR (Unix/Linux environment variables like $HOME, $PWD)
416    // - ${VAR} (brace expansion)
417    // - $(cmd) (command substitution)
418    // - %VAR% (Windows environment variables like %TEMP%, %USERPROFILE%)
419    // - Nested combinations like $(echo $HOME)
420    // - =cmd (Zsh equals expansion, e.g. =rg expands to /usr/bin/rg)
421    // All of these are preserved as literal strings during validation but expanded
422    // by the shell during execution, creating a TOCTOU vulnerability
423    if (
424      cleanPath.includes('$') ||
425      cleanPath.includes('%') ||
426      cleanPath.startsWith('=')
427    ) {
428      return {
429        allowed: false,
430        resolvedPath: cleanPath,
431        decisionReason: {
432          type: 'other',
433          reason: 'Shell expansion syntax in paths requires manual approval',
434        },
435      }
436    }
437  
438    // SECURITY: Block glob patterns in write/create operations
439    // Write tools don't expand globs - they use paths literally.
440    // Allowing globs in write operations could bypass security checks.
441    // Example: /allowed/dir/*.txt would only validate /allowed/dir,
442    // but the actual write would use the literal path with the *
443    if (GLOB_PATTERN_REGEX.test(cleanPath)) {
444      if (operationType === 'write' || operationType === 'create') {
445        return {
446          allowed: false,
447          resolvedPath: cleanPath,
448          decisionReason: {
449            type: 'other',
450            reason:
451              'Glob patterns are not allowed in write operations. Please specify an exact file path.',
452          },
453        }
454      }
455  
456      // For read operations, validate the base directory where the glob would expand
457      return validateGlobPattern(
458        cleanPath,
459        cwd,
460        toolPermissionContext,
461        operationType,
462      )
463    }
464  
465    // Resolve path
466    const absolutePath = isAbsolute(cleanPath)
467      ? cleanPath
468      : resolve(cwd, cleanPath)
469    const { resolvedPath, isCanonical } = safeResolvePath(
470      getFsImplementation(),
471      absolutePath,
472    )
473  
474    const result = isPathAllowed(
475      resolvedPath,
476      toolPermissionContext,
477      operationType,
478      isCanonical ? [resolvedPath] : undefined,
479    )
480    return {
481      allowed: result.allowed,
482      resolvedPath,
483      decisionReason: result.decisionReason,
484    }
485  }