source dump of claude code
at main 485 lines 16 kB view raw
1import memoize from 'lodash-es/memoize.js' 2import { homedir } from 'os' 3import { dirname, isAbsolute, resolve } from 'path' 4import type { ToolPermissionContext } from '../../Tool.js' 5import { getPlatform } from '../../utils/platform.js' 6import { 7 getFsImplementation, 8 getPathsForPermissionCheck, 9 safeResolvePath, 10} from '../fsOperations.js' 11import { containsPathTraversal } from '../path.js' 12import { SandboxManager } from '../sandbox/sandbox-adapter.js' 13import { containsVulnerableUncPath } from '../shell/readOnlyCommandValidation.js' 14import { 15 checkEditableInternalPath, 16 checkPathSafetyForAutoEdit, 17 checkReadableInternalPath, 18 matchingRuleForInput, 19 pathInAllowedWorkingPath, 20 pathInWorkingPath, 21} from './filesystem.js' 22import type { PermissionDecisionReason } from './PermissionResult.js' 23 24const MAX_DIRS_TO_LIST = 5 25const GLOB_PATTERN_REGEX = /[*?[\]{}]/ 26 27export type FileOperationType = 'read' | 'write' | 'create' 28 29export type PathCheckResult = { 30 allowed: boolean 31 decisionReason?: PermissionDecisionReason 32} 33 34export type ResolvedPathCheckResult = PathCheckResult & { 35 resolvedPath: string 36} 37 38export 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 */ 57export 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 */ 80export 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 */ 101export 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. 128const 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 */ 141export 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 */ 269export 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 318const WINDOWS_DRIVE_ROOT_REGEX = /^[A-Za-z]:\/?$/ 319const 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 */ 331export 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 */ 373export 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}