@@ -10,6 +10,7 @@ const WRAPPER_BINARIES = new Set([
1010 "nohup" ,
1111 "sudo" ,
1212] ) ;
13+ const SHELL_BINARIES = new Set ( [ "sh" , "bash" ] ) ;
1314
1415interface PolicyMatch {
1516 id : string ;
@@ -81,18 +82,20 @@ export async function evaluateBashCommandPolicy({
8182 const activePolicies = buildActivePolicies ( policyConfig , presetCatalog ) ;
8283
8384 for ( const segment of segments ) {
84- const normalizedArgv = normalizeSegment ( segment ) ;
85- if ( ! normalizedArgv || normalizedArgv . length === 0 ) {
86- continue ;
87- }
85+ const normalizedArgvCandidates = normalizeSegment ( segment ) ;
86+ for ( const normalizedArgv of normalizedArgvCandidates ) {
87+ if ( normalizedArgv . length === 0 ) {
88+ continue ;
89+ }
8890
89- const match = selectMatchingPolicy ( activePolicies , normalizedArgv ) ;
90- if ( match ) {
91- return {
92- allowed : false ,
93- normalizedArgv,
94- policy : match ,
95- } ;
91+ const match = selectMatchingPolicy ( activePolicies , normalizedArgv ) ;
92+ if ( match ) {
93+ return {
94+ allowed : false ,
95+ normalizedArgv,
96+ policy : match ,
97+ } ;
98+ }
9699 }
97100 }
98101
@@ -101,9 +104,9 @@ export async function evaluateBashCommandPolicy({
101104 } ;
102105}
103106
104- function normalizeSegment ( segment : string [ ] ) : string [ ] | null {
107+ function normalizeSegment ( segment : string [ ] ) : string [ ] [ ] {
105108 if ( segment . length === 0 ) {
106- return null ;
109+ return [ ] ;
107110 }
108111
109112 const normalized = [ ...segment ] ;
@@ -120,11 +123,78 @@ function normalizeSegment(segment: string[]): string[] | null {
120123 }
121124
122125 if ( normalized . length === 0 ) {
123- return null ;
126+ return [ ] ;
124127 }
125128
126129 normalized [ 0 ] = path . basename ( normalized [ 0 ] ?? "" ) ;
127- return normalized ;
130+
131+ const nestedSegments = unwrapNestedCommandSegments ( normalized ) ;
132+ if ( ! nestedSegments ) {
133+ return [ normalized ] ;
134+ }
135+
136+ const nestedNormalized : string [ ] [ ] = [ ] ;
137+ for ( const nestedSegment of nestedSegments ) {
138+ nestedNormalized . push ( ...normalizeSegment ( nestedSegment ) ) ;
139+ }
140+
141+ return nestedNormalized . length > 0 ? nestedNormalized : [ normalized ] ;
142+ }
143+
144+ function unwrapNestedCommandSegments ( segment : string [ ] ) : string [ ] [ ] | null {
145+ const executable = segment [ 0 ] ;
146+ if ( ! executable ) {
147+ return null ;
148+ }
149+
150+ if ( executable === "nix" ) {
151+ const nestedArgv = extractNixCommandArgv ( segment ) ;
152+ return nestedArgv ? [ nestedArgv ] : null ;
153+ }
154+
155+ if ( SHELL_BINARIES . has ( executable ) ) {
156+ const shellPayload = extractShellCommandPayload ( segment ) ;
157+ if ( ! shellPayload ) {
158+ return null ;
159+ }
160+
161+ return parseCommandSegments ( shellPayload ) ;
162+ }
163+
164+ return null ;
165+ }
166+
167+ function extractNixCommandArgv ( segment : string [ ] ) : string [ ] | null {
168+ for ( let index = 1 ; index < segment . length ; index += 1 ) {
169+ const token = segment [ index ] ;
170+ if ( token !== "-c" && token !== "--command" ) {
171+ continue ;
172+ }
173+
174+ const nestedArgv = segment . slice ( index + 1 ) ;
175+ return nestedArgv . length > 0 ? nestedArgv : null ;
176+ }
177+
178+ return null ;
179+ }
180+
181+ function extractShellCommandPayload ( segment : string [ ] ) : string | null {
182+ for ( let index = 1 ; index < segment . length ; index += 1 ) {
183+ const token = segment [ index ] ;
184+ if ( ! token || token === "--" ) {
185+ continue ;
186+ }
187+
188+ if ( token === "-c" ) {
189+ return segment [ index + 1 ] ?? null ;
190+ }
191+
192+ if ( token . startsWith ( "-" ) && token . includes ( "c" ) ) {
193+ return segment [ index + 1 ] ?? null ;
194+ }
195+ }
196+
197+ return null ;
128198}
129199
130200export function formatPolicyBlockMessage ( match : PolicyMatch ) : string {
@@ -439,16 +509,28 @@ function tokenizeShellCommand(command: string): string[] | null {
439509 let current = "" ;
440510 let quote : string | null = null ;
441511 let escaping = false ;
512+ let index = 0 ;
513+
514+ const pushCurrent = ( ) => {
515+ if ( current . length > 0 ) {
516+ tokens . push ( current ) ;
517+ current = "" ;
518+ }
519+ } ;
520+
521+ while ( index < command . length ) {
522+ const character = command [ index ] ?? "" ;
442523
443- for ( const character of command ) {
444524 if ( escaping ) {
445525 current += character ;
446526 escaping = false ;
527+ index += 1 ;
447528 continue ;
448529 }
449530
450531 if ( character === "\\" && quote !== "'" ) {
451532 escaping = true ;
533+ index += 1 ;
452534 continue ;
453535 }
454536
@@ -458,32 +540,49 @@ function tokenizeShellCommand(command: string): string[] | null {
458540 } else {
459541 current += character ;
460542 }
543+ index += 1 ;
461544 continue ;
462545 }
463546
464547 if ( character === '"' || character === "'" ) {
465548 quote = character ;
549+ index += 1 ;
466550 continue ;
467551 }
468552
469553 if ( / \s / . test ( character ) ) {
470- if ( current . length > 0 ) {
471- tokens . push ( current ) ;
472- current = "" ;
554+ pushCurrent ( ) ;
555+ index += 1 ;
556+ continue ;
557+ }
558+
559+ if ( character === "&" || character === "|" || character === ";" ) {
560+ pushCurrent ( ) ;
561+
562+ const nextCharacter = command [ index + 1 ] ?? "" ;
563+ if (
564+ ( character === "&" || character === "|" ) &&
565+ nextCharacter === character
566+ ) {
567+ tokens . push ( character + nextCharacter ) ;
568+ index += 2 ;
569+ continue ;
473570 }
571+
572+ tokens . push ( character ) ;
573+ index += 1 ;
474574 continue ;
475575 }
476576
477577 current += character ;
578+ index += 1 ;
478579 }
479580
480581 if ( escaping || quote ) {
481582 return null ;
482583 }
483584
484- if ( current . length > 0 ) {
485- tokens . push ( current ) ;
486- }
585+ pushCurrent ( ) ;
487586
488587 return tokens ;
489588}
@@ -505,52 +604,6 @@ function isShellOperator(token: string): boolean {
505604 return SHELL_OPERATORS . has ( token ) ;
506605}
507606
508- /**
509- * Splits a token that may contain embedded operators (e.g., "ls;" -> ["ls", ";"])
510- */
511- function splitTokenWithEmbeddedOperators ( token : string ) : string [ ] {
512- const result : string [ ] = [ ] ;
513- let current = "" ;
514- let i = 0 ;
515-
516- while ( i < token . length ) {
517- // Check for multi-character operators first (&&, ||)
518- if ( i + 1 < token . length ) {
519- const twoChar = token . slice ( i , i + 2 ) ;
520- if ( twoChar === "&&" || twoChar === "||" ) {
521- if ( current . length > 0 ) {
522- result . push ( current ) ;
523- current = "" ;
524- }
525- result . push ( twoChar ) ;
526- i += 2 ;
527- continue ;
528- }
529- }
530-
531- // Check for single-character operators
532- const char = token [ i ] ;
533- if ( SHELL_OPERATORS . has ( char ) ) {
534- if ( current . length > 0 ) {
535- result . push ( current ) ;
536- current = "" ;
537- }
538- result . push ( char ) ;
539- i ++ ;
540- continue ;
541- }
542-
543- current += char ;
544- i ++ ;
545- }
546-
547- if ( current . length > 0 ) {
548- result . push ( current ) ;
549- }
550-
551- return result ;
552- }
553-
554607/**
555608 * Parses a command string into segments separated by shell control operators.
556609 *
@@ -571,17 +624,10 @@ export function parseCommandSegments(command: string): string[][] | null {
571624 return null ;
572625 }
573626
574- // Flatten tokens that may contain embedded operators (e.g., "ls;" -> ["ls", ";"])
575- const flattenedTokens : string [ ] = [ ] ;
576- for ( const token of tokens ) {
577- const splitTokens = splitTokenWithEmbeddedOperators ( token ) ;
578- flattenedTokens . push ( ...splitTokens ) ;
579- }
580-
581627 const segments : string [ ] [ ] = [ ] ;
582628 let currentSegment : string [ ] = [ ] ;
583629
584- for ( const token of flattenedTokens ) {
630+ for ( const token of tokens ) {
585631 if ( isShellOperator ( token ) ) {
586632 // Start a new segment, skipping empty segments (consecutive operators)
587633 if ( currentSegment . length > 0 ) {
0 commit comments