@@ -359,9 +359,24 @@ const ENV_ASSIGNMENT = /^\w+=/;
359359const RM_WRAPPER = / ^ ( s u d o | c o m m a n d | e n v | e x e c | b u i l t i n | t i m e | n i c e | n o h u p ) $ / ;
360360const RECURSIVE_FLAG = / ^ ( - - r e c u r s i v e | - [ A - Z a - z ] * [ r R ] [ A - Z a - z ] * ) $ / ;
361361
362- // Interpreters whose `-c` / `--command` payload is an independent shell subject.
363- // Exported so tests and callers share one explicit list with the peeler.
364- export const SHELL_INTERPRETERS = new Set ( [ "bash" , "sh" , "zsh" , "dash" , "ksh" ] ) ;
362+ // Interpreters whose `-c` / `--command` / cmd `/c` payload is an independent
363+ // shell subject. Exported so tests and callers share one explicit list with
364+ // the peeler. Matching is basename-based and ignores Windows executable
365+ // suffixes (`cmd.exe` → `cmd`).
366+ export const SHELL_INTERPRETERS = new Set ( [
367+ "bash" ,
368+ "sh" ,
369+ "zsh" ,
370+ "dash" ,
371+ "ksh" ,
372+ "ash" ,
373+ "fish" ,
374+ "csh" ,
375+ "tcsh" ,
376+ "pwsh" ,
377+ "powershell" ,
378+ "cmd" ,
379+ ] ) ;
365380// Max recursive peel depth for nested wrappers. Exported so the depth cap is a
366381// named policy knob tests can assert against, not a magic number.
367382export const MAX_PEEL_DEPTH = 4 ;
@@ -472,10 +487,30 @@ function isSafeShellPositional(token: string): boolean {
472487 return SAFE_REJOIN_TOKEN . test ( token ) ;
473488}
474489
490+ const INTERPRETER_SUFFIX = / \. (?: e x e | c m d | c o m | b a t ) $ / i;
491+ const CMD_INTERPRETERS = new Set ( [ "cmd" ] ) ;
492+ const PWSH_INTERPRETERS = new Set ( [ "pwsh" , "powershell" ] ) ;
493+
494+ function shellInterpreterName ( token : string ) : string {
495+ return programBasename ( token ) . replace ( INTERPRETER_SUFFIX , "" ) . toLowerCase ( ) ;
496+ }
497+
498+ function isInterpreterCommandSwitch (
499+ interpreter : string ,
500+ token : string ,
501+ ) : boolean {
502+ if ( token === "-c" || token === "--command" ) return true ;
503+ if ( CMD_INTERPRETERS . has ( interpreter ) && / ^ \/ [ c k ] $ / i. test ( token ) ) return true ;
504+ if ( PWSH_INTERPRETERS . has ( interpreter ) && / ^ - c o m m a n d $ / i. test ( token ) )
505+ return true ;
506+ return false ;
507+ }
508+
475509// `\bash` / `\sh` — tokenize artifact from peeling through an escaped quote.
476510function isBackslashInterpreterToken ( token : string ) : boolean {
477511 const base = programBasename ( token ) ;
478- return base . startsWith ( "\\" ) && SHELL_INTERPRETERS . has ( base . slice ( 1 ) ) ;
512+ if ( ! base . startsWith ( "\\" ) ) return false ;
513+ return SHELL_INTERPRETERS . has ( shellInterpreterName ( base . slice ( 1 ) ) ) ;
479514}
480515
481516function shellPayloadReferencesPositional ( payload : string ) : boolean {
@@ -593,6 +628,7 @@ function peelShellDashC(
593628 tokens : string [ ] ,
594629 start : number ,
595630 rawSegment : string ,
631+ interpreter : string ,
596632) : PeelOutcome {
597633 let i = start ;
598634 while ( i < tokens . length ) {
@@ -602,7 +638,7 @@ function peelShellDashC(
602638 i ++ ;
603639 break ;
604640 }
605- if ( t === "-c" || t === "--command" ) {
641+ if ( isInterpreterCommandSwitch ( interpreter , t ) ) {
606642 const tokenPayload = tokens [ i + 1 ] ;
607643 if ( tokenPayload === undefined ) return { kind : "opaque" } ;
608644 const optionOccurrence = tokens
@@ -955,7 +991,7 @@ function peelOnce(segment: string): PeelOutcome {
955991 const current = tokens [ i ] ;
956992 if ( current === undefined )
957993 return strippedPrefix ? { kind : "opaque" } : { kind : "none" } ;
958- const prog = programBasename ( current ) ;
994+ const prog = shellInterpreterName ( current ) ;
959995 if ( SHELL_INTERPRETERS . has ( prog ) ) {
960996 // A backtick or `$(` anywhere in the raw segment means the -c payload may
961997 // contain command substitution. tokenize() surfaces substitution content as
@@ -966,7 +1002,7 @@ function peelOnce(segment: string): PeelOutcome {
9661002 // wrapper as opaque rather than risk peeling a truncated, misleading payload.
9671003 if ( segment . includes ( "`" ) || segment . includes ( "$(" ) )
9681004 return { kind : "opaque" } ;
969- const shellPeel = peelShellDashC ( tokens , i + 1 , segment ) ;
1005+ const shellPeel = peelShellDashC ( tokens , i + 1 , segment , prog ) ;
9701006 if ( shellPeel . kind !== "none" ) return shellPeel ;
9711007 // Interpreter without -c (e.g. `bash script.sh`) — not a peelable wrapper.
9721008 return { kind : "none" } ;
@@ -990,8 +1026,9 @@ export interface ShellExpandResult {
9901026}
9911027
9921028// Expand a shell command into subjects the auto-shell policy, hard-deny, and
993- // recursive-rm checks should scan. Peels bash/sh/zsh/dash/ksh -c, xargs
994- // utility tails, env -S/--split-string payloads, and transparent prefixes
1029+ // recursive-rm checks should scan. Peels nested interpreters (`bash`/`fish`/
1030+ // `cmd` `/c` and the rest of SHELL_INTERPRETERS), xargs utility tails, env
1031+ // -S/--split-string payloads, busybox applets, and transparent prefixes
9951032// (env/nice/timeout/…), recursing with a depth cap so nested wrappers cannot
9961033// hide a dangerous payload.
9971034//
0 commit comments