1mod mode;
9#[cfg(test)]
10mod tests;
11
12use crate::core::error::ShellError;
13pub use mode::ShellSecurity;
14
15pub fn check_shell_allowlist(command: &str) -> Result<(), ShellError> {
23 match ShellSecurity::resolve() {
24 ShellSecurity::Off => Ok(()),
25 ShellSecurity::Warn => {
26 if let Err(msg) = enforce_shell_allowlist(command) {
27 tracing::warn!(
28 target: "shell_security",
29 "warn-only: would block ({})",
30 msg.lines().next().unwrap_or("blocked")
31 );
32 }
33 Ok(())
34 }
35 ShellSecurity::Enforce => enforce_shell_allowlist(command),
36 }
37}
38
39#[must_use]
51pub fn passes_enforced(command: &str) -> bool {
52 enforce_shell_allowlist(command).is_ok()
53}
54
55fn enforce_shell_allowlist(command: &str) -> Result<(), ShellError> {
62 let normalized = normalize_line_continuations(command);
63 let cmd = normalized.as_str();
64
65 if has_dangerous_patterns(cmd) {
66 return Err(format!(
67 "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
68 which is blocked regardless of allowlist. \
69 This is a permanent security restriction, not a transient error.\n\
70 Command: {command}"
71 )
72 .into());
73 }
74
75 let strict = crate::core::config::Config::load().shell_strict_mode;
76 check_substitution_in_args(cmd, strict)?;
77 check_pipe_to_bare_interpreter(cmd, strict)?;
78
79 let allowlist = effective_allowlist();
80 if allowlist.is_empty() {
81 check_unconditional_blocked_only(cmd)?;
82 return Ok(());
83 }
84 check_all_segments(cmd, &allowlist)
85}
86
87fn normalize_line_continuations(command: &str) -> String {
90 command
91 .replace("\\\r\n", "")
92 .replace("\\\n", "")
93 .replace(['\u{2028}', '\u{2029}'], "\n")
94}
95
96fn check_substitution_in_args(command: &str, strict: bool) -> Result<(), ShellError> {
100 if has_expanding_substitution_in_args(command) {
101 if strict {
102 tracing::warn!(
103 "[SECURITY] Command substitution in arguments blocked (shell_strict_mode=true): {command}"
104 );
105 return Err(format!(
106 "[BLOCKED — DO NOT RETRY] Command substitution ($(), backticks, <()/>()) in \
107 arguments is blocked because shell_strict_mode = true. \
108 This is a permanent security restriction.\n\
109 Command: {command}"
110 )
111 .into());
112 }
113 tracing::warn!(
114 "[SECURITY] Command substitution in arguments detected (warn-only, set shell_strict_mode=true to block): {command}"
115 );
116 }
117 Ok(())
118}
119
120fn has_expanding_substitution_in_args(command: &str) -> bool {
124 let bytes = command.as_bytes();
125 let len = bytes.len();
126 let mut i = 0;
127 let mut in_single_quote = false;
128 let mut seen_space_after_cmd = false;
129
130 while i < len {
131 let ch = bytes[i];
132 if in_single_quote {
133 if ch == b'\'' {
134 in_single_quote = false;
135 }
136 i += 1;
137 continue;
138 }
139 if ch == b'\\' {
143 i = (i + 2).min(len);
144 continue;
145 }
146 match ch {
147 b'\'' => {
148 in_single_quote = true;
149 i += 1;
150 }
151 b' ' | b'\t' if !seen_space_after_cmd => {
152 seen_space_after_cmd = true;
153 i += 1;
154 }
155 _ if !seen_space_after_cmd => {
156 i += 1;
157 }
158 _ => {
159 if ch == b'$' && i + 1 < len && bytes[i + 1] == b'(' {
160 return true;
161 }
162 if ch == b'`' {
163 return true;
164 }
165 if (ch == b'<' || ch == b'>') && i + 1 < len && bytes[i + 1] == b'(' {
166 return true;
167 }
168 i += 1;
169 }
170 }
171 }
172 false
173}
174
175fn check_pipe_to_bare_interpreter(command: &str, strict: bool) -> Result<(), ShellError> {
178 let segments = split_on_operators(command);
179
180 for (idx, seg) in segments.iter().enumerate() {
181 if idx == 0 {
182 continue;
183 }
184 if is_bare_interpreter_stdin(seg) {
185 let base = extract_base_from_segment(seg);
186 if strict {
187 tracing::warn!(
188 "[SECURITY] Pipe to bare interpreter '{base}' blocked (shell_strict_mode=true)"
189 );
190 return Err(format!(
191 "[BLOCKED — DO NOT RETRY] Piping into bare interpreter '{base}' is blocked \
192 because shell_strict_mode = true. Run a script file instead.\n\
193 Command: {command}"
194 )
195 .into());
196 }
197 tracing::warn!("[SECURITY] Pipe to bare interpreter '{base}' detected (warn-only)");
198 }
199 }
200 Ok(())
201}
202
203fn check_unconditional_blocked_only(command: &str) -> Result<(), ShellError> {
205 let segments = extract_all_commands(command);
206 for seg in &segments {
207 let base = extract_base_from_segment(seg);
208 if !base.is_empty() && UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
209 return Err(format!(
210 "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
211 regardless of allowlist configuration.\n\
212 Command: {command}"
213 )
214 .into());
215 }
216 check_inline_env_block(seg)?;
217 check_interpreter_eval_only(seg)?;
218 check_dangerous_flags(seg)?;
219 }
220 Ok(())
221}
222
223pub fn shell_tokenize(input: &str) -> Vec<String> {
227 let mut tokens = Vec::new();
228 let mut current = String::new();
229 let mut chars = input.chars().peekable();
230 let mut in_single = false;
231 let mut in_double = false;
232
233 while let Some(c) = chars.next() {
234 match c {
235 '\'' if !in_double => in_single = !in_single,
236 '"' if !in_single => in_double = !in_double,
237 '\\' if !in_single => {
238 if let Some(next) = chars.next() {
239 current.push(next);
240 }
241 }
242 c if c.is_whitespace() && !in_single && !in_double => {
243 if !current.is_empty() {
244 tokens.push(std::mem::take(&mut current));
245 }
246 }
247 _ => current.push(c),
248 }
249 }
250 if !current.is_empty() {
251 tokens.push(current);
252 }
253 tokens
254}
255
256fn quote_aware_token_end(input: &str) -> usize {
260 let bytes = input.as_bytes();
261 let len = bytes.len();
262 let mut i = 0;
263 let mut in_single = false;
264 let mut in_double = false;
265
266 while i < len {
267 let ch = bytes[i];
268 match ch {
269 b'\'' if !in_double => {
270 in_single = !in_single;
271 i += 1;
272 }
273 b'"' if !in_single => {
274 in_double = !in_double;
275 i += 1;
276 }
277 b'\\' if !in_single => {
278 i = (i + 2).min(len);
279 }
280 b if b.is_ascii_whitespace() && !in_single && !in_double => return i,
281 _ => i += 1,
282 }
283 }
284 len
285}
286
287fn check_interpreter_eval_only(segment: &str) -> Result<(), ShellError> {
292 check_interpreter_eval_only_inner(segment, 0)
293}
294
295fn check_interpreter_eval_only_inner(segment: &str, depth: usize) -> Result<(), ShellError> {
296 if depth > 3 {
297 return Ok(());
298 }
299 let trimmed = skip_env_assignments(segment.trim());
300 let tokens = shell_tokenize(trimmed);
301 if tokens.is_empty() {
302 return Ok(());
303 }
304 let base = tokens[0]
305 .rsplit('/')
306 .next()
307 .unwrap_or(&tokens[0])
308 .to_string();
309
310 if DELEGATION_COMMANDS.contains(&base.as_str()) {
311 let rest_tokens = delegated_command_tokens(&tokens[1..]);
312 if !rest_tokens.is_empty() {
313 return check_interpreter_eval_only_inner(&rest_tokens.join(" "), depth + 1);
314 }
315 return Ok(());
316 }
317
318 if !INTERPRETER_COMMANDS.contains(&base.as_str()) {
319 return Ok(());
320 }
321 for tok in &tokens[1..] {
322 if EVAL_FLAGS.contains(&tok.as_str()) {
323 return Err(format!(
324 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with inline code execution \
325 flag '{tok}' is blocked. Use a script file instead.\n\
326 This is a permanent security restriction."
327 )
328 .into());
329 }
330 if has_eval_flag_prefix(tok) {
331 return Err(format!(
332 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with combined flag '{tok}' \
333 containing eval flag is blocked.\n\
334 This is a permanent security restriction."
335 )
336 .into());
337 }
338 }
339 if tokens[1..].iter().any(|t| t.contains("<<")) {
340 return Err(heredoc_blocked_message(&base).into());
341 }
342 Ok(())
343}
344
345fn heredoc_blocked_message(base: &str) -> String {
351 format!(
352 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with heredoc stdin is blocked. \
353 Inline code in the command string leaves no auditable artifact.\n\
354 Do this instead: write the code to a file, then run it —\n\
355 1. create /tmp/snippet with your code (Write/ctx_edit tool)\n\
356 2. {base} /tmp/snippet\n\
357 This is a permanent security restriction."
358 )
359}
360
361const UNCONDITIONAL_BLOCKED: &[&str] = &["eval", "exec", "source", "."];
364
365const INTERPRETER_COMMANDS: &[&str] = &[
367 "python", "python3", "python2", "node", "ruby", "perl", "lua", "php", "bash", "sh", "zsh",
368 "fish", "dash", "ksh",
369];
370
371const EVAL_FLAGS: &[&str] = &[
373 "-c", "-e", "-r", "-p", "--eval", "--exec", "-exec", "--print", "--run",
374];
375
376const SCRIPT_EXTENSIONS: &[&str] = &[
378 ".py", ".rb", ".js", ".ts", ".pl", ".lua", ".php", ".sh", ".bash", ".zsh", ".mjs", ".cjs",
379 ".tsx", ".jsx",
380];
381
382const DELEGATION_COMMANDS: &[&str] = &["env", "nice", "timeout", "sudo", "doas", "xargs", "nohup"];
386
387fn delegated_command_tokens(tokens: &[String]) -> Vec<&str> {
391 tokens
392 .iter()
393 .map(std::string::String::as_str)
394 .skip_while(|t| {
395 t.starts_with('-')
396 || t.contains('=')
397 || *t == "{}"
398 || (!t.is_empty() && t.chars().all(|c| c.is_ascii_digit()))
399 })
400 .collect()
401}
402
403fn check_interpreter_abuse(segment: &str, allowlist: &[String]) -> Result<(), ShellError> {
406 check_interpreter_abuse_inner(segment, allowlist, 0)
407}
408
409fn check_interpreter_abuse_inner(
410 segment: &str,
411 allowlist: &[String],
412 depth: usize,
413) -> Result<(), ShellError> {
414 if depth > 3 {
415 return Ok(());
416 }
417 let trimmed = skip_env_assignments(segment.trim());
418 let tokens = shell_tokenize(trimmed);
419 if tokens.is_empty() {
420 return Ok(());
421 }
422
423 let base = tokens[0]
424 .rsplit('/')
425 .next()
426 .unwrap_or(&tokens[0])
427 .to_string();
428
429 if INTERPRETER_COMMANDS.contains(&base.as_str()) {
430 for tok in &tokens[1..] {
431 if EVAL_FLAGS.contains(&tok.as_str()) {
432 return Err(format!(
433 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with inline code execution \
434 flag '{tok}' is blocked. Use a script file instead.\n\
435 This is a permanent security restriction."
436 )
437 .into());
438 }
439 if has_eval_flag_prefix(tok) {
440 return Err(format!(
441 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with combined flag '{tok}' \
442 containing eval flag is blocked.\n\
443 This is a permanent security restriction."
444 )
445 .into());
446 }
447 }
448 if tokens[1..].iter().any(|t| t.contains("<<")) {
449 return Err(heredoc_blocked_message(&base).into());
450 }
451 }
452
453 if DELEGATION_COMMANDS.contains(&base.as_str()) {
454 let rest_tokens = delegated_command_tokens(&tokens[1..]);
455 if let Some(&delegated_tok) = rest_tokens.first() {
456 let delegated = delegated_tok.rsplit('/').next().unwrap_or(delegated_tok);
457 if !delegated.is_empty() && !allowlist.iter().any(|a| a == delegated) {
458 return Err(format!(
459 "[BLOCKED — DO NOT RETRY] '{base}' delegates to '{delegated}' which is not \
460 in the shell allowlist. This is a permanent restriction."
461 )
462 .into());
463 }
464 let rest_str = rest_tokens.join(" ");
465 check_interpreter_abuse_inner(&rest_str, allowlist, depth + 1)?;
466 }
467 }
468
469 Ok(())
470}
471
472fn has_eval_flag_prefix(token: &str) -> bool {
474 if !token.starts_with('-') || token.starts_with("--") || token.len() < 3 {
475 return false;
476 }
477 let flag_chars = &token[1..];
478 let eval_chars = ['c', 'e', 'r', 'p'];
479 flag_chars.chars().any(|c| eval_chars.contains(&c))
480}
481
482fn is_bare_interpreter_stdin(segment: &str) -> bool {
484 let trimmed = skip_env_assignments(segment.trim());
485 let tokens = shell_tokenize(trimmed);
486 if tokens.is_empty() {
487 return false;
488 }
489 let base = tokens[0]
490 .rsplit('/')
491 .next()
492 .unwrap_or(&tokens[0])
493 .to_string();
494 if !INTERPRETER_COMMANDS.contains(&base.as_str()) {
495 return false;
496 }
497 !tokens[1..]
498 .iter()
499 .any(|t| !t.starts_with('-') && SCRIPT_EXTENSIONS.iter().any(|ext| t.ends_with(ext)))
500}
501
502const DANGEROUS_GIT_FLAGS: &[&str] = &[
504 "--upload-pack",
505 "--receive-pack",
506 "--config=core.sshcommand",
507 "--config=core.gitproxy",
508];
509
510const DANGEROUS_TAR_FLAGS: &[&str] = &["--to-command", "--use-compress-program"];
511
512const BLOCKED_INLINE_ENV: &[&str] = &[
514 "PATH=",
515 "GIT_ASKPASS=",
516 "GIT_SSH=",
517 "GIT_SSH_COMMAND=",
518 "GIT_EDITOR=",
519 "GIT_EXTERNAL_DIFF=",
520 "SSH_ASKPASS=",
521 "LD_PRELOAD=",
522 "DYLD_INSERT_LIBRARIES=",
523];
524
525fn check_dangerous_flags(segment: &str) -> Result<(), ShellError> {
526 let trimmed = skip_env_assignments(segment.trim());
527 let tokens = shell_tokenize(trimmed);
528 if tokens.is_empty() {
529 return Ok(());
530 }
531 let base = tokens[0]
532 .rsplit('/')
533 .next()
534 .unwrap_or(&tokens[0])
535 .to_string();
536
537 match base.as_str() {
538 "git" => {
539 for tok in &tokens[1..] {
540 for flag in DANGEROUS_GIT_FLAGS {
541 if tok.starts_with(flag) {
542 return Err(format!(
543 "[BLOCKED — DO NOT RETRY] 'git' with dangerous flag '{tok}' is blocked.\n\
544 This is a permanent security restriction."
545 ).into());
546 }
547 }
548 }
549 }
550 "tar" => {
551 for tok in &tokens[1..] {
552 for flag in DANGEROUS_TAR_FLAGS {
553 if tok.starts_with(flag) {
554 return Err(format!(
555 "[BLOCKED — DO NOT RETRY] 'tar' with dangerous flag '{tok}' is blocked.\n\
556 This is a permanent security restriction."
557 ).into());
558 }
559 }
560 }
561 }
562 "find" => {
563 for tok in &tokens[1..] {
564 if tok == "-exec" || tok == "-execdir" {
565 return Err(format!(
566 "[BLOCKED — DO NOT RETRY] 'find' with '{tok}' is blocked. \
567 Use 'find ... -print' and pipe to xargs instead.\n\
568 This is a permanent security restriction."
569 )
570 .into());
571 }
572 }
573 }
574 "awk" | "gawk" | "mawk" => {
575 for tok in &tokens[1..] {
576 if tok.contains("system(") {
577 return Err(format!(
578 "[BLOCKED — DO NOT RETRY] '{base}' with 'system()' call is blocked.\n\
579 This is a permanent security restriction."
580 )
581 .into());
582 }
583 }
584 }
585 _ => {}
586 }
587 Ok(())
588}
589
590fn check_inline_env_block(segment: &str) -> Result<(), ShellError> {
591 let trimmed = segment.trim();
592 for blocked in BLOCKED_INLINE_ENV {
593 if trimmed.starts_with(blocked) {
594 return Err(format!(
595 "[BLOCKED — DO NOT RETRY] Inline environment override '{blocked}' is blocked.\n\
596 This is a permanent security restriction."
597 )
598 .into());
599 }
600 }
601 Ok(())
602}
603
604const HEADER_KEYWORDS: &[&str] = &["for", "select", "in", "done", "fi"];
609
610const BODY_INTRO_KEYWORDS: &[&str] = &[
615 "do", "then", "else", "elif", "if", "while", "until", "time", "!",
616];
617
618fn expand_to_leaf_segments(command: &str) -> Result<Vec<String>, ShellError> {
627 if has_case_construct(command) {
628 return Err(format!(
629 "[BLOCKED — DO NOT RETRY] `case`/`esac` constructs are not supported in \
630 restricted (allowlisted) shell mode — their `pattern)` arms cannot be \
631 leaf-validated safely. Run a script file or disable the allowlist instead.\n\
632 Command: {command}"
633 )
634 .into());
635 }
636 let mut leaves = Vec::new();
637 for seg in extract_all_commands(command) {
638 resolve_segment_leaves(&seg, 0, &mut leaves)?;
639 }
640 Ok(leaves)
641}
642
643fn resolve_segment_leaves(
646 segment: &str,
647 depth: usize,
648 out: &mut Vec<String>,
649) -> Result<(), ShellError> {
650 if depth > 4 {
651 return Err(format!(
652 "[BLOCKED — DO NOT RETRY] Shell command nests compound/subshell groups too \
653 deeply to validate safely.\nCommand: {segment}"
654 )
655 .into());
656 }
657 let mut s = segment.trim();
658 loop {
659 let tokens = shell_tokenize(s);
660 let Some(first) = tokens.first() else {
661 return Ok(()); };
663 let kw = first.as_str();
664 if HEADER_KEYWORDS.contains(&kw) {
665 return Ok(()); }
667 if BODY_INTRO_KEYWORDS.contains(&kw) {
668 s = remainder_after_first_token(s).trim();
669 if s.is_empty() {
670 return Ok(());
671 }
672 continue;
673 }
674 break;
675 }
676 if let Some(inner) = balanced_paren_inner(s) {
677 for inner_seg in extract_all_commands(inner) {
678 resolve_segment_leaves(&inner_seg, depth + 1, out)?;
679 }
680 return Ok(());
681 }
682 out.push(s.to_string());
687 Ok(())
688}
689
690fn remainder_after_first_token(s: &str) -> &str {
692 let trimmed = s.trim_start();
693 let end = quote_aware_token_end(trimmed);
694 &trimmed[end..]
695}
696
697fn balanced_paren_inner(segment: &str) -> Option<&str> {
701 let trimmed = segment.trim();
702 let bytes = trimmed.as_bytes();
703 if bytes.first() != Some(&b'(') {
704 return None;
705 }
706 let len = bytes.len();
707 let mut depth: i32 = 0;
708 let mut in_single_quote = false;
709 let mut in_double_quote = false;
710 let mut i = 0;
711 while i < len {
712 let ch = bytes[i];
713 if in_single_quote {
714 if ch == b'\'' {
715 in_single_quote = false;
716 }
717 i += 1;
718 continue;
719 }
720 if in_double_quote {
721 match ch {
722 b'\\' => i += 1, b'"' => in_double_quote = false,
724 _ => {}
725 }
726 i += 1;
727 continue;
728 }
729 match ch {
730 b'\\' => i += 1,
733 b'\'' => in_single_quote = true,
734 b'"' => in_double_quote = true,
735 b'(' => depth += 1,
736 b')' => {
737 depth -= 1;
738 if depth == 0 {
739 return if i == len - 1 {
740 Some(trimmed[1..i].trim())
741 } else {
742 None
743 };
744 }
745 }
746 _ => {}
747 }
748 i += 1;
749 }
750 None
751}
752
753fn has_case_construct(command: &str) -> bool {
757 for seg in split_on_operators(command) {
758 if shell_tokenize(seg.trim())
759 .iter()
760 .any(|t| t == "case" || t == "esac")
761 {
762 return true;
763 }
764 }
765 contains_double_semicolon(command)
766}
767
768fn contains_double_semicolon(command: &str) -> bool {
770 let bytes = command.as_bytes();
771 let len = bytes.len();
772 let mut in_single_quote = false;
773 let mut in_double_quote = false;
774 let mut i = 0;
775 while i < len {
776 let ch = bytes[i];
777 if in_single_quote {
778 if ch == b'\'' {
779 in_single_quote = false;
780 }
781 i += 1;
782 continue;
783 }
784 if in_double_quote {
785 if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
786 in_double_quote = false;
787 }
788 i += 1;
789 continue;
790 }
791 match ch {
792 b'\'' => in_single_quote = true,
793 b'"' => in_double_quote = true,
794 b';' if i + 1 < len && bytes[i + 1] == b';' => return true,
795 _ => {}
796 }
797 i += 1;
798 }
799 false
800}
801
802fn check_all_segments(command: &str, allowlist: &[String]) -> Result<(), ShellError> {
803 if allowlist.is_empty() {
804 return Ok(());
805 }
806
807 if has_dangerous_patterns(command) {
808 return Err(format!(
809 "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
810 which is blocked in restricted mode. \
811 This is a permanent security restriction, not a transient error.\n\
812 Command: {command}"
813 )
814 .into());
815 }
816
817 let segments = expand_to_leaf_segments(command)?;
818 if segments.is_empty() {
819 return Err("[BLOCKED — DO NOT RETRY] Empty command".into());
820 }
821
822 for seg in &segments {
823 check_inline_env_block(seg)?;
824 let base = extract_base_from_segment(seg);
825 if base.is_empty() {
826 continue;
827 }
828 if UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
829 return Err(format!(
830 "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
831 regardless of allowlist membership. \
832 This is a permanent security restriction.\n\
833 Command: {command}"
834 )
835 .into());
836 }
837 check_interpreter_abuse(seg, allowlist)?;
838 check_dangerous_flags(seg)?;
839 if !allowlist.iter().any(|a| a == &base) {
840 return Err(allowlist_block_message(&base).into());
841 }
842 }
843 Ok(())
844}
845
846fn has_dangerous_patterns(command: &str) -> bool {
854 let trimmed = command.trim();
855
856 for blocked in UNCONDITIONAL_BLOCKED {
857 let with_space = format!("{blocked} ");
858 if trimmed.starts_with(&with_space) {
859 return true;
860 }
861 for sep in ["; ", "&& ", "|| ", "| ", "\n"] {
862 if trimmed.contains(&format!("{sep}{blocked} ")) {
863 return true;
864 }
865 }
866 }
867
868 if has_substitution_at_command_pos(trimmed) {
869 return true;
870 }
871
872 false
873}
874
875fn has_substitution_at_command_pos(command: &str) -> bool {
879 let segments = split_on_operators(command);
880 for seg in segments {
881 let trimmed = seg.trim();
882 let cmd_start = skip_env_assignments(trimmed);
883
884 if cmd_start.starts_with("$(") {
885 return true;
886 }
887
888 let tokens = shell_tokenize(cmd_start);
889 let first_token = tokens.first().map_or("", std::string::String::as_str);
890 if first_token.starts_with('`') || first_token == "`" {
891 return true;
892 }
893 }
894 false
895}
896
897fn extract_all_commands(command: &str) -> Vec<String> {
900 split_on_operators(command)
901 .into_iter()
902 .map(|s| s.trim().to_string())
903 .filter(|s| !s.is_empty())
904 .collect()
905}
906
907fn split_on_operators(command: &str) -> Vec<&str> {
914 let mut segments = Vec::new();
915 let mut start = 0;
916 let bytes = command.as_bytes();
917 let len = bytes.len();
918 let mut i = 0;
919 let mut in_single_quote = false;
920 let mut in_double_quote = false;
921 let mut paren_depth: u32 = 0;
922
923 while i < len {
924 let ch = bytes[i];
925
926 if in_single_quote {
927 if ch == b'\'' {
928 in_single_quote = false;
929 }
930 i += 1;
931 continue;
932 }
933
934 if in_double_quote {
935 match ch {
936 b'\\' => i = (i + 2).min(len),
938 b'"' => {
939 in_double_quote = false;
940 i += 1;
941 }
942 _ => i += 1,
943 }
944 continue;
945 }
946
947 match ch {
948 b'\\' => {
949 i = (i + 2).min(len);
952 }
953 b'\'' => {
954 in_single_quote = true;
955 i += 1;
956 }
957 b'"' => {
958 in_double_quote = true;
959 i += 1;
960 }
961 b'(' => {
962 paren_depth += 1;
963 i += 1;
964 }
965 b')' => {
966 paren_depth = paren_depth.saturating_sub(1);
967 i += 1;
968 }
969 b'\n' | b'\r' | b';' if paren_depth == 0 => {
970 segments.push(&command[start..i]);
971 i += 1;
972 start = i;
973 }
974 b'&' if paren_depth == 0 => {
975 if i + 1 < len && bytes[i + 1] == b'&' {
976 segments.push(&command[start..i]);
978 i += 2;
979 start = i;
980 } else if (i > 0 && bytes[i - 1] == b'>') || (i + 1 < len && bytes[i + 1] == b'>') {
981 i += 1;
986 } else {
987 segments.push(&command[start..i]);
989 i += 1;
990 start = i;
991 }
992 }
993 b'|' if paren_depth == 0 => {
994 if i + 1 < len && bytes[i + 1] == b'|' {
995 segments.push(&command[start..i]);
997 i += 2;
998 start = i;
999 } else if i > 0 && bytes[i - 1] == b'>' {
1000 i += 1;
1006 } else {
1007 segments.push(&command[start..i]);
1009 i += 1;
1010 start = i;
1011 }
1012 }
1013 _ => {
1014 i += 1;
1015 }
1016 }
1017 }
1018
1019 if start < len {
1020 segments.push(&command[start..]);
1021 }
1022
1023 segments
1024}
1025
1026fn extract_base_from_segment(segment: &str) -> String {
1028 let trimmed = segment.trim();
1029 if trimmed.is_empty() {
1030 return String::new();
1031 }
1032
1033 let cmd_part = skip_env_assignments(trimmed);
1034 if cmd_part.is_empty() {
1035 return String::new();
1036 }
1037
1038 let tokens = shell_tokenize(cmd_part);
1039 let first_token = tokens.first().map_or("", std::string::String::as_str);
1040
1041 first_token
1042 .rsplit('/')
1043 .next()
1044 .unwrap_or(first_token)
1045 .to_string()
1046}
1047
1048fn skip_env_assignments(segment: &str) -> &str {
1052 let mut rest = segment;
1053 loop {
1054 let rest_trimmed = rest.trim_start();
1055 if rest_trimmed.is_empty() {
1056 return rest_trimmed;
1057 }
1058 let end = quote_aware_token_end(rest_trimmed);
1059 if end == 0 {
1060 return rest_trimmed;
1061 }
1062 let raw_token = &rest_trimmed[..end];
1063 let unquoted: String = raw_token
1064 .chars()
1065 .filter(|c| *c != '"' && *c != '\'')
1066 .collect();
1067 if unquoted.contains('=')
1068 && !unquoted.starts_with('-')
1069 && !unquoted.starts_with('/')
1070 && !unquoted.starts_with('.')
1071 {
1072 rest = &rest_trimmed[end..];
1073 } else {
1074 return rest_trimmed;
1075 }
1076 }
1077}
1078
1079fn effective_allowlist() -> Vec<String> {
1080 if let Ok(ov) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE") {
1082 return ov
1083 .split(',')
1084 .map(|s| s.trim().to_string())
1085 .filter(|s| !s.is_empty())
1086 .collect();
1087 }
1088 let cfg = crate::core::config::Config::load();
1089 let mut list = cfg.shell_allowlist;
1090 if !list.is_empty() {
1094 for entry in cfg.shell_allowlist_extra {
1095 if !entry.is_empty() && !list.contains(&entry) {
1096 list.push(entry);
1097 }
1098 }
1099 }
1100 if let Ok(env_val) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST") {
1101 for entry in env_val
1102 .split(',')
1103 .map(|s| s.trim().to_string())
1104 .filter(|s| !s.is_empty())
1105 {
1106 if !list.contains(&entry) {
1107 list.push(entry);
1108 }
1109 }
1110 }
1111 list
1112}
1113
1114fn allowlist_block_message(base: &str) -> String {
1121 let cfg_path = crate::core::config::Config::path().map_or_else(
1122 || "~/.lean-ctx/config.toml".to_string(),
1123 |p| p.display().to_string(),
1124 );
1125
1126 let mut msg = format!(
1127 "[BLOCKED — DO NOT RETRY] '{base}' is not in the shell allowlist. \
1128 This is a permanent restriction, not a transient error.\n\
1129 Fix (additive, keeps the defaults): run lean-ctx allow {base}\n\
1130 Config in effect: {cfg_path}\n\
1131 Or disable the allowlist entirely: set shell_allowlist = []\n\
1132 Or turn off all shell gating (you own the risk): set shell_security = \"off\" \
1133 (or env LEAN_CTX_SHELL_SECURITY=off) — compression still applies.\n\
1134 Do NOT retry this command — it will fail again with the same error."
1135 );
1136
1137 if crate::core::config::cloud_infra_commands().contains(&base) {
1138 msg.push_str(
1139 "\nNote: cloud/infra CLIs (terraform, kubectl, aws, …) are deliberately \
1140 excluded from the defaults — they mutate remote infrastructure with \
1141 ambient credentials. Opting in is a deliberate user decision.",
1142 );
1143 }
1144
1145 if let Some(parse_err) = crate::core::config::last_config_parse_error() {
1146 msg.push_str(&format!(
1147 "\n\n⚠ Your config.toml currently FAILS to parse, so lean-ctx is running on the \
1148 built-in defaults — this is almost certainly why editing the allowlist had no \
1149 effect. Fix the TOML error below, then retry:\n {parse_err}\n File: {cfg_path}"
1150 ));
1151 } else if let Some(missing) = crate::core::config::Config::missing_config_path() {
1152 msg.push_str(&format!(
1156 "\n\n⚠ No config file exists at {} — lean-ctx is running on built-in defaults. \
1157 If you added the command to a config.toml in a DIFFERENT location (XDG \
1158 ~/.config/lean-ctx vs legacy ~/.lean-ctx, or your MCP client launches lean-ctx \
1159 in a sandbox/container with a different HOME), the runtime never reads it. \
1160 `lean-ctx doctor` prints the path actually in effect; pin it with \
1161 LEAN_CTX_CONFIG_DIR.",
1162 missing.display()
1163 ));
1164 }
1165
1166 if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
1170 msg.push_str("\n\n⚠ ");
1171 msg.push_str(¬ice);
1172 }
1173
1174 msg
1175}
1176
1177pub fn extract_all_commands_pub(command: &str) -> Vec<String> {
1179 extract_all_commands(command)
1180}
1181
1182#[must_use]
1187pub fn effective_allowlist_pub() -> Vec<String> {
1188 effective_allowlist()
1189}
1190
1191pub fn extract_base_command(command: &str) -> String {
1193 let first_seg = split_on_operators(command)
1194 .into_iter()
1195 .next()
1196 .unwrap_or(command);
1197 extract_base_from_segment(first_seg)
1198}