@@ -123,9 +123,10 @@ export function parseCommand(command: string): string[] {
123123 currentCommand . push ( token . op )
124124 }
125125 } else if ( typeof token === "string" ) {
126- // Check if it's a subshell placeholder
126+ // Check if this is a subshell placeholder
127127 const subshellMatch = token . match ( / _ _ S U B S H _ ( \d + ) _ _ / )
128128 if ( subshellMatch ) {
129+ // Add the subshell command as a separate command
129130 if ( currentCommand . length > 0 ) {
130131 commands . push ( currentCommand . join ( " " ) )
131132 currentCommand = [ ]
@@ -279,6 +280,40 @@ export function isAutoDeniedSingleCommand(
279280 return longestDeniedMatch . length >= longestAllowedMatch . length
280281}
281282
283+ /**
284+ * Check if a command contains subshell execution that should be blocked.
285+ * Only blocks if there's a denylist configured and the command isn't just outputting text.
286+ */
287+ function containsBlockableSubshell ( command : string , deniedCommands ?: string [ ] ) : boolean {
288+ if ( ! deniedCommands ?. length ) return false
289+
290+ const trimmedCommand = command . trim ( )
291+ const isTextOutputCommand =
292+ / ^ ( e c h o | p r i n t f | c a t | p r i n t ) \s + [ " ' ] / . test ( trimmedCommand ) || / ^ ( e c h o | p r i n t f | c a t | p r i n t ) \s + \\ ? " / . test ( trimmedCommand )
293+
294+ if ( isTextOutputCommand ) return false
295+
296+ // Look for actual command substitution $()
297+ const hasCommandSubstitution = / \$ \( [ ^ ) ] + \) / . test ( command )
298+
299+ // For backticks, be more careful - they could be in markdown
300+ let hasBacktickSubstitution = false
301+ if ( command . includes ( "`" ) ) {
302+ // Simple heuristic: if the command has multi-line content or markdown-like
303+ // patterns, the backticks are probably not for command substitution
304+ const hasMarkdownIndicators =
305+ command . includes ( "```" ) || command . includes ( "\n" ) || command . includes ( "##" ) || command . includes ( "**" )
306+
307+ if ( ! hasMarkdownIndicators ) {
308+ // Check if backticks are likely command substitution
309+ // Look for patterns like: cmd `subcmd` or var=`cmd`
310+ hasBacktickSubstitution = / [ ^ \\ ] ` [ ^ ` \n ] + ` / . test ( command )
311+ }
312+ }
313+
314+ return hasCommandSubstitution || hasBacktickSubstitution
315+ }
316+
282317/**
283318 * Check if a command string should be auto-approved.
284319 * Only blocks subshell attempts if there's a denylist configured.
@@ -288,7 +323,7 @@ export function isAutoApprovedCommand(command: string, allowedCommands: string[]
288323 if ( ! command ?. trim ( ) ) return true
289324
290325 // Only block subshell execution attempts if there's a denylist configured
291- if ( ( command . includes ( "$(" ) || command . includes ( "`" ) ) && deniedCommands ?. length ) {
326+ if ( containsBlockableSubshell ( command , deniedCommands ) ) {
292327 return false
293328 }
294329
@@ -313,7 +348,7 @@ export function isAutoDeniedCommand(command: string, allowedCommands: string[],
313348 if ( ! command ?. trim ( ) ) return false
314349
315350 // Only block subshell execution attempts if there's a denylist configured
316- if ( ( command . includes ( "$(" ) || command . includes ( "`" ) ) && deniedCommands ?. length ) {
351+ if ( containsBlockableSubshell ( command , deniedCommands ) ) {
317352 return true
318353 }
319354
@@ -385,7 +420,7 @@ export function getCommandDecision(
385420 if ( ! command ?. trim ( ) ) return "auto_approve"
386421
387422 // Only block subshell execution attempts if there's a denylist configured
388- if ( ( command . includes ( "$(" ) || command . includes ( "`" ) ) && deniedCommands ?. length ) {
423+ if ( containsBlockableSubshell ( command , deniedCommands ) ) {
389424 return "auto_deny"
390425 }
391426
0 commit comments