@@ -269,10 +269,13 @@ const RM_WRAPPER = /^(sudo|command|env|exec|builtin|time|nice|nohup)$/;
269269const RECURSIVE_FLAG = / ^ ( - - r e c u r s i v e | - [ A - Z a - z ] * [ r R ] [ A - Z a - z ] * ) $ / ;
270270
271271// Interpreters whose `-c` / `--command` payload is an independent shell subject.
272- const SHELL_INTERPRETERS = new Set ( [ "bash" , "sh" , "zsh" , "dash" , "ksh" ] ) ;
272+ // Exported so tests and callers share one explicit list with the peeler.
273+ export const SHELL_INTERPRETERS = new Set ( [ "bash" , "sh" , "zsh" , "dash" , "ksh" ] ) ;
273274// Transparent prefixes that sit in front of a real program without changing it.
274275const PREFIX_WRAPPERS = new Set ( [ "command" , "env" , "builtin" , "time" , "nice" , "nohup" , "timeout" ] ) ;
275- const MAX_PEEL_DEPTH = 4 ;
276+ // Max recursive peel depth for nested wrappers. Exported so the depth cap is a
277+ // named policy knob tests can assert against, not a magic number.
278+ export const MAX_PEEL_DEPTH = 4 ;
276279
277280// xargs flags that consume the following token as a value.
278281const XARGS_VALUE_FLAGS = new Set ( [
@@ -371,6 +374,43 @@ function rejoinTokens(tokens: string[]): string | null {
371374 return quoted . join ( " " ) ;
372375}
373376
377+ // True when a token is a safe `$0`/`$1` positional after `bash -c 'script'` —
378+ // a plain word with no shell syntax. Anything else after the -c payload is
379+ // treated as evidence the quoted body was split by a tokenizer that does not
380+ // honor backslash-escapes (classic `bash -c "…\"…"` degradation).
381+ function isSafeShellPositional ( token : string ) : boolean {
382+ if ( token . startsWith ( "-" ) && token !== "-" ) return false ;
383+ if ( token . includes ( "\\" ) ) return false ;
384+ if ( / [ > < | & ; ` $ ] / . test ( token ) ) return false ;
385+ return SAFE_REJOIN_TOKEN . test ( token ) ;
386+ }
387+
388+ // `\bash` / `\sh` — tokenize artifact from peeling through an escaped quote.
389+ function isBackslashInterpreterToken ( token : string ) : boolean {
390+ const base = programBasename ( token ) ;
391+ return base . startsWith ( "\\" ) && SHELL_INTERPRETERS . has ( base . slice ( 1 ) ) ;
392+ }
393+
394+ function shellPayloadReferencesPositional ( payload : string ) : boolean {
395+ return / \$ (?: [ 0 - 9 @ * # ] | \{ (?: [ 0 - 9 ] + | [ @ * # ] ) \} ) / . test ( payload ) ;
396+ }
397+
398+ function nestedInterpreterPayloadOpaque ( payload : string , rest : readonly string [ ] ) : boolean {
399+ if ( isBackslashInterpreterToken ( payload ) ) return true ;
400+ const first = tokenize ( payload ) [ 0 ] ;
401+ if ( first !== undefined && isBackslashInterpreterToken ( first ) ) return true ;
402+ // The payload can execute trailing argv through $0/$1/... substitution, so
403+ // the payload alone is not a faithful subject for dependency-install policy.
404+ if ( rest . length > 0 && shellPayloadReferencesPositional ( payload ) ) return true ;
405+ // Trailing tokens after the -c payload: allow only plain positionals.
406+ // `-c`, redirects, backslashes, or flags mean the quoted body was split and
407+ // the truncated payload must not be trusted on its own under auto.
408+ if ( rest . length > 0 && ! rest . every ( isSafeShellPositional ) ) return true ;
409+ return false ;
410+ }
411+
412+ const SHELL_SEPARATE_VALUE_FLAGS = new Set ( [ "-O" , "-o" ] ) ;
413+
374414function peelShellDashC ( tokens : string [ ] , start : number ) : PeelOutcome {
375415 let i = start ;
376416 while ( i < tokens . length ) {
@@ -382,18 +422,28 @@ function peelShellDashC(tokens: string[], start: number): PeelOutcome {
382422 if ( t === "-c" || t === "--command" ) {
383423 const payload = tokens [ i + 1 ] ;
384424 if ( payload === undefined || isOpaquePayload ( payload ) ) return { kind : "opaque" } ;
425+ const rest = tokens . slice ( i + 2 ) ;
426+ if ( nestedInterpreterPayloadOpaque ( payload , rest ) ) return { kind : "opaque" } ;
385427 return { kind : "inner" , command : payload } ;
386428 }
387429 if ( t . startsWith ( "--command=" ) ) {
388430 const payload = t . slice ( "--command=" . length ) ;
389431 if ( isOpaquePayload ( payload ) ) return { kind : "opaque" } ;
432+ // Glued `--command=` has no separate rest tokens; still reject `\bash`.
433+ if ( nestedInterpreterPayloadOpaque ( payload , [ ] ) ) return { kind : "opaque" } ;
390434 return { kind : "inner" , command : payload } ;
391435 }
436+ if ( SHELL_SEPARATE_VALUE_FLAGS . has ( t ) ) {
437+ i += 2 ;
438+ continue ;
439+ }
392440 // Clustered short flags that include `c` (`-lc`, `-ic`, …): `c` takes the
393441 // next token as the command string, matching bash/sh/zsh.
394442 if ( / ^ - [ A - Z a - z ] * c [ A - Z a - z ] * $ / . test ( t ) ) {
395443 const payload = tokens [ i + 1 ] ;
396444 if ( payload === undefined || isOpaquePayload ( payload ) ) return { kind : "opaque" } ;
445+ const rest = tokens . slice ( i + 2 ) ;
446+ if ( nestedInterpreterPayloadOpaque ( payload , rest ) ) return { kind : "opaque" } ;
397447 return { kind : "inner" , command : payload } ;
398448 }
399449 if ( t . startsWith ( "-" ) && t !== "-" ) {
@@ -808,7 +858,15 @@ export function expandShellSubjects(command: string, maxDepth = MAX_PEEL_DEPTH):
808858 seen . add ( stripped ) ;
809859 subjects . push ( stripped ) ;
810860 }
811- if ( depth >= maxDepth ) return ;
861+ if ( depth >= maxDepth ) {
862+ // Depth exhausted while this subject may still be a nested interpreter
863+ // wrapper. Mark opaque so auto cannot accept a leaf we never fully peeled.
864+ for ( const segment of splitChainedCommand ( trimmed ) ) {
865+ const peeled = peelOnce ( segment ) ;
866+ if ( peeled . kind === "inner" || peeled . kind === "opaque" ) opaque = true ;
867+ }
868+ return ;
869+ }
812870
813871 for ( const segment of splitChainedCommand ( trimmed ) ) {
814872 const peeled = peelOnce ( segment ) ;
0 commit comments