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