|
|
@@ -44,7 +44,7 @@ type ShellToken = string | { op: string } | { command: string }
|
|
|
*
|
|
|
* ## Security Considerations:
|
|
|
*
|
|
|
- * - **Subshell Protection**: Prevents command injection via $(command) or `command`
|
|
|
+ * - **Subshell Protection**: Prevents command injection via $(command), `command`, or process substitution
|
|
|
* - **Chain Analysis**: Each command in a chain (cmd1 && cmd2) is validated separately
|
|
|
* - **Case Insensitive**: All matching is case-insensitive for consistency
|
|
|
* - **Whitespace Handling**: Commands are trimmed and normalized before matching
|
|
|
@@ -58,13 +58,36 @@ type ShellToken = string | { op: string } | { command: string }
|
|
|
* This allows users to have personal defaults while projects can define specific restrictions.
|
|
|
*/
|
|
|
|
|
|
+/**
|
|
|
+ * Detect subshell usage and command substitution patterns:
|
|
|
+ * - $() - command substitution
|
|
|
+ * - `` - backticks (legacy command substitution)
|
|
|
+ * - <() - process substitution (input)
|
|
|
+ * - >() - process substitution (output)
|
|
|
+ * - $(()) - arithmetic expansion
|
|
|
+ * - $[] - arithmetic expansion (alternative syntax)
|
|
|
+ *
|
|
|
+ * @example
|
|
|
+ * ```typescript
|
|
|
+ * containsSubshell("echo $(date)") // true - command substitution
|
|
|
+ * containsSubshell("echo `date`") // true - backtick substitution
|
|
|
+ * containsSubshell("diff <(sort f1)") // true - process substitution
|
|
|
+ * containsSubshell("echo $((1+2))") // true - arithmetic expansion
|
|
|
+ * containsSubshell("echo $[1+2]") // true - arithmetic expansion (alt)
|
|
|
+ * containsSubshell("echo hello") // false - no subshells
|
|
|
+ * ```
|
|
|
+ */
|
|
|
+export function containsSubshell(source: string): boolean {
|
|
|
+ return /(\$\()|`|(<\(|>\()|(\$\(\()|(\$\[)/.test(source)
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Split a command string into individual sub-commands by
|
|
|
* chaining operators (&&, ||, ;, or |) and newlines.
|
|
|
*
|
|
|
* Uses shell-quote to properly handle:
|
|
|
* - Quoted strings (preserves quotes)
|
|
|
- * - Subshell commands ($(cmd) or `cmd`)
|
|
|
+ * - Subshell commands ($(cmd), `cmd`, <(cmd), >(cmd))
|
|
|
* - PowerShell redirections (2>&1)
|
|
|
* - Chain operators (&&, ||, ;, |)
|
|
|
* - Newlines as command separators
|
|
|
@@ -89,6 +112,36 @@ export function parseCommand(command: string): string[] {
|
|
|
return allCommands
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * Helper function to restore placeholders in a command string
|
|
|
+ */
|
|
|
+function restorePlaceholders(
|
|
|
+ command: string,
|
|
|
+ quotes: string[],
|
|
|
+ redirections: string[],
|
|
|
+ arrayIndexing: string[],
|
|
|
+ arithmeticExpressions: string[],
|
|
|
+ parameterExpansions: string[],
|
|
|
+ variables: string[],
|
|
|
+ subshells: string[],
|
|
|
+): string {
|
|
|
+ let result = command
|
|
|
+ // 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 variable references
|
|
|
+ result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)])
|
|
|
+ result = result.replace(/__SUBSH_(\d+)__/g, (_, i) => subshells[parseInt(i)])
|
|
|
+ return result
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Parse a single line of commands (internal helper function)
|
|
|
*/
|
|
|
@@ -103,7 +156,6 @@ function parseCommandLine(command: string): 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) => {
|
|
|
@@ -118,6 +170,12 @@ function parseCommandLine(command: string): string[] {
|
|
|
return `__ARITH_${arithmeticExpressions.length - 1}__`
|
|
|
})
|
|
|
|
|
|
+ // Handle $[...] arithmetic expressions (alternative syntax)
|
|
|
+ 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) => {
|
|
|
@@ -126,9 +184,9 @@ function parseCommandLine(command: string): string[] {
|
|
|
})
|
|
|
|
|
|
// Handle process substitutions: <(...) and >(...)
|
|
|
- processedCommand = processedCommand.replace(/[<>]\([^)]+\)/g, (match) => {
|
|
|
- processSubstitutions.push(match)
|
|
|
- return `__PROCSUB_${processSubstitutions.length - 1}__`
|
|
|
+ processedCommand = processedCommand.replace(/[<>]\(([^)]+)\)/g, (_, inner) => {
|
|
|
+ subshells.push(inner.trim())
|
|
|
+ return `__SUBSH_${subshells.length - 1}__`
|
|
|
})
|
|
|
|
|
|
// Handle simple variable references: $varname pattern
|
|
|
@@ -144,7 +202,7 @@ function parseCommandLine(command: string): string[] {
|
|
|
return `__VAR_${variables.length - 1}__`
|
|
|
})
|
|
|
|
|
|
- // Then handle subshell commands
|
|
|
+ // Then handle subshell commands $() and back-ticks
|
|
|
processedCommand = processedCommand
|
|
|
.replace(/\$\((.*?)\)/g, (_, inner) => {
|
|
|
subshells.push(inner.trim())
|
|
|
@@ -175,24 +233,18 @@ function parseCommandLine(command: string): string[] {
|
|
|
.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
|
|
|
- })
|
|
|
+ return fallbackCommands.map((cmd) =>
|
|
|
+ restorePlaceholders(
|
|
|
+ cmd,
|
|
|
+ quotes,
|
|
|
+ redirections,
|
|
|
+ arrayIndexing,
|
|
|
+ arithmeticExpressions,
|
|
|
+ parameterExpansions,
|
|
|
+ variables,
|
|
|
+ subshells,
|
|
|
+ ),
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
const commands: string[] = []
|
|
|
@@ -231,24 +283,18 @@ function parseCommandLine(command: string): string[] {
|
|
|
}
|
|
|
|
|
|
// 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
|
|
|
- })
|
|
|
+ return commands.map((cmd) =>
|
|
|
+ restorePlaceholders(
|
|
|
+ cmd,
|
|
|
+ quotes,
|
|
|
+ redirections,
|
|
|
+ arrayIndexing,
|
|
|
+ arithmeticExpressions,
|
|
|
+ parameterExpansions,
|
|
|
+ variables,
|
|
|
+ subshells,
|
|
|
+ ),
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -430,14 +476,6 @@ export function getCommandDecision(
|
|
|
): CommandDecision {
|
|
|
if (!command?.trim()) return "auto_approve"
|
|
|
|
|
|
- // Check if subshells contain denied prefixes
|
|
|
- if ((command.includes("$(") || command.includes("`")) && deniedCommands?.length) {
|
|
|
- const mainCommandLower = command.toLowerCase()
|
|
|
- if (deniedCommands.some((denied) => mainCommandLower.includes(denied.toLowerCase()))) {
|
|
|
- return "auto_deny"
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
// Parse into sub-commands (split by &&, ||, ;, |)
|
|
|
const subCommands = parseCommand(command)
|
|
|
|
|
|
@@ -610,7 +648,7 @@ export class CommandValidator {
|
|
|
hasSubshells: boolean
|
|
|
} {
|
|
|
const subCommands = parseCommand(command)
|
|
|
- const hasSubshells = command.includes("$(") || command.includes("`")
|
|
|
+ const hasSubshells = containsSubshell(command)
|
|
|
|
|
|
const allowedMatches = subCommands.map((cmd) => ({
|
|
|
command: cmd,
|