diff --git a/webview-ui/src/utils/command-parser.ts b/webview-ui/src/utils/command-parser.ts index d8ad9eebbc..14a83313ba 100644 --- a/webview-ui/src/utils/command-parser.ts +++ b/webview-ui/src/utils/command-parser.ts @@ -41,7 +41,7 @@ export function extractPatternsFromCommand(command: string): string[] { } function isValidToken(token: string): boolean { - return !!token && !token.match(/[/\\~:]/) && token !== "." && !token.match(/\.\w+$/) + return !!token && !token.startsWith("-") && !token.match(/[/\\~:]/) && token !== "." && !token.match(/\.\w+$/) } function extractFromTokens(tokens: string[], patterns: Set): void { diff --git a/webview-ui/src/utils/command-validation.ts b/webview-ui/src/utils/command-validation.ts index 6396eef1a4..b403d41d8c 100644 --- a/webview-ui/src/utils/command-validation.ts +++ b/webview-ui/src/utils/command-validation.ts @@ -1,5 +1,7 @@ import { parse } from "shell-quote" +type ShellToken = string | { op: string } | { command: string } + /** * # Command Denylist Feature - Longest Prefix Match Strategy * @@ -70,38 +72,183 @@ import { parse } from "shell-quote" export function parseCommand(command: string): string[] { if (!command?.trim()) return [] - try { - const parsed = parse(command) - const commands: string[] = [] - let currentCommand: string[] = [] + // Split by newlines first (handle different line ending formats) + // This regex splits on \r\n (Windows), \n (Unix), or \r (old Mac) + const lines = command.split(/\r\n|\r|\n/) + const allCommands: string[] = [] - for (const token of parsed) { - if (typeof token === "object" && "op" in token) { - // Chain operator - split command - if (["&&", "||", ";", "|"].includes(token.op)) { - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - currentCommand = [] - } - } else { - // Other operators are part of the command - currentCommand.push(token.op) + for (const line of lines) { + // Skip empty lines + if (!line.trim()) continue + + // Process each line through the existing parsing logic + const lineCommands = parseCommandLine(line) + allCommands.push(...lineCommands) + } + + return allCommands +} + +/** + * Parse a single line of commands (internal helper function) + */ +function parseCommandLine(command: string): string[] { + if (!command?.trim()) return [] + + // Storage for replaced content + const redirections: string[] = [] + const subshells: string[] = [] + const quotes: string[] = [] + const arrayIndexing: string[] = [] + const arithmeticExpressions: string[] = [] + const variables: string[] = [] + const parameterExpansions: string[] = [] + const processSubstitutions: string[] = [] + + // First handle PowerShell redirections by temporarily replacing them + let processedCommand = command.replace(/\d*>&\d*/g, (match) => { + redirections.push(match) + return `__REDIR_${redirections.length - 1}__` + }) + + // Handle arithmetic expressions: $((...)) pattern + // Match the entire arithmetic expression including nested parentheses + processedCommand = processedCommand.replace(/\$\(\([^)]*(?:\)[^)]*)*\)\)/g, (match) => { + arithmeticExpressions.push(match) + return `__ARITH_${arithmeticExpressions.length - 1}__` + }) + + // Handle parameter expansions: ${...} patterns (including array indexing) + // This covers ${var}, ${var:-default}, ${var:+alt}, ${#var}, ${var%pattern}, etc. + processedCommand = processedCommand.replace(/\$\{[^}]+\}/g, (match) => { + parameterExpansions.push(match) + return `__PARAM_${parameterExpansions.length - 1}__` + }) + + // Handle process substitutions: <(...) and >(...) + processedCommand = processedCommand.replace(/[<>]\([^)]+\)/g, (match) => { + processSubstitutions.push(match) + return `__PROCSUB_${processSubstitutions.length - 1}__` + }) + + // Handle simple variable references: $varname pattern + // This prevents shell-quote from splitting $count into separate tokens + processedCommand = processedCommand.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => { + variables.push(match) + return `__VAR_${variables.length - 1}__` + }) + + // Handle special bash variables: $?, $!, $#, $$, $@, $*, $-, $0-$9 + processedCommand = processedCommand.replace(/\$[?!#$@*\-0-9]/g, (match) => { + variables.push(match) + return `__VAR_${variables.length - 1}__` + }) + + // Then handle subshell commands + processedCommand = processedCommand + .replace(/\$\((.*?)\)/g, (_, inner) => { + subshells.push(inner.trim()) + return `__SUBSH_${subshells.length - 1}__` + }) + .replace(/`(.*?)`/g, (_, inner) => { + subshells.push(inner.trim()) + return `__SUBSH_${subshells.length - 1}__` + }) + + // Then handle quoted strings + processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => { + quotes.push(match) + return `__QUOTE_${quotes.length - 1}__` + }) + + let tokens: ShellToken[] + try { + tokens = parse(processedCommand) as ShellToken[] + } catch (error: any) { + // If shell-quote fails to parse, fall back to simple splitting + console.warn("shell-quote parse error:", error.message, "for command:", processedCommand) + + // Simple fallback: split by common operators + const fallbackCommands = processedCommand + .split(/(?:&&|\|\||;|\|)/) + .map((cmd) => cmd.trim()) + .filter((cmd) => cmd.length > 0) + + // Restore all placeholders for each command + return fallbackCommands.map((cmd) => { + let result = cmd + // Restore quotes + result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)]) + // Restore redirections + result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)]) + // Restore array indexing expressions + result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)]) + // Restore arithmetic expressions + result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)]) + // Restore parameter expansions + result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)]) + // Restore process substitutions + result = result.replace(/__PROCSUB_(\d+)__/g, (_, i) => processSubstitutions[parseInt(i)]) + // Restore variable references + result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)]) + return result + }) + } + + const commands: string[] = [] + let currentCommand: string[] = [] + + for (const token of tokens) { + if (typeof token === "object" && "op" in token) { + // Chain operator - split command + if (["&&", "||", ";", "|"].includes(token.op)) { + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) + currentCommand = [] } - } else if (typeof token === "string") { + } else { + // Other operators (>, &) are part of the command + currentCommand.push(token.op) + } + } else if (typeof token === "string") { + // Check if it's a subshell placeholder + const subshellMatch = token.match(/__SUBSH_(\d+)__/) + if (subshellMatch) { + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) + currentCommand = [] + } + commands.push(subshells[parseInt(subshellMatch[1])]) + } else { currentCommand.push(token) } } - - // Add any remaining command - if (currentCommand.length > 0) { - commands.push(currentCommand.join(" ")) - } - - return commands - } catch (_error) { - // If shell-quote fails, fall back to simple splitting - return [command] } + + // Add any remaining command + if (currentCommand.length > 0) { + commands.push(currentCommand.join(" ")) + } + + // Restore quotes and redirections + return commands.map((cmd) => { + let result = cmd + // Restore quotes + result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)]) + // Restore redirections + result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)]) + // Restore array indexing expressions + result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)]) + // Restore arithmetic expressions + result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)]) + // Restore parameter expansions + result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)]) + // Restore process substitutions + result = result.replace(/__PROCSUB_(\d+)__/g, (_, i) => processSubstitutions[parseInt(i)]) + // Restore variable references + result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)]) + return result + }) } /**