@@ -101,6 +101,121 @@ describe("recursive rm detection", () => {
101101 } ) ;
102102} ) ;
103103
104+ describe ( "clustered shell command options" , ( ) => {
105+ const payload = `cat $'.envrc'` ;
106+
107+ for ( const [ shell , options ] of [
108+ [ "zsh" , "-yc" ] ,
109+ [ "dash" , "-Vc" ] ,
110+ [ "ksh" , "-Gc" ] ,
111+ ] as const ) {
112+ test . skipIf ( Bun . which ( shell ) === null ) (
113+ `${ shell } ${ options } executes the following argument as a payload` ,
114+ ( ) => {
115+ const result = Bun . spawnSync ( [ shell , options , "printf clustered-ok" ] ) ;
116+ expect ( result . exitCode ) . toBe ( 0 ) ;
117+ expect ( result . stdout . toString ( ) ) . toBe ( "clustered-ok" ) ;
118+ } ,
119+ ) ;
120+ }
121+
122+ test ( "reconstructs double-quoted payloads with canonical and clustered options" , ( ) => {
123+ for ( const options of [ "-c" , "-lc" , "-xec" , "-cc" , "-cache" ] ) {
124+ const command = `bash ${ options } "cat \\$'.envrc'"` ;
125+ expect ( expandShellSubjects ( command ) ) . toEqual ( {
126+ subjects : [ command , payload ] ,
127+ opaque : false ,
128+ } ) ;
129+ }
130+ } ) ;
131+
132+ test ( "supports repeated command options for sh-compatible interpreters" , ( ) => {
133+ for ( const shell of [ "bash" , "sh" , "zsh" , "dash" , "ksh" ] ) {
134+ const command = `${ shell } -cc "find /"` ;
135+ expect ( expandShellSubjects ( command ) . subjects ) . toContain ( "find /" ) ;
136+ expect ( runShellAuthzBlockReason ( command ) ) . toMatch (
137+ / O p e n - e n d e d s h e l l s e a r c h b l o c k e d / ,
138+ ) ;
139+ }
140+ } ) ;
141+
142+ test ( "inspects interpreter-specific and conservative alphabetic clusters" , ( ) => {
143+ for ( const [ shell , options ] of [
144+ [ "zsh" , "-yc" ] ,
145+ [ "dash" , "-Vc" ] ,
146+ [ "ksh" , "-Gc" ] ,
147+ [ "bash" , "-zc" ] ,
148+ [ "bash" , "-lc" ] ,
149+ [ "sh" , "-ec" ] ,
150+ ] ) {
151+ const openEnded = `${ shell } ${ options } "find /"` ;
152+ expect ( expandShellSubjects ( openEnded ) . subjects ) . toContain ( "find /" ) ;
153+ expect ( runShellAuthzBlockReason ( openEnded ) ) . toMatch (
154+ / O p e n - e n d e d s h e l l s e a r c h b l o c k e d / ,
155+ ) ;
156+ expect (
157+ runShellAuthzBlockReason ( `${ shell } ${ options } "rm -rf /"` ) ,
158+ ) . toMatch ( / D e s t r u c t i v e c o m m a n d b l o c k e d / ) ;
159+ }
160+ } ) ;
161+
162+ test ( "reconstructs the complete shell word from adjacent fragments" , ( ) => {
163+ const cases = [
164+ { command : `bash -c "rm "'-rf /'` , payload : "rm -rf /" } ,
165+ { command : `bash -lc 'rm '"-rf /"` , payload : "rm -rf /" } ,
166+ { command : `bash -xec "fi"'nd /'` , payload : "find /" } ,
167+ { command : `bash -cc fi"nd /"` , payload : "find /" } ,
168+ {
169+ command : `env -u -c bash -c "fi"'nd /'` ,
170+ payload : "find /" ,
171+ } ,
172+ ] ;
173+
174+ for ( const { command, payload : expectedPayload } of cases ) {
175+ expect ( expandShellSubjects ( command ) ) . toEqual ( {
176+ subjects : [ command , expectedPayload ] ,
177+ opaque : false ,
178+ } ) ;
179+ }
180+ } ) ;
181+
182+ test ( "does not treat true lookalikes as command options" , ( ) => {
183+ for ( const command of [
184+ `bash script-c "find /"` ,
185+ `bash --rcfile "find /"` ,
186+ `bash -y+c "find /"` ,
187+ `bash -c1 "find /"` ,
188+ `bash script.sh -c "find /"` ,
189+ ] ) {
190+ expect ( expandShellSubjects ( command ) ) . toEqual ( {
191+ subjects : [ command ] ,
192+ opaque : false ,
193+ } ) ;
194+ }
195+ } ) ;
196+
197+ test ( "hard-denies complete adjacent-fragment payloads" , ( ) => {
198+ for ( const command of [
199+ `bash -c "rm "'-rf /'` ,
200+ `bash -lc 'rm '"-rf /"` ,
201+ `bash -xec "fi"'nd /'` ,
202+ `bash -cc fi"nd /"` ,
203+ ] ) {
204+ expect ( runShellAuthzBlockReason ( command ) ) . toMatch (
205+ / D e s t r u c t i v e c o m m a n d b l o c k e d | O p e n - e n d e d s h e l l s e a r c h b l o c k e d / ,
206+ ) ;
207+ }
208+ } ) ;
209+
210+ test ( "hard-denies clustered command payloads like canonical command payloads" , ( ) => {
211+ for ( const options of [ "-c" , "-lc" , "-xec" , "-cc" , "-cache" ] ) {
212+ expect ( runShellAuthzBlockReason ( `bash ${ options } "find /"` ) ) . toMatch (
213+ / O p e n - e n d e d s h e l l s e a r c h b l o c k e d / ,
214+ ) ;
215+ }
216+ } ) ;
217+ } ) ;
218+
104219describe ( "stdin-blocking with quote-aware tokenizeSegment" , ( ) => {
105220 test ( "unquoted readers with no file operand are blocked" , ( ) => {
106221 expect ( runShellAuthzBlockReason ( "cat" ) ) . toMatch ( / s t a n d a r d i n p u t / ) ;
@@ -388,6 +503,35 @@ describe("authz hard-deny peels glued and trailing env -S forms", () => {
388503 ) ;
389504 } ) ;
390505
506+ test ( "split payloads own trailing terminal-looking arguments" , ( ) => {
507+ const cases = [
508+ {
509+ command : `env -S "find ." --help` ,
510+ subject : "find . --help" ,
511+ reason : openEnded ,
512+ } ,
513+ {
514+ command : `env -S "rm -rf /" --version` ,
515+ subject : "rm -rf / --version" ,
516+ reason : destructive ,
517+ } ,
518+ {
519+ command : `env -S "npm install left-pad" --help` ,
520+ subject : "npm install left-pad --help" ,
521+ } ,
522+ ] ;
523+
524+ for ( const { command, subject, reason } of cases ) {
525+ expect ( expandShellSubjects ( command ) ) . toEqual ( {
526+ subjects : [ command , subject ] ,
527+ opaque : false ,
528+ } ) ;
529+ if ( reason !== undefined ) {
530+ expect ( runShellAuthzBlockReason ( command ) ) . toMatch ( reason ) ;
531+ }
532+ }
533+ } ) ;
534+
391535 test ( "G7: soft-allow non-catastrophic rm inside -S is not hard-denied" , ( ) => {
392536 expect (
393537 runShellAuthzBlockReason ( `env -S "rm -rf node_modules"` ) ,
@@ -433,6 +577,71 @@ describe("authz hard-deny peels glued and trailing env -S forms", () => {
433577 ) ;
434578 } ) ;
435579
580+ test ( "transparent time options and end-of-options expose hard-denied utilities" , ( ) => {
581+ expect ( runShellAuthzBlockReason ( `time -p find .` ) ) . toMatch ( openEnded ) ;
582+ expect ( runShellAuthzBlockReason ( `time -- find .` ) ) . toMatch ( openEnded ) ;
583+ expect ( runShellAuthzBlockReason ( `/usr/bin/time -o report find .` ) ) . toMatch (
584+ openEnded ,
585+ ) ;
586+ expect ( runShellAuthzBlockReason ( `/usr/bin/time -ao report find .` ) ) . toMatch (
587+ openEnded ,
588+ ) ;
589+ expect ( runShellAuthzBlockReason ( `time -f %e find .` ) ) . toMatch ( openEnded ) ;
590+ } ) ;
591+
592+ test ( "clustered env and timeout value options expose hard-denied utilities" , ( ) => {
593+ expect ( runShellAuthzBlockReason ( `env -iu PATH find .` ) ) . toMatch ( openEnded ) ;
594+ expect ( runShellAuthzBlockReason ( `timeout -vs KILL 1 find .` ) ) . toMatch (
595+ openEnded ,
596+ ) ;
597+ } ) ;
598+
599+ test ( "valid GNU timeout durations expose hard-denied utilities" , ( ) => {
600+ for ( const duration of [
601+ ".5s" ,
602+ "1" ,
603+ "1e3" ,
604+ "1e3s" ,
605+ "0x1p4" ,
606+ "2m" ,
607+ "3h" ,
608+ "4d" ,
609+ "inf" ,
610+ "infinity" ,
611+ ] ) {
612+ expect ( runShellAuthzBlockReason ( `timeout ${ duration } find .` ) ) . toMatch (
613+ openEnded ,
614+ ) ;
615+ }
616+ } ) ;
617+
618+ test ( "env value operands are parsed before terminal modes" , ( ) => {
619+ expect ( runShellAuthzBlockReason ( `env -u --help find .` ) ) . toMatch ( openEnded ) ;
620+ expect ( runShellAuthzBlockReason ( `env -C --version find .` ) ) . toMatch (
621+ openEnded ,
622+ ) ;
623+ } ) ;
624+
625+ test ( "env continues assignment parsing after end-of-options" , ( ) => {
626+ expect ( runShellAuthzBlockReason ( `env -- FILE=x find .` ) ) . toMatch ( openEnded ) ;
627+ expect ( runShellAuthzBlockReason ( `env -i -- FILE=x find .` ) ) . toMatch (
628+ openEnded ,
629+ ) ;
630+ } ) ;
631+
632+ test ( "terminal wrapper modes do not peel their operands as commands" , ( ) => {
633+ for ( const command of [
634+ "command --help find ." ,
635+ "command -p -v find" ,
636+ "command -pv find" ,
637+ "env --help find" ,
638+ "nice --help find" ,
639+ "timeout --help find" ,
640+ ] ) {
641+ expect ( runShellAuthzBlockReason ( command ) ) . toBeUndefined ( ) ;
642+ }
643+ } ) ;
644+
436645 test ( "G13: empty/whitespace -S payload with trailing utility is hard-denied" , ( ) => {
437646 // Runtime still executes the trailing utility; do not opaque-drop it.
438647 expect ( runShellAuthzBlockReason ( `env -S " " find /` ) ) . toMatch ( openEnded ) ;
@@ -463,6 +672,22 @@ describe("authz hard-deny peels glued and trailing env -S forms", () => {
463672 expect ( runShellAuthzBlockReason ( `env -S -v cat` ) ) . toMatch ( stdinHang ) ;
464673 } ) ;
465674
675+ test ( "G15b: clustered env value shorts inside -S consume the flag operand" , ( ) => {
676+ expect ( runShellAuthzBlockReason ( `env -S "-iu PATH find /"` ) ) . toMatch (
677+ openEnded ,
678+ ) ;
679+ expect ( runShellAuthzBlockReason ( `env -S "-iu PATH rm -rf /"` ) ) . toMatch (
680+ destructive ,
681+ ) ;
682+ expect ( runShellAuthzBlockReason ( `env -S "-iC /tmp find /"` ) ) . toMatch (
683+ openEnded ,
684+ ) ;
685+ expect ( runShellAuthzBlockReason ( `env -iu PATH find /` ) ) . toMatch ( openEnded ) ;
686+ expect ( runShellAuthzBlockReason ( `env -S "-i -u PATH find /"` ) ) . toMatch (
687+ openEnded ,
688+ ) ;
689+ } ) ;
690+
466691 test ( "G16: env -S quoted rm flags still hard-deny catastrophic targets" , ( ) => {
467692 expect ( runShellAuthzBlockReason ( `env -S "rm '-rf' '/'"` ) ) . toMatch (
468693 destructive ,
0 commit comments