@@ -360,9 +360,24 @@ const ENV_ASSIGNMENT = /^\w+=/;
360360const 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 ) $ / ;
361361const RECURSIVE_FLAG = / ^ ( - - r e c u r s i v e | - [ A - Z a - z ] * [ r R ] [ A - Z a - z ] * ) $ / ;
362362
363- // Interpreters whose `-c` / `--command` payload is an independent shell subject.
364- // Exported so tests and callers share one explicit list with the peeler.
365- export const SHELL_INTERPRETERS = new Set ( [ "bash" , "sh" , "zsh" , "dash" , "ksh" ] ) ;
363+ // Interpreters whose `-c` / `--command` / cmd `/c` payload is an independent
364+ // shell subject. Exported so tests and callers share one explicit list with
365+ // the peeler. Matching is basename-based and ignores Windows executable
366+ // suffixes (`cmd.exe` → `cmd`).
367+ export const SHELL_INTERPRETERS = new Set ( [
368+ "bash" ,
369+ "sh" ,
370+ "zsh" ,
371+ "dash" ,
372+ "ksh" ,
373+ "ash" ,
374+ "fish" ,
375+ "csh" ,
376+ "tcsh" ,
377+ "pwsh" ,
378+ "powershell" ,
379+ "cmd" ,
380+ ] ) ;
366381// Max recursive peel depth for nested wrappers. Exported so the depth cap is a
367382// named policy knob tests can assert against, not a magic number.
368383export const MAX_PEEL_DEPTH = 4 ;
@@ -473,10 +488,30 @@ function isSafeShellPositional(token: string): boolean {
473488 return SAFE_REJOIN_TOKEN . test ( token ) ;
474489}
475490
491+ const INTERPRETER_SUFFIX = / \. (?: e x e | c m d | c o m | b a t ) $ / i;
492+ const CMD_INTERPRETERS = new Set ( [ "cmd" ] ) ;
493+ const PWSH_INTERPRETERS = new Set ( [ "pwsh" , "powershell" ] ) ;
494+
495+ function shellInterpreterName ( token : string ) : string {
496+ return programBasename ( token ) . replace ( INTERPRETER_SUFFIX , "" ) . toLowerCase ( ) ;
497+ }
498+
499+ function isInterpreterCommandSwitch (
500+ interpreter : string ,
501+ token : string ,
502+ ) : boolean {
503+ if ( token === "-c" || token === "--command" ) return true ;
504+ if ( CMD_INTERPRETERS . has ( interpreter ) && / ^ \/ [ c k ] $ / i. test ( token ) ) return true ;
505+ if ( PWSH_INTERPRETERS . has ( interpreter ) && / ^ - c o m m a n d $ / i. test ( token ) )
506+ return true ;
507+ return false ;
508+ }
509+
476510// `\bash` / `\sh` — tokenize artifact from peeling through an escaped quote.
477511function isBackslashInterpreterToken ( token : string ) : boolean {
478512 const base = programBasename ( token ) ;
479- return base . startsWith ( "\\" ) && SHELL_INTERPRETERS . has ( base . slice ( 1 ) ) ;
513+ if ( ! base . startsWith ( "\\" ) ) return false ;
514+ return SHELL_INTERPRETERS . has ( shellInterpreterName ( base . slice ( 1 ) ) ) ;
480515}
481516
482517function shellPayloadReferencesPositional ( payload : string ) : boolean {
@@ -594,6 +629,7 @@ function peelShellDashC(
594629 tokens : string [ ] ,
595630 start : number ,
596631 rawSegment : string ,
632+ interpreter : string ,
597633) : PeelOutcome {
598634 let i = start ;
599635 while ( i < tokens . length ) {
@@ -603,7 +639,7 @@ function peelShellDashC(
603639 i ++ ;
604640 break ;
605641 }
606- if ( t === "-c" || t === "--command" ) {
642+ if ( isInterpreterCommandSwitch ( interpreter , t ) ) {
607643 const tokenPayload = tokens [ i + 1 ] ;
608644 if ( tokenPayload === undefined ) return { kind : "opaque" } ;
609645 const optionOccurrence = tokens
@@ -935,7 +971,7 @@ function peelOnce(segment: string): PeelOutcome {
935971 const current = tokens [ i ] ;
936972 if ( current === undefined )
937973 return strippedPrefix ? { kind : "opaque" } : { kind : "none" } ;
938- const prog = programBasename ( current ) ;
974+ const prog = shellInterpreterName ( current ) ;
939975 if ( SHELL_INTERPRETERS . has ( prog ) ) {
940976 // A backtick or `$(` anywhere in the raw segment means the -c payload may
941977 // contain command substitution. tokenize() surfaces substitution content as
@@ -946,7 +982,7 @@ function peelOnce(segment: string): PeelOutcome {
946982 // wrapper as opaque rather than risk peeling a truncated, misleading payload.
947983 if ( segment . includes ( "`" ) || segment . includes ( "$(" ) )
948984 return { kind : "opaque" } ;
949- const shellPeel = peelShellDashC ( tokens , i + 1 , segment ) ;
985+ const shellPeel = peelShellDashC ( tokens , i + 1 , segment , prog ) ;
950986 if ( shellPeel . kind !== "none" ) return shellPeel ;
951987 // Interpreter without -c (e.g. `bash script.sh`) — not a peelable wrapper.
952988 return { kind : "none" } ;
@@ -979,8 +1015,9 @@ export interface ShellExpandResult {
9791015}
9801016
9811017// Expand a shell command into subjects the auto-shell policy, hard-deny, and
982- // recursive-rm checks should scan. Peels bash/sh/zsh/dash/ksh -c, xargs
983- // utility tails, env -S/--split-string payloads, and transparent prefixes
1018+ // recursive-rm checks should scan. Peels nested interpreters (`bash`/`fish`/
1019+ // `cmd` `/c` and the rest of SHELL_INTERPRETERS), xargs utility tails, env
1020+ // -S/--split-string payloads, busybox applets, and transparent prefixes
9841021// (env/nice/timeout/…), recursing with a depth cap so nested wrappers cannot
9851022// hide a dangerous payload.
9861023//
0 commit comments