source dump of claude code
at main 1303 lines 44 kB view raw
1import { homedir } from 'os' 2import { isAbsolute, resolve } from 'path' 3import type { z } from 'zod/v4' 4import type { ToolPermissionContext } from '../../Tool.js' 5import type { Redirect, SimpleCommand } from '../../utils/bash/ast.js' 6import { 7 extractOutputRedirections, 8 splitCommand_DEPRECATED, 9} from '../../utils/bash/commands.js' 10import { tryParseShellCommand } from '../../utils/bash/shellQuote.js' 11import { getDirectoryForPath } from '../../utils/path.js' 12import { allWorkingDirectories } from '../../utils/permissions/filesystem.js' 13import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' 14import { createReadRuleSuggestion } from '../../utils/permissions/PermissionUpdate.js' 15import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' 16import { 17 expandTilde, 18 type FileOperationType, 19 formatDirectoryList, 20 isDangerousRemovalPath, 21 validatePath, 22} from '../../utils/permissions/pathValidation.js' 23import type { BashTool } from './BashTool.js' 24import { stripSafeWrappers } from './bashPermissions.js' 25import { sedCommandIsAllowedByAllowlist } from './sedValidation.js' 26 27export type PathCommand = 28 | 'cd' 29 | 'ls' 30 | 'find' 31 | 'mkdir' 32 | 'touch' 33 | 'rm' 34 | 'rmdir' 35 | 'mv' 36 | 'cp' 37 | 'cat' 38 | 'head' 39 | 'tail' 40 | 'sort' 41 | 'uniq' 42 | 'wc' 43 | 'cut' 44 | 'paste' 45 | 'column' 46 | 'tr' 47 | 'file' 48 | 'stat' 49 | 'diff' 50 | 'awk' 51 | 'strings' 52 | 'hexdump' 53 | 'od' 54 | 'base64' 55 | 'nl' 56 | 'grep' 57 | 'rg' 58 | 'sed' 59 | 'git' 60 | 'jq' 61 | 'sha256sum' 62 | 'sha1sum' 63 | 'md5sum' 64 65/** 66 * Checks if an rm/rmdir command targets dangerous paths that should always 67 * require explicit user approval, even if allowlist rules exist. 68 * This prevents catastrophic data loss from commands like `rm -rf /`. 69 */ 70function checkDangerousRemovalPaths( 71 command: 'rm' | 'rmdir', 72 args: string[], 73 cwd: string, 74): PermissionResult { 75 // Extract paths using the existing path extractor 76 const extractor = PATH_EXTRACTORS[command] 77 const paths = extractor(args) 78 79 for (const path of paths) { 80 // Expand tilde and resolve to absolute path 81 // NOTE: We check the path WITHOUT resolving symlinks, because dangerous paths 82 // like /tmp should be caught even though /tmp is a symlink to /private/tmp on macOS 83 const cleanPath = expandTilde(path.replace(/^['"]|['"]$/g, '')) 84 const absolutePath = isAbsolute(cleanPath) 85 ? cleanPath 86 : resolve(cwd, cleanPath) 87 88 // Check if this is a dangerous path (using the non-symlink-resolved path) 89 if (isDangerousRemovalPath(absolutePath)) { 90 return { 91 behavior: 'ask', 92 message: `Dangerous ${command} operation detected: '${absolutePath}'\n\nThis command would remove a critical system directory. This requires explicit approval and cannot be auto-allowed by permission rules.`, 93 decisionReason: { 94 type: 'other', 95 reason: `Dangerous ${command} operation on critical path: ${absolutePath}`, 96 }, 97 // Don't provide suggestions - we don't want to encourage saving dangerous commands 98 suggestions: [], 99 } 100 } 101 } 102 103 // No dangerous paths found 104 return { 105 behavior: 'passthrough', 106 message: `No dangerous removals detected for ${command} command`, 107 } 108} 109 110/** 111 * SECURITY: Extract positional (non-flag) arguments, correctly handling the 112 * POSIX `--` end-of-options delimiter. 113 * 114 * Most commands (rm, cat, touch, etc.) stop parsing options at `--` and treat 115 * ALL subsequent arguments as positional, even if they start with `-`. Naive 116 * `!arg.startsWith('-')` filtering drops these, causing path validation to be 117 * silently skipped for attack payloads like: 118 * 119 * rm -- -/../.claude/settings.local.json 120 * 121 * Here `-/../.claude/settings.local.json` starts with `-` so the naive filter 122 * drops it, validation sees zero paths, returns passthrough, and the file is 123 * deleted without a prompt. With `--` handling, the path IS extracted and 124 * validated (blocked by isClaudeConfigFilePath / pathInAllowedWorkingPath). 125 */ 126function filterOutFlags(args: string[]): string[] { 127 const result: string[] = [] 128 let afterDoubleDash = false 129 for (const arg of args) { 130 if (afterDoubleDash) { 131 result.push(arg) 132 } else if (arg === '--') { 133 afterDoubleDash = true 134 } else if (!arg?.startsWith('-')) { 135 result.push(arg) 136 } 137 } 138 return result 139} 140 141// Helper: Parse grep/rg style commands (pattern then paths) 142function parsePatternCommand( 143 args: string[], 144 flagsWithArgs: Set<string>, 145 defaults: string[] = [], 146): string[] { 147 const paths: string[] = [] 148 let patternFound = false 149 // SECURITY: Track `--` end-of-options delimiter. After `--`, all args are 150 // positional regardless of leading `-`. See filterOutFlags() doc comment. 151 let afterDoubleDash = false 152 153 for (let i = 0; i < args.length; i++) { 154 const arg = args[i] 155 if (arg === undefined || arg === null) continue 156 157 if (!afterDoubleDash && arg === '--') { 158 afterDoubleDash = true 159 continue 160 } 161 162 if (!afterDoubleDash && arg.startsWith('-')) { 163 const flag = arg.split('=')[0] 164 // Pattern flags mark that we've found the pattern 165 if (flag && ['-e', '--regexp', '-f', '--file'].includes(flag)) { 166 patternFound = true 167 } 168 // Skip next arg if flag needs it 169 if (flag && flagsWithArgs.has(flag) && !arg.includes('=')) { 170 i++ 171 } 172 continue 173 } 174 175 // First non-flag is pattern, rest are paths 176 if (!patternFound) { 177 patternFound = true 178 continue 179 } 180 paths.push(arg) 181 } 182 183 return paths.length > 0 ? paths : defaults 184} 185 186/** 187 * Extracts paths from command arguments for different path commands. 188 * Each command has specific logic for how it handles paths and flags. 189 */ 190export const PATH_EXTRACTORS: Record< 191 PathCommand, 192 (args: string[]) => string[] 193> = { 194 // cd: special case - all args form one path 195 cd: args => (args.length === 0 ? [homedir()] : [args.join(' ')]), 196 197 // ls: filter flags, default to current dir 198 ls: args => { 199 const paths = filterOutFlags(args) 200 return paths.length > 0 ? paths : ['.'] 201 }, 202 203 // find: collect paths until hitting a real flag, also check path-taking flags 204 // SECURITY: `find -- -path` makes `-path` a starting point (not a predicate). 205 // GNU find supports `--` to allow search roots starting with `-`. After `--`, 206 // we conservatively collect all remaining args as paths to validate. This 207 // over-includes predicates like `-name foo`, but find is a read-only op and 208 // predicates resolve to paths within cwd (allowed), so no false blocks for 209 // legitimate use. The over-inclusion ensures attack paths like 210 // `find -- -/../../etc` are caught. 211 find: args => { 212 const paths: string[] = [] 213 const pathFlags = new Set([ 214 '-newer', 215 '-anewer', 216 '-cnewer', 217 '-mnewer', 218 '-samefile', 219 '-path', 220 '-wholename', 221 '-ilname', 222 '-lname', 223 '-ipath', 224 '-iwholename', 225 ]) 226 const newerPattern = /^-newer[acmBt][acmtB]$/ 227 let foundNonGlobalFlag = false 228 let afterDoubleDash = false 229 230 for (let i = 0; i < args.length; i++) { 231 const arg = args[i] 232 if (!arg) continue 233 234 if (afterDoubleDash) { 235 paths.push(arg) 236 continue 237 } 238 239 if (arg === '--') { 240 afterDoubleDash = true 241 continue 242 } 243 244 // Handle flags 245 if (arg.startsWith('-')) { 246 // Global options don't stop collection 247 if (['-H', '-L', '-P'].includes(arg)) continue 248 249 // Mark that we've seen a non-global flag 250 foundNonGlobalFlag = true 251 252 // Check if this flag takes a path argument 253 if (pathFlags.has(arg) || newerPattern.test(arg)) { 254 const nextArg = args[i + 1] 255 if (nextArg) { 256 paths.push(nextArg) 257 i++ // Skip the path we just processed 258 } 259 } 260 continue 261 } 262 263 // Only collect non-flag arguments before first non-global flag 264 if (!foundNonGlobalFlag) { 265 paths.push(arg) 266 } 267 } 268 return paths.length > 0 ? paths : ['.'] 269 }, 270 271 // All simple commands: just filter out flags 272 mkdir: filterOutFlags, 273 touch: filterOutFlags, 274 rm: filterOutFlags, 275 rmdir: filterOutFlags, 276 mv: filterOutFlags, 277 cp: filterOutFlags, 278 cat: filterOutFlags, 279 head: filterOutFlags, 280 tail: filterOutFlags, 281 sort: filterOutFlags, 282 uniq: filterOutFlags, 283 wc: filterOutFlags, 284 cut: filterOutFlags, 285 paste: filterOutFlags, 286 column: filterOutFlags, 287 file: filterOutFlags, 288 stat: filterOutFlags, 289 diff: filterOutFlags, 290 awk: filterOutFlags, 291 strings: filterOutFlags, 292 hexdump: filterOutFlags, 293 od: filterOutFlags, 294 base64: filterOutFlags, 295 nl: filterOutFlags, 296 sha256sum: filterOutFlags, 297 sha1sum: filterOutFlags, 298 md5sum: filterOutFlags, 299 300 // tr: special case - skip character sets 301 tr: args => { 302 const hasDelete = args.some( 303 a => 304 a === '-d' || 305 a === '--delete' || 306 (a.startsWith('-') && a.includes('d')), 307 ) 308 const nonFlags = filterOutFlags(args) 309 return nonFlags.slice(hasDelete ? 1 : 2) // Skip SET1 or SET1+SET2 310 }, 311 312 // grep: pattern then paths, defaults to stdin 313 grep: args => { 314 const flags = new Set([ 315 '-e', 316 '--regexp', 317 '-f', 318 '--file', 319 '--exclude', 320 '--include', 321 '--exclude-dir', 322 '--include-dir', 323 '-m', 324 '--max-count', 325 '-A', 326 '--after-context', 327 '-B', 328 '--before-context', 329 '-C', 330 '--context', 331 ]) 332 const paths = parsePatternCommand(args, flags) 333 // Special: if -r/-R flag present and no paths, use current dir 334 if ( 335 paths.length === 0 && 336 args.some(a => ['-r', '-R', '--recursive'].includes(a)) 337 ) { 338 return ['.'] 339 } 340 return paths 341 }, 342 343 // rg: pattern then paths, defaults to current dir 344 rg: args => { 345 const flags = new Set([ 346 '-e', 347 '--regexp', 348 '-f', 349 '--file', 350 '-t', 351 '--type', 352 '-T', 353 '--type-not', 354 '-g', 355 '--glob', 356 '-m', 357 '--max-count', 358 '--max-depth', 359 '-r', 360 '--replace', 361 '-A', 362 '--after-context', 363 '-B', 364 '--before-context', 365 '-C', 366 '--context', 367 ]) 368 return parsePatternCommand(args, flags, ['.']) 369 }, 370 371 // sed: processes files in-place or reads from stdin 372 sed: args => { 373 const paths: string[] = [] 374 let skipNext = false 375 let scriptFound = false 376 // SECURITY: Track `--` end-of-options delimiter. After `--`, all args are 377 // positional regardless of leading `-`. See filterOutFlags() doc comment. 378 let afterDoubleDash = false 379 380 for (let i = 0; i < args.length; i++) { 381 if (skipNext) { 382 skipNext = false 383 continue 384 } 385 386 const arg = args[i] 387 if (!arg) continue 388 389 if (!afterDoubleDash && arg === '--') { 390 afterDoubleDash = true 391 continue 392 } 393 394 // Handle flags (only before `--`) 395 if (!afterDoubleDash && arg.startsWith('-')) { 396 // -f flag: next arg is a script file that needs validation 397 if (['-f', '--file'].includes(arg)) { 398 const scriptFile = args[i + 1] 399 if (scriptFile) { 400 paths.push(scriptFile) // Add script file to paths for validation 401 skipNext = true 402 } 403 scriptFound = true 404 } 405 // -e flag: next arg is expression, not a file 406 else if (['-e', '--expression'].includes(arg)) { 407 skipNext = true 408 scriptFound = true 409 } 410 // Combined flags like -ie or -nf 411 else if (arg.includes('e') || arg.includes('f')) { 412 scriptFound = true 413 } 414 continue 415 } 416 417 // First non-flag is the script (if not already found via -e/-f) 418 if (!scriptFound) { 419 scriptFound = true 420 continue 421 } 422 423 // Rest are file paths 424 paths.push(arg) 425 } 426 427 return paths 428 }, 429 430 // jq: filter then file paths (similar to grep) 431 // The jq command structure is: jq [flags] filter [files...] 432 // If no files are provided, jq reads from stdin 433 jq: args => { 434 const paths: string[] = [] 435 const flagsWithArgs = new Set([ 436 '-e', 437 '--expression', 438 '-f', 439 '--from-file', 440 '--arg', 441 '--argjson', 442 '--slurpfile', 443 '--rawfile', 444 '--args', 445 '--jsonargs', 446 '-L', 447 '--library-path', 448 '--indent', 449 '--tab', 450 ]) 451 let filterFound = false 452 // SECURITY: Track `--` end-of-options delimiter. After `--`, all args are 453 // positional regardless of leading `-`. See filterOutFlags() doc comment. 454 let afterDoubleDash = false 455 456 for (let i = 0; i < args.length; i++) { 457 const arg = args[i] 458 if (arg === undefined || arg === null) continue 459 460 if (!afterDoubleDash && arg === '--') { 461 afterDoubleDash = true 462 continue 463 } 464 465 if (!afterDoubleDash && arg.startsWith('-')) { 466 const flag = arg.split('=')[0] 467 // Pattern flags mark that we've found the filter 468 if (flag && ['-e', '--expression'].includes(flag)) { 469 filterFound = true 470 } 471 // Skip next arg if flag needs it 472 if (flag && flagsWithArgs.has(flag) && !arg.includes('=')) { 473 i++ 474 } 475 continue 476 } 477 478 // First non-flag is filter, rest are file paths 479 if (!filterFound) { 480 filterFound = true 481 continue 482 } 483 paths.push(arg) 484 } 485 486 // If no file paths, jq reads from stdin (no paths to validate) 487 return paths 488 }, 489 490 // git: handle subcommands that access arbitrary files outside the repository 491 git: args => { 492 // git diff --no-index is special - it explicitly compares files outside git's control 493 // This flag allows git diff to compare any two files on the filesystem, not just 494 // files within the repository, which is why it needs path validation 495 if (args.length >= 1 && args[0] === 'diff') { 496 if (args.includes('--no-index')) { 497 // SECURITY: git diff --no-index accepts `--` before file paths. 498 // Use filterOutFlags which handles `--` correctly instead of naive 499 // startsWith('-') filtering, to catch paths like `-/../etc/passwd`. 500 const filePaths = filterOutFlags(args.slice(1)) 501 return filePaths.slice(0, 2) // git diff --no-index expects exactly 2 paths 502 } 503 } 504 // Other git commands (add, rm, mv, show, etc.) operate within the repository context 505 // and are already constrained by git's own security model, so they don't need 506 // additional path validation 507 return [] 508 }, 509} 510 511const SUPPORTED_PATH_COMMANDS = Object.keys(PATH_EXTRACTORS) as PathCommand[] 512 513const ACTION_VERBS: Record<PathCommand, string> = { 514 cd: 'change directories to', 515 ls: 'list files in', 516 find: 'search files in', 517 mkdir: 'create directories in', 518 touch: 'create or modify files in', 519 rm: 'remove files from', 520 rmdir: 'remove directories from', 521 mv: 'move files to/from', 522 cp: 'copy files to/from', 523 cat: 'concatenate files from', 524 head: 'read the beginning of files from', 525 tail: 'read the end of files from', 526 sort: 'sort contents of files from', 527 uniq: 'filter duplicate lines from files in', 528 wc: 'count lines/words/bytes in files from', 529 cut: 'extract columns from files in', 530 paste: 'merge files from', 531 column: 'format files from', 532 tr: 'transform text from files in', 533 file: 'examine file types in', 534 stat: 'read file stats from', 535 diff: 'compare files from', 536 awk: 'process text from files in', 537 strings: 'extract strings from files in', 538 hexdump: 'display hex dump of files from', 539 od: 'display octal dump of files from', 540 base64: 'encode/decode files from', 541 nl: 'number lines in files from', 542 grep: 'search for patterns in files from', 543 rg: 'search for patterns in files from', 544 sed: 'edit files in', 545 git: 'access files with git from', 546 jq: 'process JSON from files in', 547 sha256sum: 'compute SHA-256 checksums for files in', 548 sha1sum: 'compute SHA-1 checksums for files in', 549 md5sum: 'compute MD5 checksums for files in', 550} 551 552export const COMMAND_OPERATION_TYPE: Record<PathCommand, FileOperationType> = { 553 cd: 'read', 554 ls: 'read', 555 find: 'read', 556 mkdir: 'create', 557 touch: 'create', 558 rm: 'write', 559 rmdir: 'write', 560 mv: 'write', 561 cp: 'write', 562 cat: 'read', 563 head: 'read', 564 tail: 'read', 565 sort: 'read', 566 uniq: 'read', 567 wc: 'read', 568 cut: 'read', 569 paste: 'read', 570 column: 'read', 571 tr: 'read', 572 file: 'read', 573 stat: 'read', 574 diff: 'read', 575 awk: 'read', 576 strings: 'read', 577 hexdump: 'read', 578 od: 'read', 579 base64: 'read', 580 nl: 'read', 581 grep: 'read', 582 rg: 'read', 583 sed: 'write', 584 git: 'read', 585 jq: 'read', 586 sha256sum: 'read', 587 sha1sum: 'read', 588 md5sum: 'read', 589} 590 591/** 592 * Command-specific validators that run before path validation. 593 * Returns true if the command is valid, false if it should be rejected. 594 * Used to block commands with flags that could bypass path validation. 595 */ 596const COMMAND_VALIDATOR: Partial< 597 Record<PathCommand, (args: string[]) => boolean> 598> = { 599 mv: (args: string[]) => !args.some(arg => arg?.startsWith('-')), 600 cp: (args: string[]) => !args.some(arg => arg?.startsWith('-')), 601} 602 603function validateCommandPaths( 604 command: PathCommand, 605 args: string[], 606 cwd: string, 607 toolPermissionContext: ToolPermissionContext, 608 compoundCommandHasCd?: boolean, 609 operationTypeOverride?: FileOperationType, 610): PermissionResult { 611 const extractor = PATH_EXTRACTORS[command] 612 const paths = extractor(args) 613 const operationType = operationTypeOverride ?? COMMAND_OPERATION_TYPE[command] 614 615 // SECURITY: Check command-specific validators (e.g., to block flags that could bypass path validation) 616 // Some commands like mv/cp have flags (--target-directory=PATH) that can bypass path extraction, 617 // so we block ALL flags for these commands to ensure security. 618 const validator = COMMAND_VALIDATOR[command] 619 if (validator && !validator(args)) { 620 return { 621 behavior: 'ask', 622 message: `${command} with flags requires manual approval to ensure path safety. For security, Claude Code cannot automatically validate ${command} commands that use flags, as some flags like --target-directory=PATH can bypass path validation.`, 623 decisionReason: { 624 type: 'other', 625 reason: `${command} command with flags requires manual approval`, 626 }, 627 } 628 } 629 630 // SECURITY: Block write operations in compound commands containing 'cd' 631 // This prevents bypassing path safety checks via directory changes before operations. 632 // Example attack: cd .claude/ && mv test.txt settings.json 633 // This would bypass the check for .claude/settings.json because paths are resolved 634 // relative to the original CWD, not accounting for the cd's effect. 635 // 636 // ALTERNATIVE APPROACH: Instead of blocking all writes with cd, we could track the 637 // effective CWD through the command chain (e.g., after "cd .claude/", subsequent 638 // commands would be validated with CWD=".claude/"). This would be more permissive 639 // but requires careful handling of: 640 // - Relative paths (cd ../foo) 641 // - Special cd targets (cd ~, cd -, cd with no args) 642 // - Multiple cd commands in sequence 643 // - Error cases where cd target cannot be determined 644 // For now, we take the conservative approach of requiring manual approval. 645 if (compoundCommandHasCd && operationType !== 'read') { 646 return { 647 behavior: 'ask', 648 message: `Commands that change directories and perform write operations require explicit approval to ensure paths are evaluated correctly. For security, Claude Code cannot automatically determine the final working directory when 'cd' is used in compound commands.`, 649 decisionReason: { 650 type: 'other', 651 reason: 652 'Compound command contains cd with write operation - manual approval required to prevent path resolution bypass', 653 }, 654 } 655 } 656 657 for (const path of paths) { 658 const { allowed, resolvedPath, decisionReason } = validatePath( 659 path, 660 cwd, 661 toolPermissionContext, 662 operationType, 663 ) 664 665 if (!allowed) { 666 const workingDirs = Array.from( 667 allWorkingDirectories(toolPermissionContext), 668 ) 669 const dirListStr = formatDirectoryList(workingDirs) 670 671 // Use security check's custom reason if available (type: 'other' or 'safetyCheck') 672 // Otherwise use the standard "was blocked" message 673 const message = 674 decisionReason?.type === 'other' || 675 decisionReason?.type === 'safetyCheck' 676 ? decisionReason.reason 677 : `${command} in '${resolvedPath}' was blocked. For security, Claude Code may only ${ACTION_VERBS[command]} the allowed working directories for this session: ${dirListStr}.` 678 679 if (decisionReason?.type === 'rule') { 680 return { 681 behavior: 'deny', 682 message, 683 decisionReason, 684 } 685 } 686 687 return { 688 behavior: 'ask', 689 message, 690 blockedPath: resolvedPath, 691 decisionReason, 692 } 693 } 694 } 695 696 // All paths are valid - return passthrough 697 return { 698 behavior: 'passthrough', 699 message: `Path validation passed for ${command} command`, 700 } 701} 702 703export function createPathChecker( 704 command: PathCommand, 705 operationTypeOverride?: FileOperationType, 706) { 707 return ( 708 args: string[], 709 cwd: string, 710 context: ToolPermissionContext, 711 compoundCommandHasCd?: boolean, 712 ): PermissionResult => { 713 // First check normal path validation (which includes explicit deny rules) 714 const result = validateCommandPaths( 715 command, 716 args, 717 cwd, 718 context, 719 compoundCommandHasCd, 720 operationTypeOverride, 721 ) 722 723 // If explicitly denied, respect that (don't override with dangerous path message) 724 if (result.behavior === 'deny') { 725 return result 726 } 727 728 // Check for dangerous removal paths AFTER explicit deny rules but BEFORE other results 729 // This ensures the check runs even if the user has allowlist rules or if glob patterns 730 // were rejected, but respects explicit deny rules. Dangerous patterns get a specific 731 // error message that overrides generic glob pattern rejection messages. 732 if (command === 'rm' || command === 'rmdir') { 733 const dangerousPathResult = checkDangerousRemovalPaths(command, args, cwd) 734 if (dangerousPathResult.behavior !== 'passthrough') { 735 return dangerousPathResult 736 } 737 } 738 739 // If it's a passthrough, return it directly 740 if (result.behavior === 'passthrough') { 741 return result 742 } 743 744 // If it's an ask decision, add suggestions based on the operation type 745 if (result.behavior === 'ask') { 746 const operationType = 747 operationTypeOverride ?? COMMAND_OPERATION_TYPE[command] 748 const suggestions: PermissionUpdate[] = [] 749 750 // Only suggest adding directory/rules if we have a blocked path 751 if (result.blockedPath) { 752 if (operationType === 'read') { 753 // For read operations, suggest a Read rule for the directory (only if it exists) 754 const dirPath = getDirectoryForPath(result.blockedPath) 755 const suggestion = createReadRuleSuggestion(dirPath, 'session') 756 if (suggestion) { 757 suggestions.push(suggestion) 758 } 759 } else { 760 // For write/create operations, suggest adding the directory 761 suggestions.push({ 762 type: 'addDirectories', 763 directories: [getDirectoryForPath(result.blockedPath)], 764 destination: 'session', 765 }) 766 } 767 } 768 769 // For write operations, also suggest enabling accept-edits mode 770 if (operationType === 'write' || operationType === 'create') { 771 suggestions.push({ 772 type: 'setMode', 773 mode: 'acceptEdits', 774 destination: 'session', 775 }) 776 } 777 778 result.suggestions = suggestions 779 } 780 781 // Return the decision directly 782 return result 783 } 784} 785 786/** 787 * Parses command arguments using shell-quote, converting glob objects to strings. 788 * This is necessary because shell-quote parses patterns like *.txt as glob objects, 789 * but we need them as strings for path validation. 790 */ 791function parseCommandArguments(cmd: string): string[] { 792 const parseResult = tryParseShellCommand(cmd, env => `$${env}`) 793 if (!parseResult.success) { 794 // Malformed shell syntax, return empty array 795 return [] 796 } 797 const parsed = parseResult.tokens 798 const extractedArgs: string[] = [] 799 800 for (const arg of parsed) { 801 if (typeof arg === 'string') { 802 // Include empty strings - they're valid arguments (e.g., grep "" /tmp/t) 803 extractedArgs.push(arg) 804 } else if ( 805 typeof arg === 'object' && 806 arg !== null && 807 'op' in arg && 808 arg.op === 'glob' && 809 'pattern' in arg 810 ) { 811 // shell-quote parses glob patterns as objects, but we need them as strings for validation 812 extractedArgs.push(String(arg.pattern)) 813 } 814 } 815 816 return extractedArgs 817} 818 819/** 820 * Validates a single command for path constraints and shell safety. 821 * 822 * This function: 823 * 1. Parses the command arguments 824 * 2. Checks if it's a path command (cd, ls, find) 825 * 3. Validates for shell injection patterns 826 * 4. Validates all paths are within allowed directories 827 * 828 * @param cmd - The command string to validate 829 * @param cwd - Current working directory 830 * @param toolPermissionContext - Context containing allowed directories 831 * @param compoundCommandHasCd - Whether the full compound command contains a cd 832 * @returns PermissionResult - 'passthrough' if not a path command, otherwise validation result 833 */ 834function validateSinglePathCommand( 835 cmd: string, 836 cwd: string, 837 toolPermissionContext: ToolPermissionContext, 838 compoundCommandHasCd?: boolean, 839): PermissionResult { 840 // SECURITY: Strip wrapper commands (timeout, nice, nohup, time) before extracting 841 // the base command. Without this, dangerous commands wrapped with these utilities 842 // would bypass path validation since the wrapper command (e.g., 'timeout') would 843 // be checked instead of the actual command (e.g., 'rm'). 844 // Example: 'timeout 10 rm -rf /' would otherwise see 'timeout' as the base command. 845 const strippedCmd = stripSafeWrappers(cmd) 846 847 // Parse command into arguments, handling quotes and globs 848 const extractedArgs = parseCommandArguments(strippedCmd) 849 if (extractedArgs.length === 0) { 850 return { 851 behavior: 'passthrough', 852 message: 'Empty command - no paths to validate', 853 } 854 } 855 856 // Check if this is a path command we need to validate 857 const [baseCmd, ...args] = extractedArgs 858 if (!baseCmd || !SUPPORTED_PATH_COMMANDS.includes(baseCmd as PathCommand)) { 859 return { 860 behavior: 'passthrough', 861 message: `Command '${baseCmd}' is not a path-restricted command`, 862 } 863 } 864 865 // For read-only sed commands (e.g., sed -n '1,10p' file.txt), 866 // validate file paths as read operations instead of write operations. 867 // sed is normally classified as 'write' for path validation, but when the 868 // command is purely reading (line printing with -n), file args are read-only. 869 const operationTypeOverride = 870 baseCmd === 'sed' && sedCommandIsAllowedByAllowlist(strippedCmd) 871 ? ('read' as FileOperationType) 872 : undefined 873 874 // Validate all paths are within allowed directories 875 const pathChecker = createPathChecker( 876 baseCmd as PathCommand, 877 operationTypeOverride, 878 ) 879 return pathChecker(args, cwd, toolPermissionContext, compoundCommandHasCd) 880} 881 882/** 883 * Like validateSinglePathCommand but operates on AST-derived argv directly 884 * instead of re-parsing the command string with shell-quote. Avoids the 885 * shell-quote single-quote backslash bug that causes parseCommandArguments 886 * to silently return [] and skip path validation. 887 */ 888function validateSinglePathCommandArgv( 889 cmd: SimpleCommand, 890 cwd: string, 891 toolPermissionContext: ToolPermissionContext, 892 compoundCommandHasCd?: boolean, 893): PermissionResult { 894 const argv = stripWrappersFromArgv(cmd.argv) 895 if (argv.length === 0) { 896 return { 897 behavior: 'passthrough', 898 message: 'Empty command - no paths to validate', 899 } 900 } 901 const [baseCmd, ...args] = argv 902 if (!baseCmd || !SUPPORTED_PATH_COMMANDS.includes(baseCmd as PathCommand)) { 903 return { 904 behavior: 'passthrough', 905 message: `Command '${baseCmd}' is not a path-restricted command`, 906 } 907 } 908 // sed read-only override: use .text for the allowlist check since 909 // sedCommandIsAllowedByAllowlist takes a string. argv is already 910 // wrapper-stripped but .text is raw tree-sitter span (includes 911 // `timeout 5 ` prefix), so strip here too. 912 const operationTypeOverride = 913 baseCmd === 'sed' && 914 sedCommandIsAllowedByAllowlist(stripSafeWrappers(cmd.text)) 915 ? ('read' as FileOperationType) 916 : undefined 917 const pathChecker = createPathChecker( 918 baseCmd as PathCommand, 919 operationTypeOverride, 920 ) 921 return pathChecker(args, cwd, toolPermissionContext, compoundCommandHasCd) 922} 923 924function validateOutputRedirections( 925 redirections: Array<{ target: string; operator: '>' | '>>' }>, 926 cwd: string, 927 toolPermissionContext: ToolPermissionContext, 928 compoundCommandHasCd?: boolean, 929): PermissionResult { 930 // SECURITY: Block output redirections in compound commands containing 'cd' 931 // This prevents bypassing path safety checks via directory changes before redirections. 932 // Example attack: cd .claude/ && echo "malicious" > settings.json 933 // The redirection target would be validated relative to the original CWD, but the 934 // actual write happens in the changed directory after 'cd' executes. 935 if (compoundCommandHasCd && redirections.length > 0) { 936 return { 937 behavior: 'ask', 938 message: `Commands that change directories and write via output redirection require explicit approval to ensure paths are evaluated correctly. For security, Claude Code cannot automatically determine the final working directory when 'cd' is used in compound commands.`, 939 decisionReason: { 940 type: 'other', 941 reason: 942 'Compound command contains cd with output redirection - manual approval required to prevent path resolution bypass', 943 }, 944 } 945 } 946 for (const { target } of redirections) { 947 // /dev/null is always safe - it discards output 948 if (target === '/dev/null') { 949 continue 950 } 951 const { allowed, resolvedPath, decisionReason } = validatePath( 952 target, 953 cwd, 954 toolPermissionContext, 955 'create', // Treat > and >> as create operations 956 ) 957 958 if (!allowed) { 959 const workingDirs = Array.from( 960 allWorkingDirectories(toolPermissionContext), 961 ) 962 const dirListStr = formatDirectoryList(workingDirs) 963 964 // Use security check's custom reason if available (type: 'other' or 'safetyCheck') 965 // Otherwise use the standard message for deny rules or working directory restrictions 966 const message = 967 decisionReason?.type === 'other' || 968 decisionReason?.type === 'safetyCheck' 969 ? decisionReason.reason 970 : decisionReason?.type === 'rule' 971 ? `Output redirection to '${resolvedPath}' was blocked by a deny rule.` 972 : `Output redirection to '${resolvedPath}' was blocked. For security, Claude Code may only write to files in the allowed working directories for this session: ${dirListStr}.` 973 974 // If denied by a deny rule, return 'deny' behavior 975 if (decisionReason?.type === 'rule') { 976 return { 977 behavior: 'deny', 978 message, 979 decisionReason, 980 } 981 } 982 983 return { 984 behavior: 'ask', 985 message, 986 blockedPath: resolvedPath, 987 decisionReason, 988 suggestions: [ 989 { 990 type: 'addDirectories', 991 directories: [getDirectoryForPath(resolvedPath)], 992 destination: 'session', 993 }, 994 ], 995 } 996 } 997 } 998 999 return { 1000 behavior: 'passthrough', 1001 message: 'No unsafe redirections found', 1002 } 1003} 1004 1005/** 1006 * Checks path constraints for commands that access the filesystem (cd, ls, find). 1007 * Also validates output redirections to ensure they're within allowed directories. 1008 * 1009 * @returns 1010 * - 'ask' if any path command or redirection tries to access outside allowed directories 1011 * - 'passthrough' if no path commands were found or if all are within allowed directories 1012 */ 1013export function checkPathConstraints( 1014 input: z.infer<typeof BashTool.inputSchema>, 1015 cwd: string, 1016 toolPermissionContext: ToolPermissionContext, 1017 compoundCommandHasCd?: boolean, 1018 astRedirects?: Redirect[], 1019 astCommands?: SimpleCommand[], 1020): PermissionResult { 1021 // SECURITY: Process substitution >(cmd) can execute commands that write to files 1022 // without those files appearing as redirect targets. For example: 1023 // echo secret > >(tee .git/config) 1024 // The tee command writes to .git/config but it's not detected as a redirect. 1025 // Require explicit approval for any command containing process substitution. 1026 // Skip on AST path — process_substitution is in DANGEROUS_TYPES and 1027 // already returned too-complex before reaching here. 1028 if (!astCommands && />>\s*>\s*\(|>\s*>\s*\(|<\s*\(/.test(input.command)) { 1029 return { 1030 behavior: 'ask', 1031 message: 1032 'Process substitution (>(...) or <(...)) can execute arbitrary commands and requires manual approval', 1033 decisionReason: { 1034 type: 'other', 1035 reason: 'Process substitution requires manual approval', 1036 }, 1037 } 1038 } 1039 1040 // SECURITY: When AST-derived redirects are available, use them directly 1041 // instead of re-parsing with shell-quote. shell-quote has a known 1042 // single-quote backslash bug that silently merges redirect operators into 1043 // garbled tokens on a successful parse (not a parse failure, so the 1044 // fail-closed guard doesn't help). The AST already resolved targets 1045 // correctly and checkSemantics validated them. 1046 const { redirections, hasDangerousRedirection } = astRedirects 1047 ? astRedirectsToOutputRedirections(astRedirects) 1048 : extractOutputRedirections(input.command) 1049 1050 // SECURITY: If we found a redirection operator with a target containing shell expansion 1051 // syntax ($VAR or %VAR%), require manual approval since the target can't be safely validated. 1052 if (hasDangerousRedirection) { 1053 return { 1054 behavior: 'ask', 1055 message: 'Shell expansion syntax in paths requires manual approval', 1056 decisionReason: { 1057 type: 'other', 1058 reason: 'Shell expansion syntax in paths requires manual approval', 1059 }, 1060 } 1061 } 1062 const redirectionResult = validateOutputRedirections( 1063 redirections, 1064 cwd, 1065 toolPermissionContext, 1066 compoundCommandHasCd, 1067 ) 1068 if (redirectionResult.behavior !== 'passthrough') { 1069 return redirectionResult 1070 } 1071 1072 // SECURITY: When AST-derived commands are available, iterate them with 1073 // pre-parsed argv instead of re-parsing via splitCommand_DEPRECATED + shell-quote. 1074 // shell-quote has a single-quote backslash bug that causes 1075 // parseCommandArguments to silently return [] and skip path validation 1076 // (isDangerousRemovalPath etc). The AST already resolved argv correctly. 1077 if (astCommands) { 1078 for (const cmd of astCommands) { 1079 const result = validateSinglePathCommandArgv( 1080 cmd, 1081 cwd, 1082 toolPermissionContext, 1083 compoundCommandHasCd, 1084 ) 1085 if (result.behavior === 'ask' || result.behavior === 'deny') { 1086 return result 1087 } 1088 } 1089 } else { 1090 const commands = splitCommand_DEPRECATED(input.command) 1091 for (const cmd of commands) { 1092 const result = validateSinglePathCommand( 1093 cmd, 1094 cwd, 1095 toolPermissionContext, 1096 compoundCommandHasCd, 1097 ) 1098 if (result.behavior === 'ask' || result.behavior === 'deny') { 1099 return result 1100 } 1101 } 1102 } 1103 1104 // Always return passthrough to let other permission checks handle the command 1105 return { 1106 behavior: 'passthrough', 1107 message: 'All path commands validated successfully', 1108 } 1109} 1110 1111/** 1112 * Convert AST-derived Redirect[] to the format expected by 1113 * validateOutputRedirections. Filters to output-only redirects (excluding 1114 * fd duplications like 2>&1) and maps operators to '>' | '>>'. 1115 */ 1116function astRedirectsToOutputRedirections(redirects: Redirect[]): { 1117 redirections: Array<{ target: string; operator: '>' | '>>' }> 1118 hasDangerousRedirection: boolean 1119} { 1120 const redirections: Array<{ target: string; operator: '>' | '>>' }> = [] 1121 for (const r of redirects) { 1122 switch (r.op) { 1123 case '>': 1124 case '>|': 1125 case '&>': 1126 redirections.push({ target: r.target, operator: '>' }) 1127 break 1128 case '>>': 1129 case '&>>': 1130 redirections.push({ target: r.target, operator: '>>' }) 1131 break 1132 case '>&': 1133 // >&N (digits only) is fd duplication (e.g. 2>&1, >&10), not a file 1134 // write. >&file is the deprecated form of &>file (redirect to file). 1135 if (!/^\d+$/.test(r.target)) { 1136 redirections.push({ target: r.target, operator: '>' }) 1137 } 1138 break 1139 case '<': 1140 case '<<': 1141 case '<&': 1142 case '<<<': 1143 // input redirects — skip 1144 break 1145 } 1146 } 1147 // AST targets are fully resolved (no shell expansion) — checkSemantics 1148 // already validated them. No dangerous redirections are possible. 1149 return { redirections, hasDangerousRedirection: false } 1150} 1151 1152// ─────────────────────────────────────────────────────────────────────────── 1153// Argv-level safe-wrapper stripping (timeout, nice, stdbuf, env, time, nohup) 1154// 1155// This is the CANONICAL stripWrappersFromArgv. bashPermissions.ts still 1156// exports an older narrower copy (timeout/nice-n-N only) that is DEAD CODE 1157// — no prod consumer — but CANNOT be removed: bashPermissions.ts is right 1158// at Bun's feature() DCE complexity threshold, and deleting ~80 lines from 1159// that module silently breaks feature('BASH_CLASSIFIER') evaluation (drops 1160// every pendingClassifierCheck spread). Verified in PR #21503 round 3: 1161// baseline classifier tests 30/30 pass, after deletion 22/30 fail. See 1162// team memory: bun-feature-dce-cliff.md. Hit 3× in PR #21075 + twice in 1163// #21503. The expanded version lives here (the only prod consumer) instead. 1164// 1165// KEEP IN SYNC with: 1166// - SAFE_WRAPPER_PATTERNS in bashPermissions.ts (text-based stripSafeWrappers) 1167// - the wrapper-stripping loop in checkSemantics (src/utils/bash/ast.ts ~1860) 1168// If you add a wrapper in either, add it here too. Asymmetry means 1169// checkSemantics exposes the wrapped command to semantic checks but path 1170// validation sees the wrapper name → passthrough → wrapped paths never 1171// validated (PR #21503 review comment 2907319120). 1172// ─────────────────────────────────────────────────────────────────────────── 1173 1174// SECURITY: allowlist for timeout flag VALUES (signals are TERM/KILL/9, 1175// durations are 5/5s/10.5). Rejects $ ( ) ` | ; & and newlines that 1176// previously matched via [^ \t]+ — `timeout -k$(id) 10 ls` must NOT strip. 1177const TIMEOUT_FLAG_VALUE_RE = /^[A-Za-z0-9_.+-]+$/ 1178 1179/** 1180 * Parse timeout's GNU flags (long + short, fused + space-separated) and 1181 * return the argv index of the DURATION token, or -1 if flags are unparseable. 1182 */ 1183function skipTimeoutFlags(a: readonly string[]): number { 1184 let i = 1 1185 while (i < a.length) { 1186 const arg = a[i]! 1187 const next = a[i + 1] 1188 if ( 1189 arg === '--foreground' || 1190 arg === '--preserve-status' || 1191 arg === '--verbose' 1192 ) 1193 i++ 1194 else if (/^--(?:kill-after|signal)=[A-Za-z0-9_.+-]+$/.test(arg)) i++ 1195 else if ( 1196 (arg === '--kill-after' || arg === '--signal') && 1197 next && 1198 TIMEOUT_FLAG_VALUE_RE.test(next) 1199 ) 1200 i += 2 1201 else if (arg === '--') { 1202 i++ 1203 break 1204 } // end-of-options marker 1205 else if (arg.startsWith('--')) return -1 1206 else if (arg === '-v') i++ 1207 else if ( 1208 (arg === '-k' || arg === '-s') && 1209 next && 1210 TIMEOUT_FLAG_VALUE_RE.test(next) 1211 ) 1212 i += 2 1213 else if (/^-[ks][A-Za-z0-9_.+-]+$/.test(arg)) i++ 1214 else if (arg.startsWith('-')) return -1 1215 else break 1216 } 1217 return i 1218} 1219 1220/** 1221 * Parse stdbuf's flags (-i/-o/-e in fused/space-separated/long-= forms). 1222 * Returns argv index of wrapped COMMAND, or -1 if unparseable or no flags 1223 * consumed (stdbuf without flags is inert). Mirrors checkSemantics (ast.ts). 1224 */ 1225function skipStdbufFlags(a: readonly string[]): number { 1226 let i = 1 1227 while (i < a.length) { 1228 const arg = a[i]! 1229 if (/^-[ioe]$/.test(arg) && a[i + 1]) i += 2 1230 else if (/^-[ioe]./.test(arg)) i++ 1231 else if (/^--(input|output|error)=/.test(arg)) i++ 1232 else if (arg.startsWith('-')) 1233 return -1 // unknown flag: fail closed 1234 else break 1235 } 1236 return i > 1 && i < a.length ? i : -1 1237} 1238 1239/** 1240 * Parse env's VAR=val and safe flags (-i/-0/-v/-u NAME). Returns argv index 1241 * of wrapped COMMAND, or -1 if unparseable/no wrapped cmd. Rejects -S (argv 1242 * splitter), -C/-P (altwd/altpath). Mirrors checkSemantics (ast.ts). 1243 */ 1244function skipEnvFlags(a: readonly string[]): number { 1245 let i = 1 1246 while (i < a.length) { 1247 const arg = a[i]! 1248 if (arg.includes('=') && !arg.startsWith('-')) i++ 1249 else if (arg === '-i' || arg === '-0' || arg === '-v') i++ 1250 else if (arg === '-u' && a[i + 1]) i += 2 1251 else if (arg.startsWith('-')) 1252 return -1 // -S/-C/-P/unknown: fail closed 1253 else break 1254 } 1255 return i < a.length ? i : -1 1256} 1257 1258/** 1259 * Argv-level counterpart to stripSafeWrappers (bashPermissions.ts). Strips 1260 * wrapper commands from AST-derived argv. Env vars are already separated 1261 * into SimpleCommand.envVars so no env-var stripping here. 1262 */ 1263export function stripWrappersFromArgv(argv: string[]): string[] { 1264 let a = argv 1265 for (;;) { 1266 if (a[0] === 'time' || a[0] === 'nohup') { 1267 a = a.slice(a[1] === '--' ? 2 : 1) 1268 } else if (a[0] === 'timeout') { 1269 const i = skipTimeoutFlags(a) 1270 // SECURITY (PR #21503 round 3): unrecognized duration (`.5`, `+5`, 1271 // `inf` — strtod formats GNU timeout accepts) → return a unchanged. 1272 // Safe because checkSemantics (ast.ts) fails CLOSED on the same input 1273 // and runs first in bashToolHasPermission, so we never reach here. 1274 if (i < 0 || !a[i] || !/^\d+(?:\.\d+)?[smhd]?$/.test(a[i]!)) return a 1275 a = a.slice(i + 1) 1276 } else if (a[0] === 'nice') { 1277 // SECURITY (PR #21503 round 3): mirror checkSemantics — handle bare 1278 // `nice cmd` and legacy `nice -N cmd`, not just `nice -n N cmd`. 1279 // Previously only `-n N` was stripped: `nice rm /outside` → 1280 // baseCmd='nice' → passthrough → /outside never path-validated. 1281 if (a[1] === '-n' && a[2] && /^-?\d+$/.test(a[2])) 1282 a = a.slice(a[3] === '--' ? 4 : 3) 1283 else if (a[1] && /^-\d+$/.test(a[1])) a = a.slice(a[2] === '--' ? 3 : 2) 1284 else a = a.slice(a[1] === '--' ? 2 : 1) 1285 } else if (a[0] === 'stdbuf') { 1286 // SECURITY (PR #21503 round 3): PR-WIDENED. Pre-PR, `stdbuf -o0 -eL rm` 1287 // was rejected by fragment check (old checkSemantics slice(2) left 1288 // name='-eL'). Post-PR, checkSemantics strips both flags → name='rm' 1289 // → passes. But stripWrappersFromArgv returned unchanged → 1290 // baseCmd='stdbuf' → not in SUPPORTED_PATH_COMMANDS → passthrough. 1291 const i = skipStdbufFlags(a) 1292 if (i < 0) return a 1293 a = a.slice(i) 1294 } else if (a[0] === 'env') { 1295 // Same asymmetry: checkSemantics strips env, we didn't. 1296 const i = skipEnvFlags(a) 1297 if (i < 0) return a 1298 a = a.slice(i) 1299 } else { 1300 return a 1301 } 1302 } 1303}