Files
Roo-Code/webview-ui/src/utils/command-validation.ts
2025-01-17 14:11:28 -05:00

122 lines
3.7 KiB
TypeScript

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)
})
}