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