import { parse } from "shell-quote" type ShellToken = string | { op: string } | { command: string } /** * Split a command string into individual sub-commands by * chaining operators (&&, ||, ;, or |). * * Uses shell-quote to properly handle: * - Quoted strings (preserves quotes) * - Subshell commands ($(cmd) or `cmd`) * - PowerShell redirections (2>&1) * - Chain operators (&&, ||, ;, |) */ export function parseCommand(command: string): string[] { if (!command?.trim()) return [] // First handle PowerShell redirections by temporarily replacing them const redirections: string[] = [] let processedCommand = command.replace(/\d*>&\d*/g, (match) => { redirections.push(match) return `__REDIR_${redirections.length - 1}__` }) // Then handle subshell commands const subshells: string[] = [] 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 const quotes: string[] = [] processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => { quotes.push(match) return `__QUOTE_${quotes.length - 1}__` }) const tokens = parse(processedCommand) as ShellToken[] 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 { // 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(" ")) } // 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)]) return result }) } /** * Check if a single command is allowed based on prefix matching. */ export function isAllowedSingleCommand(command: string, allowedCommands: string[]): boolean { if (!command || !allowedCommands?.length) return false const trimmedCommand = command.trim().toLowerCase() return allowedCommands.some((prefix) => trimmedCommand.startsWith(prefix.toLowerCase())) } /** * Check if a command string is allowed based on the allowed command prefixes. * This version also blocks subshell attempts by checking for `$(` or `` ` ``. */ export function validateCommand(command: string, allowedCommands: string[]): boolean { if (!command?.trim()) return true // Block subshell execution attempts if (command.includes("$(") || command.includes("`")) { return false } // Parse into sub-commands (split by &&, ||, ;, |) const subCommands = parseCommand(command) // Then ensure every sub-command starts with an allowed prefix return subCommands.every((cmd) => { // Remove simple PowerShell-like redirections (e.g. 2>&1) before checking const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim() return isAllowedSingleCommand(cmdWithoutRedirection, allowedCommands) }) }