Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
11 kB · 294 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295import { hasMalformedTokens, hasShellQuoteSingleQuoteBug, type ParseEntry, quote, tryParseShellCommand,} from './shellQuote.js'
/** * Rearranges a command with pipes to place stdin redirect after the first command. * This fixes an issue where eval treats the entire piped command as a single unit, * causing the stdin redirect to apply to eval itself rather than the first command. */export function rearrangePipeCommand(command: string): string { // Skip if command has backticks - shell-quote doesn't handle them well if (command.includes('`')) { return quoteWithEvalStdinRedirect(command) }
// Skip if command has command substitution - shell-quote parses $() incorrectly, // treating ( and ) as separate operators instead of recognizing command substitution if (command.includes('$(')) { return quoteWithEvalStdinRedirect(command) }
// Skip if command references shell variables ($VAR, ${VAR}). shell-quote's parse() // expands these to empty string when no env is passed, silently dropping the // reference. Even if we preserved the token via an env function, quote() would // then escape the $ during rebuild, preventing runtime expansion. See #9732. if (/\$[A-Za-z_{]/.test(command)) { return quoteWithEvalStdinRedirect(command) }
// Skip if command contains bash control structures (for/while/until/if/case/select) // shell-quote cannot parse these correctly and will incorrectly find pipes inside // the control structure body, breaking the command when rearranged if (containsControlStructure(command)) { return quoteWithEvalStdinRedirect(command) }
// Join continuation lines before parsing: shell-quote doesn't handle \<newline> // and produces empty string tokens for each occurrence, causing spurious empty // arguments in the reconstructed command const joined = joinContinuationLines(command)
// shell-quote treats bare newlines as whitespace, not command separators. // Parsing+rebuilding 'cmd1 | head\ncmd2 | grep' yields 'cmd1 | head cmd2 | grep', // silently merging pipelines. Line-continuation (\<newline>) is already stripped // above; any remaining newline is a real separator. Bail to the eval fallback, // which preserves the newline inside a single-quoted arg. See #32515. if (joined.includes('\n')) { return quoteWithEvalStdinRedirect(command) }
// SECURITY: shell-quote treats \' inside single quotes as an escape, but // bash treats it as literal \ followed by a closing quote. The pattern // '\' <payload> '\' makes shell-quote merge <payload> into the quoted // string, hiding operators like ; from the token stream. Rebuilding from // that merged token can expose the operators when bash re-parses. if (hasShellQuoteSingleQuoteBug(joined)) { return quoteWithEvalStdinRedirect(command) }
const parseResult = tryParseShellCommand(joined)
// If parsing fails (malformed syntax), fall back to quoting the whole command if (!parseResult.success) { return quoteWithEvalStdinRedirect(command) }
const parsed = parseResult.tokens
// SECURITY: shell-quote tokenizes differently from bash. Input like // `echo {"hi":\"hi;calc.exe"}` is a bash syntax error (unbalanced quote), // but shell-quote parses it into tokens with `;` as an operator and // `calc.exe` as a separate word. Rebuilding from those tokens produces // valid bash that executes `calc.exe` — turning a syntax error into an // injection. Unbalanced delimiters in a string token signal this // misparsing; fall back to whole-command quoting, which preserves the // original (bash then rejects it with the same syntax error it would have // raised without us). if (hasMalformedTokens(joined, parsed)) { return quoteWithEvalStdinRedirect(command) }
const firstPipeIndex = findFirstPipeOperator(parsed)
if (firstPipeIndex <= 0) { return quoteWithEvalStdinRedirect(command) }
// Rebuild: first_command < /dev/null | rest_of_pipeline const parts = [ ...buildCommandParts(parsed, 0, firstPipeIndex), '< /dev/null', ...buildCommandParts(parsed, firstPipeIndex, parsed.length), ]
return singleQuoteForEval(parts.join(' '))}
/** * Finds the index of the first pipe operator in parsed shell command */function findFirstPipeOperator(parsed: ParseEntry[]): number { for (let i = 0; i < parsed.length; i++) { const entry = parsed[i] if (isOperator(entry, '|')) { return i } } return -1}
/** * Builds command parts from parsed entries, handling strings and operators. * Special handling for file descriptor redirections to preserve them as single units. */function buildCommandParts( parsed: ParseEntry[], start: number, end: number,): string[] { const parts: string[] = [] // Track if we've seen a non-env-var string token yet // Environment variables are only valid at the start of a command let seenNonEnvVar = false
for (let i = start; i < end; i++) { const entry = parsed[i]
// Check for file descriptor redirections (e.g., 2>&1, 2>/dev/null) if ( typeof entry === 'string' && /^[012]$/.test(entry) && i + 2 < end && isOperator(parsed[i + 1]) ) { const op = parsed[i + 1] as { op: string } const target = parsed[i + 2]
// Handle 2>&1 style redirections if ( op.op === '>&' && typeof target === 'string' && /^[012]$/.test(target) ) { parts.push(`${entry}>&${target}`) i += 2 continue }
// Handle 2>/dev/null style redirections if (op.op === '>' && target === '/dev/null') { parts.push(`${entry}>/dev/null`) i += 2 continue }
// Handle 2> &1 style (space between > and &1) if ( op.op === '>' && typeof target === 'string' && target.startsWith('&') ) { const fd = target.slice(1) if (/^[012]$/.test(fd)) { parts.push(`${entry}>&${fd}`) i += 2 continue } } }
// Handle regular entries if (typeof entry === 'string') { // Environment variable assignments are only valid at the start of a command, // before any non-env-var tokens (the actual command and its arguments) const isEnvVar = !seenNonEnvVar && isEnvironmentVariableAssignment(entry)
if (isEnvVar) { // For env var assignments, we need to preserve the = but quote the value if needed // Split into name and value parts const eqIndex = entry.indexOf('=') const name = entry.slice(0, eqIndex) const value = entry.slice(eqIndex + 1)
// Quote the value part to handle spaces and special characters const quotedValue = quote([value]) parts.push(`${name}=${quotedValue}`) } else { // Once we see a non-env-var string, all subsequent strings are arguments seenNonEnvVar = true parts.push(quote([entry])) } } else if (isOperator(entry)) { // Special handling for glob operators if (entry.op === 'glob' && 'pattern' in entry) { // Don't quote glob patterns - they need to remain as-is for shell expansion parts.push(entry.pattern as string) } else { parts.push(entry.op) // Reset after command separators - the next command can have its own env vars if (isCommandSeparator(entry.op)) { seenNonEnvVar = false } } } }
return parts}
/** * Checks if a string is an environment variable assignment (VAR=value) * Environment variable names must start with letter or underscore, * followed by letters, numbers, or underscores */function isEnvironmentVariableAssignment(str: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*=/.test(str)}
/** * Checks if an operator is a command separator that starts a new command context. * After these operators, environment variable assignments are valid again. */function isCommandSeparator(op: string): boolean { return op === '&&' || op === '||' || op === ';'}
/** * Type guard to check if a parsed entry is an operator */function isOperator(entry: unknown, op?: string): entry is { op: string } { if (!entry || typeof entry !== 'object' || !('op' in entry)) { return false } return op ? entry.op === op : true}
/** * Checks if a command contains bash control structures that shell-quote cannot parse. * These include for/while/until/if/case/select loops and conditionals. * We match keywords followed by whitespace to avoid false positives with commands * or arguments that happen to contain these words. */function containsControlStructure(command: string): boolean { return /\b(for|while|until|if|case|select)\s/.test(command)}
/** * Quotes a command and adds `< /dev/null` as a shell redirect on eval, rather than * as an eval argument. This is critical for pipe commands where we can't parse the * pipe boundary (e.g., commands with $(), backticks, or control structures). * * Using `singleQuoteForEval(cmd) + ' < /dev/null'` produces: eval 'cmd' < /dev/null * → eval's stdin is /dev/null, eval evaluates 'cmd', pipes inside work correctly * * The previous approach `quote([cmd, '<', '/dev/null'])` produced: eval 'cmd' \< /dev/null * → eval concatenates args to 'cmd < /dev/null', redirect applies to LAST pipe command */function quoteWithEvalStdinRedirect(command: string): string { return singleQuoteForEval(command) + ' < /dev/null'}
/** * Single-quote a string for use as an eval argument. Escapes embedded single * quotes via '"'"' (close-sq, literal-sq-in-dq, reopen-sq). Used instead of * shell-quote's quote() which switches to double-quote mode when the input * contains single quotes and then escapes ! -> \!, corrupting jq/awk filters * like `select(.x != .y)` into `select(.x \!= .y)`. */function singleQuoteForEval(s: string): string { return "'" + s.replace(/'/g, `'"'"'`) + "'"}
/** * Joins shell continuation lines (backslash-newline) into a single line. * Only joins when there's an odd number of backslashes before the newline * (the last one escapes the newline). Even backslashes pair up as escape * sequences and the newline remains a separator. */function joinContinuationLines(command: string): string { return command.replace(/\\+\n/g, match => { const backslashCount = match.length - 1 // -1 for the newline if (backslashCount % 2 === 1) { // Odd number: last backslash escapes the newline (line continuation) return '\\'.repeat(backslashCount - 1) } else { // Even number: all pair up, newline is a real separator return match } })}