/ utils / bash / shellQuoting.ts
shellQuoting.ts
  1  import { quote } from './shellQuote.js'
  2  
  3  /**
  4   * Detects if a command contains a heredoc pattern
  5   * Matches patterns like: <<EOF, <<'EOF', <<"EOF", <<-EOF, <<-'EOF', <<\EOF, etc.
  6   */
  7  function containsHeredoc(command: string): boolean {
  8    // Match heredoc patterns: << followed by optional -, then optional quotes or backslash, then word
  9    // Matches: <<EOF, <<'EOF', <<"EOF", <<-EOF, <<-'EOF', <<\EOF
 10    // Check for bit-shift operators first and exclude them
 11    if (
 12      /\d\s*<<\s*\d/.test(command) ||
 13      /\[\[\s*\d+\s*<<\s*\d+\s*\]\]/.test(command) ||
 14      /\$\(\(.*<<.*\)\)/.test(command)
 15    ) {
 16      return false
 17    }
 18  
 19    // Now check for heredoc patterns
 20    const heredocRegex = /<<-?\s*(?:(['"]?)(\w+)\1|\\(\w+))/
 21    return heredocRegex.test(command)
 22  }
 23  
 24  /**
 25   * Detects if a command contains multiline strings in quotes
 26   */
 27  function containsMultilineString(command: string): boolean {
 28    // Check for strings with actual newlines in them
 29    // Handle escaped quotes by using a more sophisticated pattern
 30    // Match single quotes: '...\n...' where content can include escaped quotes \'
 31    // Match double quotes: "...\n..." where content can include escaped quotes \"
 32    const singleQuoteMultiline = /'(?:[^'\\]|\\.)*\n(?:[^'\\]|\\.)*'/
 33    const doubleQuoteMultiline = /"(?:[^"\\]|\\.)*\n(?:[^"\\]|\\.)*"/
 34  
 35    return (
 36      singleQuoteMultiline.test(command) || doubleQuoteMultiline.test(command)
 37    )
 38  }
 39  
 40  /**
 41   * Quotes a shell command appropriately, preserving heredocs and multiline strings
 42   * @param command The command to quote
 43   * @param addStdinRedirect Whether to add < /dev/null
 44   * @returns The properly quoted command
 45   */
 46  export function quoteShellCommand(
 47    command: string,
 48    addStdinRedirect: boolean = true,
 49  ): string {
 50    // If command contains heredoc or multiline strings, handle specially
 51    // The shell-quote library incorrectly escapes ! to \! in these cases
 52    if (containsHeredoc(command) || containsMultilineString(command)) {
 53      // For heredocs and multiline strings, we need to quote for eval
 54      // but avoid shell-quote's aggressive escaping
 55      // We'll use single quotes and escape only single quotes in the command
 56      const escaped = command.replace(/'/g, "'\"'\"'")
 57      const quoted = `'${escaped}'`
 58  
 59      // Don't add stdin redirect for heredocs as they provide their own input
 60      if (containsHeredoc(command)) {
 61        return quoted
 62      }
 63  
 64      // For multiline strings without heredocs, add stdin redirect if needed
 65      return addStdinRedirect ? `${quoted} < /dev/null` : quoted
 66    }
 67  
 68    // For regular commands, use shell-quote
 69    if (addStdinRedirect) {
 70      return quote([command, '<', '/dev/null'])
 71    }
 72  
 73    return quote([command])
 74  }
 75  
 76  /**
 77   * Detects if a command already has a stdin redirect
 78   * Match patterns like: < file, </path/to/file, < /dev/null, etc.
 79   * But not <<EOF (heredoc), << (bit shift), or <(process substitution)
 80   */
 81  export function hasStdinRedirect(command: string): boolean {
 82    // Look for < followed by whitespace and a filename/path
 83    // Negative lookahead to exclude: <<, <(
 84    // Must be preceded by whitespace or command separator or start of string
 85    return /(?:^|[\s;&|])<(?![<(])\s*\S+/.test(command)
 86  }
 87  
 88  /**
 89   * Checks if stdin redirect should be added to a command
 90   * @param command The command to check
 91   * @returns true if stdin redirect can be safely added
 92   */
 93  export function shouldAddStdinRedirect(command: string): boolean {
 94    // Don't add stdin redirect for heredocs as it interferes with the heredoc terminator
 95    if (containsHeredoc(command)) {
 96      return false
 97    }
 98  
 99    // Don't add stdin redirect if command already has one
100    if (hasStdinRedirect(command)) {
101      return false
102    }
103  
104    // For other commands, stdin redirect is generally safe
105    return true
106  }
107  
108  /**
109   * Rewrites Windows CMD-style `>nul` redirects to POSIX `/dev/null`.
110   *
111   * The model occasionally hallucinates Windows CMD syntax (e.g., `ls 2>nul`)
112   * even though our bash shell is always POSIX (Git Bash / WSL on Windows).
113   * When Git Bash sees `2>nul`, it creates a literal file named `nul` — a
114   * Windows reserved device name that is extremely hard to delete and breaks
115   * `git add .` and `git clone`. See anthropics/claude-code#4928.
116   *
117   * Matches: `>nul`, `> NUL`, `2>nul`, `&>nul`, `>>nul` (case-insensitive)
118   * Does NOT match: `>null`, `>nullable`, `>nul.txt`, `cat nul.txt`
119   *
120   * Limitation: this regex does not parse shell quoting, so `echo ">nul"`
121   * will also be rewritten. This is acceptable collateral — it's extremely
122   * rare and rewriting to `/dev/null` inside a string is harmless.
123   */
124  const NUL_REDIRECT_REGEX = /(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g
125  
126  export function rewriteWindowsNullRedirect(command: string): string {
127    return command.replace(NUL_REDIRECT_REGEX, '$1/dev/null')
128  }