@@ -44,7 +44,7 @@ type ShellToken = string | { op: string } | { command: string }
4444 *
4545 * ## Security Considerations:
4646 *
47- * - **Subshell Protection**: Prevents command injection via $(command) or `command`
47+ * - **Subshell Protection**: Prevents command injection via $(command), `command`, or process substitution
4848 * - **Chain Analysis**: Each command in a chain (cmd1 && cmd2) is validated separately
4949 * - **Case Insensitive**: All matching is case-insensitive for consistency
5050 * - **Whitespace Handling**: Commands are trimmed and normalized before matching
@@ -58,13 +58,36 @@ type ShellToken = string | { op: string } | { command: string }
5858 * This allows users to have personal defaults while projects can define specific restrictions.
5959 */
6060
61+ /**
62+ * Detect subshell usage and command substitution patterns:
63+ * - $() - command substitution
64+ * - `` - backticks (legacy command substitution)
65+ * - <() - process substitution (input)
66+ * - >() - process substitution (output)
67+ * - $(()) - arithmetic expansion
68+ * - $[] - arithmetic expansion (alternative syntax)
69+ *
70+ * @example
71+ * ```typescript
72+ * containsSubshell("echo $(date)") // true - command substitution
73+ * containsSubshell("echo `date`") // true - backtick substitution
74+ * containsSubshell("diff <(sort f1)") // true - process substitution
75+ * containsSubshell("echo $((1+2))") // true - arithmetic expansion
76+ * containsSubshell("echo $[1+2]") // true - arithmetic expansion (alt)
77+ * containsSubshell("echo hello") // false - no subshells
78+ * ```
79+ */
80+ export function containsSubshell ( source : string ) : boolean {
81+ return / ( \$ \( ) | ` | ( < \( | > \( ) | ( \$ \( \( ) | ( \$ \[ ) / . test ( source )
82+ }
83+
6184/**
6285 * Split a command string into individual sub-commands by
6386 * chaining operators (&&, ||, ;, or |) and newlines.
6487 *
6588 * Uses shell-quote to properly handle:
6689 * - Quoted strings (preserves quotes)
67- * - Subshell commands ($(cmd) or `cmd`)
90+ * - Subshell commands ($(cmd), `cmd`, <(cmd), >(cmd) )
6891 * - PowerShell redirections (2>&1)
6992 * - Chain operators (&&, ||, ;, |)
7093 * - Newlines as command separators
@@ -89,6 +112,36 @@ export function parseCommand(command: string): string[] {
89112 return allCommands
90113}
91114
115+ /**
116+ * Helper function to restore placeholders in a command string
117+ */
118+ function restorePlaceholders (
119+ command : string ,
120+ quotes : string [ ] ,
121+ redirections : string [ ] ,
122+ arrayIndexing : string [ ] ,
123+ arithmeticExpressions : string [ ] ,
124+ parameterExpansions : string [ ] ,
125+ variables : string [ ] ,
126+ subshells : string [ ] ,
127+ ) : string {
128+ let result = command
129+ // Restore quotes
130+ result = result . replace ( / _ _ Q U O T E _ ( \d + ) _ _ / g, ( _ , i ) => quotes [ parseInt ( i ) ] )
131+ // Restore redirections
132+ result = result . replace ( / _ _ R E D I R _ ( \d + ) _ _ / g, ( _ , i ) => redirections [ parseInt ( i ) ] )
133+ // Restore array indexing expressions
134+ result = result . replace ( / _ _ A R R A Y _ ( \d + ) _ _ / g, ( _ , i ) => arrayIndexing [ parseInt ( i ) ] )
135+ // Restore arithmetic expressions
136+ result = result . replace ( / _ _ A R I T H _ ( \d + ) _ _ / g, ( _ , i ) => arithmeticExpressions [ parseInt ( i ) ] )
137+ // Restore parameter expansions
138+ result = result . replace ( / _ _ P A R A M _ ( \d + ) _ _ / g, ( _ , i ) => parameterExpansions [ parseInt ( i ) ] )
139+ // Restore variable references
140+ result = result . replace ( / _ _ V A R _ ( \d + ) _ _ / g, ( _ , i ) => variables [ parseInt ( i ) ] )
141+ result = result . replace ( / _ _ S U B S H _ ( \d + ) _ _ / g, ( _ , i ) => subshells [ parseInt ( i ) ] )
142+ return result
143+ }
144+
92145/**
93146 * Parse a single line of commands (internal helper function)
94147 */
@@ -103,7 +156,6 @@ function parseCommandLine(command: string): string[] {
103156 const arithmeticExpressions : string [ ] = [ ]
104157 const variables : string [ ] = [ ]
105158 const parameterExpansions : string [ ] = [ ]
106- const processSubstitutions : string [ ] = [ ]
107159
108160 // First handle PowerShell redirections by temporarily replacing them
109161 let processedCommand = command . replace ( / \d * > & \d * / g, ( match ) => {
@@ -118,6 +170,12 @@ function parseCommandLine(command: string): string[] {
118170 return `__ARITH_${ arithmeticExpressions . length - 1 } __`
119171 } )
120172
173+ // Handle $[...] arithmetic expressions (alternative syntax)
174+ processedCommand = processedCommand . replace ( / \$ \[ [ ^ \] ] * \] / g, ( match ) => {
175+ arithmeticExpressions . push ( match )
176+ return `__ARITH_${ arithmeticExpressions . length - 1 } __`
177+ } )
178+
121179 // Handle parameter expansions: ${...} patterns (including array indexing)
122180 // This covers ${var}, ${var:-default}, ${var:+alt}, ${#var}, ${var%pattern}, etc.
123181 processedCommand = processedCommand . replace ( / \$ \{ [ ^ } ] + \} / g, ( match ) => {
@@ -126,9 +184,9 @@ function parseCommandLine(command: string): string[] {
126184 } )
127185
128186 // Handle process substitutions: <(...) and >(...)
129- processedCommand = processedCommand . replace ( / [ < > ] \( [ ^ ) ] + \) / g, ( match ) => {
130- processSubstitutions . push ( match )
131- return `__PROCSUB_ ${ processSubstitutions . length - 1 } __`
187+ processedCommand = processedCommand . replace ( / [ < > ] \( ( [ ^ ) ] + ) \) / g, ( _ , inner ) => {
188+ subshells . push ( inner . trim ( ) )
189+ return `__SUBSH_ ${ subshells . length - 1 } __`
132190 } )
133191
134192 // Handle simple variable references: $varname pattern
@@ -144,7 +202,7 @@ function parseCommandLine(command: string): string[] {
144202 return `__VAR_${ variables . length - 1 } __`
145203 } )
146204
147- // Then handle subshell commands
205+ // Then handle subshell commands $() and back-ticks
148206 processedCommand = processedCommand
149207 . replace ( / \$ \( ( .* ?) \) / g, ( _ , inner ) => {
150208 subshells . push ( inner . trim ( ) )
@@ -175,24 +233,18 @@ function parseCommandLine(command: string): string[] {
175233 . filter ( ( cmd ) => cmd . length > 0 )
176234
177235 // Restore all placeholders for each command
178- return fallbackCommands . map ( ( cmd ) => {
179- let result = cmd
180- // Restore quotes
181- result = result . replace ( / _ _ Q U O T E _ ( \d + ) _ _ / g, ( _ , i ) => quotes [ parseInt ( i ) ] )
182- // Restore redirections
183- result = result . replace ( / _ _ R E D I R _ ( \d + ) _ _ / g, ( _ , i ) => redirections [ parseInt ( i ) ] )
184- // Restore array indexing expressions
185- result = result . replace ( / _ _ A R R A Y _ ( \d + ) _ _ / g, ( _ , i ) => arrayIndexing [ parseInt ( i ) ] )
186- // Restore arithmetic expressions
187- result = result . replace ( / _ _ A R I T H _ ( \d + ) _ _ / g, ( _ , i ) => arithmeticExpressions [ parseInt ( i ) ] )
188- // Restore parameter expansions
189- result = result . replace ( / _ _ P A R A M _ ( \d + ) _ _ / g, ( _ , i ) => parameterExpansions [ parseInt ( i ) ] )
190- // Restore process substitutions
191- result = result . replace ( / _ _ P R O C S U B _ ( \d + ) _ _ / g, ( _ , i ) => processSubstitutions [ parseInt ( i ) ] )
192- // Restore variable references
193- result = result . replace ( / _ _ V A R _ ( \d + ) _ _ / g, ( _ , i ) => variables [ parseInt ( i ) ] )
194- return result
195- } )
236+ return fallbackCommands . map ( ( cmd ) =>
237+ restorePlaceholders (
238+ cmd ,
239+ quotes ,
240+ redirections ,
241+ arrayIndexing ,
242+ arithmeticExpressions ,
243+ parameterExpansions ,
244+ variables ,
245+ subshells ,
246+ ) ,
247+ )
196248 }
197249
198250 const commands : string [ ] = [ ]
@@ -231,24 +283,18 @@ function parseCommandLine(command: string): string[] {
231283 }
232284
233285 // Restore quotes and redirections
234- return commands . map ( ( cmd ) => {
235- let result = cmd
236- // Restore quotes
237- result = result . replace ( / _ _ Q U O T E _ ( \d + ) _ _ / g, ( _ , i ) => quotes [ parseInt ( i ) ] )
238- // Restore redirections
239- result = result . replace ( / _ _ R E D I R _ ( \d + ) _ _ / g, ( _ , i ) => redirections [ parseInt ( i ) ] )
240- // Restore array indexing expressions
241- result = result . replace ( / _ _ A R R A Y _ ( \d + ) _ _ / g, ( _ , i ) => arrayIndexing [ parseInt ( i ) ] )
242- // Restore arithmetic expressions
243- result = result . replace ( / _ _ A R I T H _ ( \d + ) _ _ / g, ( _ , i ) => arithmeticExpressions [ parseInt ( i ) ] )
244- // Restore parameter expansions
245- result = result . replace ( / _ _ P A R A M _ ( \d + ) _ _ / g, ( _ , i ) => parameterExpansions [ parseInt ( i ) ] )
246- // Restore process substitutions
247- result = result . replace ( / _ _ P R O C S U B _ ( \d + ) _ _ / g, ( _ , i ) => processSubstitutions [ parseInt ( i ) ] )
248- // Restore variable references
249- result = result . replace ( / _ _ V A R _ ( \d + ) _ _ / g, ( _ , i ) => variables [ parseInt ( i ) ] )
250- return result
251- } )
286+ return commands . map ( ( cmd ) =>
287+ restorePlaceholders (
288+ cmd ,
289+ quotes ,
290+ redirections ,
291+ arrayIndexing ,
292+ arithmeticExpressions ,
293+ parameterExpansions ,
294+ variables ,
295+ subshells ,
296+ ) ,
297+ )
252298}
253299
254300/**
@@ -430,14 +476,6 @@ export function getCommandDecision(
430476) : CommandDecision {
431477 if ( ! command ?. trim ( ) ) return "auto_approve"
432478
433- // Check if subshells contain denied prefixes
434- if ( ( command . includes ( "$(" ) || command . includes ( "`" ) ) && deniedCommands ?. length ) {
435- const mainCommandLower = command . toLowerCase ( )
436- if ( deniedCommands . some ( ( denied ) => mainCommandLower . includes ( denied . toLowerCase ( ) ) ) ) {
437- return "auto_deny"
438- }
439- }
440-
441479 // Parse into sub-commands (split by &&, ||, ;, |)
442480 const subCommands = parseCommand ( command )
443481
@@ -610,7 +648,7 @@ export class CommandValidator {
610648 hasSubshells : boolean
611649 } {
612650 const subCommands = parseCommand ( command )
613- const hasSubshells = command . includes ( "$(" ) || command . includes ( "`" )
651+ const hasSubshells = containsSubshell ( command )
614652
615653 const allowedMatches = subCommands . map ( ( cmd ) => ( {
616654 command : cmd ,
0 commit comments