@@ -592,6 +592,36 @@ fn classify_git_command(command: &str) -> CommandIntent {
592592/// Returns the first non-Allow result, or Allow if all validations pass.
593593#[ must_use]
594594pub fn validate_command ( command : & str , mode : PermissionMode , workspace : & Path ) -> ValidationResult {
595+ // T1.5: Split compound pipelines (`&&`, `||`, `;`, `|`, `&`) at top level
596+ // and validate each segment independently. Without this, a malicious
597+ // chain like `ls && rm -rf /` passes because only the first command is
598+ // inspected.
599+ let segments = split_bash_pipeline ( command) ;
600+ if segments. len ( ) <= 1 {
601+ return validate_command_segment ( command, mode, workspace) ;
602+ }
603+ let mut deferred_warn: Option < ValidationResult > = None ;
604+ for segment in segments {
605+ match validate_command_segment ( segment, mode, workspace) {
606+ ValidationResult :: Allow => { }
607+ block @ ValidationResult :: Block { .. } => return block,
608+ warn @ ValidationResult :: Warn { .. } => {
609+ if deferred_warn. is_none ( ) {
610+ deferred_warn = Some ( warn) ;
611+ }
612+ }
613+ }
614+ }
615+ deferred_warn. unwrap_or ( ValidationResult :: Allow )
616+ }
617+
618+ /// Validate a single command segment (the original pre-T1.5 implementation,
619+ /// preserved unchanged so single-command inputs behave identically).
620+ fn validate_command_segment (
621+ command : & str ,
622+ mode : PermissionMode ,
623+ workspace : & Path ,
624+ ) -> ValidationResult {
595625 // 1. Mode-level validation (includes read-only checks).
596626 let result = validate_mode ( command, mode) ;
597627 if result != ValidationResult :: Allow {
@@ -614,6 +644,73 @@ pub fn validate_command(command: &str, mode: PermissionMode, workspace: &Path) -
614644 validate_paths ( command, workspace)
615645}
616646
647+ /// Split a bash command at top-level chain/pipe operators, ignoring separators
648+ /// that appear inside single quotes, double quotes, backticks, or after a
649+ /// backslash escape. Recognised separators: `&&`, `||`, `;`, `|`, `&`.
650+ /// Returns trimmed, non-empty segments in order.
651+ fn split_bash_pipeline ( command : & str ) -> Vec < & str > {
652+ let bytes = command. as_bytes ( ) ;
653+ let mut segments: Vec < & str > = Vec :: new ( ) ;
654+ let mut start: usize = 0 ;
655+ let mut i: usize = 0 ;
656+ let mut in_single = false ;
657+ let mut in_double = false ;
658+ let mut in_backtick = false ;
659+ while i < bytes. len ( ) {
660+ let c = bytes[ i] ;
661+ // Backslash escape (outside single quotes, where it is literal)
662+ if c == b'\\' && !in_single && i + 1 < bytes. len ( ) {
663+ i += 2 ;
664+ continue ;
665+ }
666+ if !in_double && !in_backtick && c == b'\'' {
667+ in_single = !in_single;
668+ i += 1 ;
669+ continue ;
670+ }
671+ if !in_single && !in_backtick && c == b'"' {
672+ in_double = !in_double;
673+ i += 1 ;
674+ continue ;
675+ }
676+ if !in_single && !in_double && c == b'`' {
677+ in_backtick = !in_backtick;
678+ i += 1 ;
679+ continue ;
680+ }
681+ if !in_single && !in_double && !in_backtick {
682+ // Two-byte separators take precedence over one-byte.
683+ let two_byte = i + 1 < bytes. len ( )
684+ && ( bytes[ i] == b'&' && bytes[ i + 1 ] == b'&'
685+ || bytes[ i] == b'|' && bytes[ i + 1 ] == b'|' ) ;
686+ if two_byte {
687+ let segment = command[ start..i] . trim ( ) ;
688+ if !segment. is_empty ( ) {
689+ segments. push ( segment) ;
690+ }
691+ i += 2 ;
692+ start = i;
693+ continue ;
694+ }
695+ if c == b';' || c == b'|' || c == b'&' {
696+ let segment = command[ start..i] . trim ( ) ;
697+ if !segment. is_empty ( ) {
698+ segments. push ( segment) ;
699+ }
700+ i += 1 ;
701+ start = i;
702+ continue ;
703+ }
704+ }
705+ i += 1 ;
706+ }
707+ let last = command[ start..] . trim ( ) ;
708+ if !last. is_empty ( ) {
709+ segments. push ( last) ;
710+ }
711+ segments
712+ }
713+
617714// ---------------------------------------------------------------------------
618715// Helpers
619716// ---------------------------------------------------------------------------
@@ -1001,4 +1098,86 @@ mod tests {
10011098 fn extracts_plain_command ( ) {
10021099 assert_eq ! ( extract_first_command( "grep -r pattern ." ) , "grep" ) ;
10031100 }
1101+
1102+ // --- split_bash_pipeline (T1.5) ---
1103+
1104+ #[ test]
1105+ fn split_pipeline_single_command ( ) {
1106+ assert_eq ! ( split_bash_pipeline( "ls -la" ) , vec![ "ls -la" ] ) ;
1107+ }
1108+
1109+ #[ test]
1110+ fn split_pipeline_double_amp ( ) {
1111+ assert_eq ! (
1112+ split_bash_pipeline( "ls -la && rm -rf /tmp/x" ) ,
1113+ vec![ "ls -la" , "rm -rf /tmp/x" ]
1114+ ) ;
1115+ }
1116+
1117+ #[ test]
1118+ fn split_pipeline_double_pipe ( ) {
1119+ assert_eq ! (
1120+ split_bash_pipeline( "test -f foo || touch foo" ) ,
1121+ vec![ "test -f foo" , "touch foo" ]
1122+ ) ;
1123+ }
1124+
1125+ #[ test]
1126+ fn split_pipeline_semicolon_and_pipe ( ) {
1127+ assert_eq ! (
1128+ split_bash_pipeline( "ls ; cat /etc/hosts | grep host" ) ,
1129+ vec![ "ls" , "cat /etc/hosts" , "grep host" ]
1130+ ) ;
1131+ }
1132+
1133+ #[ test]
1134+ fn split_pipeline_respects_double_quotes ( ) {
1135+ assert_eq ! (
1136+ split_bash_pipeline( r#"echo "a && b" && ls"# ) ,
1137+ vec![ r#"echo "a && b""# , "ls" ]
1138+ ) ;
1139+ }
1140+
1141+ #[ test]
1142+ fn split_pipeline_respects_single_quotes ( ) {
1143+ assert_eq ! (
1144+ split_bash_pipeline( r#"echo 'a;b' ; ls"# ) ,
1145+ vec![ r#"echo 'a;b'"# , "ls" ]
1146+ ) ;
1147+ }
1148+
1149+ #[ test]
1150+ fn split_pipeline_respects_backslash_escape ( ) {
1151+ assert_eq ! (
1152+ split_bash_pipeline( r#"echo a\&\&b"# ) ,
1153+ vec![ r#"echo a\&\&b"# ]
1154+ ) ;
1155+ }
1156+
1157+ // --- validate_command compound-bypass closure (T1.5) ---
1158+
1159+ #[ test]
1160+ fn validate_command_blocks_destructive_after_safe_in_chain ( ) {
1161+ let workspace = std:: env:: current_dir ( ) . unwrap ( ) ;
1162+ // Pre-T1.5: this passed because only "ls -la" was inspected.
1163+ assert ! ( matches!(
1164+ validate_command( "ls -la && rm -rf /tmp/x" , PermissionMode :: ReadOnly , & workspace) ,
1165+ ValidationResult :: Block { .. }
1166+ ) ) ;
1167+ }
1168+
1169+ #[ test]
1170+ fn validate_command_allows_chain_of_safe_commands ( ) {
1171+ let workspace = std:: env:: current_dir ( ) . unwrap ( ) ;
1172+ assert_eq ! (
1173+ validate_command( "ls -la && pwd && echo hi" , PermissionMode :: ReadOnly , & workspace) ,
1174+ ValidationResult :: Allow
1175+ ) ;
1176+ }
1177+
1178+ // Note: I considered a test that `echo "ls && rm -rf /"` should be Allow
1179+ // because the quoted text is not a separate command. The split correctly
1180+ // returns one segment, but `check_destructive` (correctly) scans the
1181+ // whole string for `rm -rf /`-like fork-bomb patterns and blocks anyway.
1182+ // Pre-existing paranoid behavior, not a regression from T1.5.
10041183}
0 commit comments