/ tools / BashTool / pathValidation.ts
pathValidation.ts
   1  import { homedir } from 'os'
   2  import { isAbsolute, resolve } from 'path'
   3  import type { z } from 'zod/v4'
   4  import type { ToolPermissionContext } from '../../Tool.js'
   5  import type { Redirect, SimpleCommand } from '../../utils/bash/ast.js'
   6  import {
   7    extractOutputRedirections,
   8    splitCommand_DEPRECATED,
   9  } from '../../utils/bash/commands.js'
  10  import { tryParseShellCommand } from '../../utils/bash/shellQuote.js'
  11  import { getDirectoryForPath } from '../../utils/path.js'
  12  import { allWorkingDirectories } from '../../utils/permissions/filesystem.js'
  13  import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
  14  import { createReadRuleSuggestion } from '../../utils/permissions/PermissionUpdate.js'
  15  import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'
  16  import {
  17    expandTilde,
  18    type FileOperationType,
  19    formatDirectoryList,
  20    isDangerousRemovalPath,
  21    validatePath,
  22  } from '../../utils/permissions/pathValidation.js'
  23  import type { BashTool } from './BashTool.js'
  24  import { stripSafeWrappers } from './bashPermissions.js'
  25  import { sedCommandIsAllowedByAllowlist } from './sedValidation.js'
  26  
  27  export 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   */
  70  function 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   */
 126  function 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)
 142  function 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   */
 190  export 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  
 511  const SUPPORTED_PATH_COMMANDS = Object.keys(PATH_EXTRACTORS) as PathCommand[]
 512  
 513  const 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  
 552  export 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   */
 596  const 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  
 603  function 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  
 703  export 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   */
 791  function 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   */
 834  function 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   */
 888  function 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  
 924  function 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   */
1013  export 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   */
1116  function 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.
1177  const 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   */
1183  function 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   */
1225  function 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   */
1244  function 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   */
1263  export 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  }