lean_ctx/core/shell_allowlist/
mod.rs1#[cfg(test)]
9mod tests;
10
11pub fn check_shell_allowlist(command: &str) -> Result<(), String> {
17 let normalized = normalize_line_continuations(command);
18 let cmd = normalized.as_str();
19
20 if has_dangerous_patterns(cmd) {
21 return Err(format!(
22 "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
23 which is blocked regardless of allowlist. \
24 This is a permanent security restriction, not a transient error.\n\
25 Command: {command}"
26 ));
27 }
28
29 let strict = crate::core::config::Config::load().shell_strict_mode;
30 check_substitution_in_args(cmd, strict)?;
31 check_pipe_to_bare_interpreter(cmd, strict)?;
32
33 let allowlist = effective_allowlist();
34 if allowlist.is_empty() {
35 check_unconditional_blocked_only(cmd)?;
36 return Ok(());
37 }
38 check_all_segments(cmd, &allowlist)
39}
40
41fn normalize_line_continuations(command: &str) -> String {
44 command
45 .replace("\\\r\n", "")
46 .replace("\\\n", "")
47 .replace(['\u{2028}', '\u{2029}'], "\n")
48}
49
50fn check_substitution_in_args(command: &str, strict: bool) -> Result<(), String> {
54 if has_expanding_substitution_in_args(command) {
55 if strict {
56 tracing::warn!(
57 "[SECURITY] Command substitution in arguments blocked (shell_strict_mode=true): {command}"
58 );
59 return Err(format!(
60 "[BLOCKED — DO NOT RETRY] Command substitution ($(), backticks, <()/>()) in \
61 arguments is blocked because shell_strict_mode = true. \
62 This is a permanent security restriction.\n\
63 Command: {command}"
64 ));
65 }
66 tracing::warn!(
67 "[SECURITY] Command substitution in arguments detected (warn-only, set shell_strict_mode=true to block): {command}"
68 );
69 }
70 Ok(())
71}
72
73fn has_expanding_substitution_in_args(command: &str) -> bool {
77 let bytes = command.as_bytes();
78 let len = bytes.len();
79 let mut i = 0;
80 let mut in_single_quote = false;
81 let mut seen_space_after_cmd = false;
82
83 while i < len {
84 let ch = bytes[i];
85 if in_single_quote {
86 if ch == b'\'' {
87 in_single_quote = false;
88 }
89 i += 1;
90 continue;
91 }
92 match ch {
93 b'\'' => {
94 in_single_quote = true;
95 i += 1;
96 }
97 b' ' | b'\t' if !seen_space_after_cmd => {
98 seen_space_after_cmd = true;
99 i += 1;
100 }
101 _ if !seen_space_after_cmd => {
102 i += 1;
103 }
104 _ => {
105 if ch == b'$' && i + 1 < len && bytes[i + 1] == b'(' {
106 return true;
107 }
108 if ch == b'`' {
109 return true;
110 }
111 if (ch == b'<' || ch == b'>') && i + 1 < len && bytes[i + 1] == b'(' {
112 return true;
113 }
114 i += 1;
115 }
116 }
117 }
118 false
119}
120
121fn check_pipe_to_bare_interpreter(command: &str, strict: bool) -> Result<(), String> {
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 if strict {
176 tracing::warn!(
177 "[SECURITY] Pipe to bare interpreter '{base}' blocked (shell_strict_mode=true)"
178 );
179 return Err(format!(
180 "[BLOCKED — DO NOT RETRY] Piping into bare interpreter '{base}' is blocked \
181 because shell_strict_mode = true. Run a script file instead.\n\
182 Command: {command}"
183 ));
184 }
185 tracing::warn!("[SECURITY] Pipe to bare interpreter '{base}' detected (warn-only)");
186 }
187 }
188 Ok(())
189}
190
191fn check_unconditional_blocked_only(command: &str) -> Result<(), String> {
193 let segments = extract_all_commands(command);
194 for seg in &segments {
195 let base = extract_base_from_segment(seg);
196 if !base.is_empty() && UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
197 return Err(format!(
198 "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
199 regardless of allowlist configuration.\n\
200 Command: {command}"
201 ));
202 }
203 check_inline_env_block(seg)?;
204 check_interpreter_eval_only(seg)?;
205 check_dangerous_flags(seg)?;
206 }
207 Ok(())
208}
209
210pub fn shell_tokenize(input: &str) -> Vec<String> {
214 let mut tokens = Vec::new();
215 let mut current = String::new();
216 let mut chars = input.chars().peekable();
217 let mut in_single = false;
218 let mut in_double = false;
219
220 while let Some(c) = chars.next() {
221 match c {
222 '\'' if !in_double => in_single = !in_single,
223 '"' if !in_single => in_double = !in_double,
224 '\\' if !in_single => {
225 if let Some(next) = chars.next() {
226 current.push(next);
227 }
228 }
229 c if c.is_whitespace() && !in_single && !in_double => {
230 if !current.is_empty() {
231 tokens.push(std::mem::take(&mut current));
232 }
233 }
234 _ => current.push(c),
235 }
236 }
237 if !current.is_empty() {
238 tokens.push(current);
239 }
240 tokens
241}
242
243fn quote_aware_token_end(input: &str) -> usize {
247 let bytes = input.as_bytes();
248 let len = bytes.len();
249 let mut i = 0;
250 let mut in_single = false;
251 let mut in_double = false;
252
253 while i < len {
254 let ch = bytes[i];
255 match ch {
256 b'\'' if !in_double => {
257 in_single = !in_single;
258 i += 1;
259 }
260 b'"' if !in_single => {
261 in_double = !in_double;
262 i += 1;
263 }
264 b'\\' if !in_single => {
265 i = (i + 2).min(len);
266 }
267 b if b.is_ascii_whitespace() && !in_single && !in_double => return i,
268 _ => i += 1,
269 }
270 }
271 len
272}
273
274fn check_interpreter_eval_only(segment: &str) -> Result<(), String> {
279 check_interpreter_eval_only_inner(segment, 0)
280}
281
282fn check_interpreter_eval_only_inner(segment: &str, depth: usize) -> Result<(), String> {
283 if depth > 3 {
284 return Ok(());
285 }
286 let trimmed = skip_env_assignments(segment.trim());
287 let tokens = shell_tokenize(trimmed);
288 if tokens.is_empty() {
289 return Ok(());
290 }
291 let base = tokens[0]
292 .rsplit('/')
293 .next()
294 .unwrap_or(&tokens[0])
295 .to_string();
296
297 if DELEGATION_COMMANDS.contains(&base.as_str()) {
298 let rest_tokens = delegated_command_tokens(&tokens[1..]);
299 if !rest_tokens.is_empty() {
300 return check_interpreter_eval_only_inner(&rest_tokens.join(" "), depth + 1);
301 }
302 return Ok(());
303 }
304
305 if !INTERPRETER_COMMANDS.contains(&base.as_str()) {
306 return Ok(());
307 }
308 for tok in &tokens[1..] {
309 if EVAL_FLAGS.contains(&tok.as_str()) {
310 return Err(format!(
311 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with inline code execution \
312 flag '{tok}' is blocked. Use a script file instead.\n\
313 This is a permanent security restriction."
314 ));
315 }
316 if has_eval_flag_prefix(tok) {
317 return Err(format!(
318 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with combined flag '{tok}' \
319 containing eval flag is blocked.\n\
320 This is a permanent security restriction."
321 ));
322 }
323 }
324 if tokens[1..].iter().any(|t| t.contains("<<")) {
325 return Err(format!(
326 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with heredoc stdin is blocked. \
327 Use a script file instead.\n\
328 This is a permanent security restriction."
329 ));
330 }
331 Ok(())
332}
333
334const UNCONDITIONAL_BLOCKED: &[&str] = &["eval", "exec", "source", "."];
337
338const INTERPRETER_COMMANDS: &[&str] = &[
340 "python", "python3", "python2", "node", "ruby", "perl", "lua", "php", "bash", "sh", "zsh",
341 "fish", "dash", "ksh",
342];
343
344const EVAL_FLAGS: &[&str] = &[
346 "-c", "-e", "-r", "-p", "--eval", "--exec", "-exec", "--print", "--run",
347];
348
349const SCRIPT_EXTENSIONS: &[&str] = &[
351 ".py", ".rb", ".js", ".ts", ".pl", ".lua", ".php", ".sh", ".bash", ".zsh", ".mjs", ".cjs",
352 ".tsx", ".jsx",
353];
354
355const DELEGATION_COMMANDS: &[&str] = &["env", "nice", "timeout", "sudo", "doas", "xargs", "nohup"];
359
360fn delegated_command_tokens(tokens: &[String]) -> Vec<&str> {
364 tokens
365 .iter()
366 .map(std::string::String::as_str)
367 .skip_while(|t| {
368 t.starts_with('-')
369 || t.contains('=')
370 || *t == "{}"
371 || (!t.is_empty() && t.chars().all(|c| c.is_ascii_digit()))
372 })
373 .collect()
374}
375
376fn check_interpreter_abuse(segment: &str, allowlist: &[String]) -> Result<(), String> {
379 check_interpreter_abuse_inner(segment, allowlist, 0)
380}
381
382fn check_interpreter_abuse_inner(
383 segment: &str,
384 allowlist: &[String],
385 depth: usize,
386) -> Result<(), String> {
387 if depth > 3 {
388 return Ok(());
389 }
390 let trimmed = skip_env_assignments(segment.trim());
391 let tokens = shell_tokenize(trimmed);
392 if tokens.is_empty() {
393 return Ok(());
394 }
395
396 let base = tokens[0]
397 .rsplit('/')
398 .next()
399 .unwrap_or(&tokens[0])
400 .to_string();
401
402 if INTERPRETER_COMMANDS.contains(&base.as_str()) {
403 for tok in &tokens[1..] {
404 if EVAL_FLAGS.contains(&tok.as_str()) {
405 return Err(format!(
406 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with inline code execution \
407 flag '{tok}' is blocked. Use a script file instead.\n\
408 This is a permanent security restriction."
409 ));
410 }
411 if has_eval_flag_prefix(tok) {
412 return Err(format!(
413 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with combined flag '{tok}' \
414 containing eval flag is blocked.\n\
415 This is a permanent security restriction."
416 ));
417 }
418 }
419 if tokens[1..].iter().any(|t| t.contains("<<")) {
420 return Err(format!(
421 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with heredoc stdin is blocked. \
422 Use a script file instead.\n\
423 This is a permanent security restriction."
424 ));
425 }
426 }
427
428 if DELEGATION_COMMANDS.contains(&base.as_str()) {
429 let rest_tokens = delegated_command_tokens(&tokens[1..]);
430 if let Some(&delegated_tok) = rest_tokens.first() {
431 let delegated = delegated_tok.rsplit('/').next().unwrap_or(delegated_tok);
432 if !delegated.is_empty() && !allowlist.iter().any(|a| a == delegated) {
433 return Err(format!(
434 "[BLOCKED — DO NOT RETRY] '{base}' delegates to '{delegated}' which is not \
435 in the shell allowlist. This is a permanent restriction."
436 ));
437 }
438 let rest_str = rest_tokens.join(" ");
439 check_interpreter_abuse_inner(&rest_str, allowlist, depth + 1)?;
440 }
441 }
442
443 Ok(())
444}
445
446fn has_eval_flag_prefix(token: &str) -> bool {
448 if !token.starts_with('-') || token.starts_with("--") || token.len() < 3 {
449 return false;
450 }
451 let flag_chars = &token[1..];
452 let eval_chars = ['c', 'e', 'r', 'p'];
453 flag_chars.chars().any(|c| eval_chars.contains(&c))
454}
455
456fn is_bare_interpreter_stdin(segment: &str) -> bool {
458 let trimmed = skip_env_assignments(segment.trim());
459 let tokens = shell_tokenize(trimmed);
460 if tokens.is_empty() {
461 return false;
462 }
463 let base = tokens[0]
464 .rsplit('/')
465 .next()
466 .unwrap_or(&tokens[0])
467 .to_string();
468 if !INTERPRETER_COMMANDS.contains(&base.as_str()) {
469 return false;
470 }
471 !tokens[1..]
472 .iter()
473 .any(|t| !t.starts_with('-') && SCRIPT_EXTENSIONS.iter().any(|ext| t.ends_with(ext)))
474}
475
476const DANGEROUS_GIT_FLAGS: &[&str] = &[
478 "--upload-pack",
479 "--receive-pack",
480 "--config=core.sshcommand",
481 "--config=core.gitproxy",
482];
483
484const DANGEROUS_TAR_FLAGS: &[&str] = &["--to-command", "--use-compress-program"];
485
486const BLOCKED_INLINE_ENV: &[&str] = &[
488 "PATH=",
489 "GIT_ASKPASS=",
490 "GIT_SSH=",
491 "GIT_SSH_COMMAND=",
492 "GIT_EDITOR=",
493 "GIT_EXTERNAL_DIFF=",
494 "SSH_ASKPASS=",
495 "LD_PRELOAD=",
496 "DYLD_INSERT_LIBRARIES=",
497];
498
499fn check_dangerous_flags(segment: &str) -> Result<(), String> {
500 let trimmed = skip_env_assignments(segment.trim());
501 let tokens = shell_tokenize(trimmed);
502 if tokens.is_empty() {
503 return Ok(());
504 }
505 let base = tokens[0]
506 .rsplit('/')
507 .next()
508 .unwrap_or(&tokens[0])
509 .to_string();
510
511 match base.as_str() {
512 "git" => {
513 for tok in &tokens[1..] {
514 for flag in DANGEROUS_GIT_FLAGS {
515 if tok.starts_with(flag) {
516 return Err(format!(
517 "[BLOCKED — DO NOT RETRY] 'git' with dangerous flag '{tok}' is blocked.\n\
518 This is a permanent security restriction."
519 ));
520 }
521 }
522 }
523 }
524 "tar" => {
525 for tok in &tokens[1..] {
526 for flag in DANGEROUS_TAR_FLAGS {
527 if tok.starts_with(flag) {
528 return Err(format!(
529 "[BLOCKED — DO NOT RETRY] 'tar' with dangerous flag '{tok}' is blocked.\n\
530 This is a permanent security restriction."
531 ));
532 }
533 }
534 }
535 }
536 "find" => {
537 for tok in &tokens[1..] {
538 if tok == "-exec" || tok == "-execdir" {
539 return Err(format!(
540 "[BLOCKED — DO NOT RETRY] 'find' with '{tok}' is blocked. \
541 Use 'find ... -print' and pipe to xargs instead.\n\
542 This is a permanent security restriction."
543 ));
544 }
545 }
546 }
547 "awk" | "gawk" | "mawk" => {
548 for tok in &tokens[1..] {
549 if tok.contains("system(") {
550 return Err(format!(
551 "[BLOCKED — DO NOT RETRY] '{base}' with 'system()' call is blocked.\n\
552 This is a permanent security restriction."
553 ));
554 }
555 }
556 }
557 _ => {}
558 }
559 Ok(())
560}
561
562fn check_inline_env_block(segment: &str) -> Result<(), String> {
563 let trimmed = segment.trim();
564 for blocked in BLOCKED_INLINE_ENV {
565 if trimmed.starts_with(blocked) {
566 return Err(format!(
567 "[BLOCKED — DO NOT RETRY] Inline environment override '{blocked}' is blocked.\n\
568 This is a permanent security restriction."
569 ));
570 }
571 }
572 Ok(())
573}
574
575const HEADER_KEYWORDS: &[&str] = &["for", "select", "in", "done", "fi"];
580
581const BODY_INTRO_KEYWORDS: &[&str] = &[
586 "do", "then", "else", "elif", "if", "while", "until", "time", "!",
587];
588
589fn expand_to_leaf_segments(command: &str) -> Result<Vec<String>, String> {
598 if has_case_construct(command) {
599 return Err(format!(
600 "[BLOCKED — DO NOT RETRY] `case`/`esac` constructs are not supported in \
601 restricted (allowlisted) shell mode — their `pattern)` arms cannot be \
602 leaf-validated safely. Run a script file or disable the allowlist instead.\n\
603 Command: {command}"
604 ));
605 }
606 let mut leaves = Vec::new();
607 for seg in extract_all_commands(command) {
608 resolve_segment_leaves(&seg, 0, &mut leaves)?;
609 }
610 Ok(leaves)
611}
612
613fn resolve_segment_leaves(
616 segment: &str,
617 depth: usize,
618 out: &mut Vec<String>,
619) -> Result<(), String> {
620 if depth > 4 {
621 return Err(format!(
622 "[BLOCKED — DO NOT RETRY] Shell command nests compound/subshell groups too \
623 deeply to validate safely.\nCommand: {segment}"
624 ));
625 }
626 let mut s = segment.trim();
627 loop {
628 let tokens = shell_tokenize(s);
629 let Some(first) = tokens.first() else {
630 return Ok(()); };
632 let kw = first.as_str();
633 if HEADER_KEYWORDS.contains(&kw) {
634 return Ok(()); }
636 if BODY_INTRO_KEYWORDS.contains(&kw) {
637 s = remainder_after_first_token(s).trim();
638 if s.is_empty() {
639 return Ok(());
640 }
641 continue;
642 }
643 break;
644 }
645 if let Some(inner) = balanced_paren_inner(s) {
646 for inner_seg in extract_all_commands(inner) {
647 resolve_segment_leaves(&inner_seg, depth + 1, out)?;
648 }
649 return Ok(());
650 }
651 out.push(s.to_string());
656 Ok(())
657}
658
659fn remainder_after_first_token(s: &str) -> &str {
661 let trimmed = s.trim_start();
662 let end = quote_aware_token_end(trimmed);
663 &trimmed[end..]
664}
665
666fn balanced_paren_inner(segment: &str) -> Option<&str> {
670 let trimmed = segment.trim();
671 let bytes = trimmed.as_bytes();
672 if bytes.first() != Some(&b'(') {
673 return None;
674 }
675 let len = bytes.len();
676 let mut depth: i32 = 0;
677 let mut in_single_quote = false;
678 let mut in_double_quote = false;
679 let mut i = 0;
680 while i < len {
681 let ch = bytes[i];
682 if in_single_quote {
683 if ch == b'\'' {
684 in_single_quote = false;
685 }
686 i += 1;
687 continue;
688 }
689 if in_double_quote {
690 if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
691 in_double_quote = false;
692 }
693 i += 1;
694 continue;
695 }
696 match ch {
697 b'\'' => in_single_quote = true,
698 b'"' => in_double_quote = true,
699 b'(' => depth += 1,
700 b')' => {
701 depth -= 1;
702 if depth == 0 {
703 return if i == len - 1 {
704 Some(trimmed[1..i].trim())
705 } else {
706 None
707 };
708 }
709 }
710 _ => {}
711 }
712 i += 1;
713 }
714 None
715}
716
717fn has_case_construct(command: &str) -> bool {
721 for seg in split_on_operators(command) {
722 if shell_tokenize(seg.trim())
723 .iter()
724 .any(|t| t == "case" || t == "esac")
725 {
726 return true;
727 }
728 }
729 contains_double_semicolon(command)
730}
731
732fn contains_double_semicolon(command: &str) -> bool {
734 let bytes = command.as_bytes();
735 let len = bytes.len();
736 let mut in_single_quote = false;
737 let mut in_double_quote = false;
738 let mut i = 0;
739 while i < len {
740 let ch = bytes[i];
741 if in_single_quote {
742 if ch == b'\'' {
743 in_single_quote = false;
744 }
745 i += 1;
746 continue;
747 }
748 if in_double_quote {
749 if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
750 in_double_quote = false;
751 }
752 i += 1;
753 continue;
754 }
755 match ch {
756 b'\'' => in_single_quote = true,
757 b'"' => in_double_quote = true,
758 b';' if i + 1 < len && bytes[i + 1] == b';' => return true,
759 _ => {}
760 }
761 i += 1;
762 }
763 false
764}
765
766fn check_all_segments(command: &str, allowlist: &[String]) -> Result<(), String> {
767 if allowlist.is_empty() {
768 return Ok(());
769 }
770
771 if has_dangerous_patterns(command) {
772 return Err(format!(
773 "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
774 which is blocked in restricted mode. \
775 This is a permanent security restriction, not a transient error.\n\
776 Command: {command}"
777 ));
778 }
779
780 let segments = expand_to_leaf_segments(command)?;
781 if segments.is_empty() {
782 return Err("[BLOCKED — DO NOT RETRY] Empty command".to_string());
783 }
784
785 for seg in &segments {
786 check_inline_env_block(seg)?;
787 let base = extract_base_from_segment(seg);
788 if base.is_empty() {
789 continue;
790 }
791 if UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
792 return Err(format!(
793 "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
794 regardless of allowlist membership. \
795 This is a permanent security restriction.\n\
796 Command: {command}"
797 ));
798 }
799 check_interpreter_abuse(seg, allowlist)?;
800 check_dangerous_flags(seg)?;
801 if !allowlist.iter().any(|a| a == &base) {
802 return Err(allowlist_block_message(&base));
803 }
804 }
805 Ok(())
806}
807
808fn has_dangerous_patterns(command: &str) -> bool {
816 let trimmed = command.trim();
817
818 for blocked in UNCONDITIONAL_BLOCKED {
819 let with_space = format!("{blocked} ");
820 if trimmed.starts_with(&with_space) {
821 return true;
822 }
823 for sep in ["; ", "&& ", "|| ", "| ", "\n"] {
824 if trimmed.contains(&format!("{sep}{blocked} ")) {
825 return true;
826 }
827 }
828 }
829
830 if has_substitution_at_command_pos(trimmed) {
831 return true;
832 }
833
834 false
835}
836
837fn has_substitution_at_command_pos(command: &str) -> bool {
841 let segments = split_on_operators(command);
842 for seg in segments {
843 let trimmed = seg.trim();
844 let cmd_start = skip_env_assignments(trimmed);
845
846 if cmd_start.starts_with("$(") {
847 return true;
848 }
849
850 let tokens = shell_tokenize(cmd_start);
851 let first_token = tokens.first().map_or("", std::string::String::as_str);
852 if first_token.starts_with('`') || first_token == "`" {
853 return true;
854 }
855 }
856 false
857}
858
859fn extract_all_commands(command: &str) -> Vec<String> {
862 split_on_operators(command)
863 .into_iter()
864 .map(|s| s.trim().to_string())
865 .filter(|s| !s.is_empty())
866 .collect()
867}
868
869fn split_on_operators(command: &str) -> Vec<&str> {
872 let mut segments = Vec::new();
873 let mut start = 0;
874 let bytes = command.as_bytes();
875 let len = bytes.len();
876 let mut i = 0;
877 let mut in_single_quote = false;
878 let mut in_double_quote = false;
879 let mut paren_depth: u32 = 0;
880
881 while i < len {
882 let ch = bytes[i];
883
884 if in_single_quote {
885 if ch == b'\'' {
886 in_single_quote = false;
887 }
888 i += 1;
889 continue;
890 }
891
892 if in_double_quote {
893 if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
894 in_double_quote = false;
895 }
896 i += 1;
897 continue;
898 }
899
900 match ch {
901 b'\'' => {
902 in_single_quote = true;
903 i += 1;
904 }
905 b'"' => {
906 in_double_quote = true;
907 i += 1;
908 }
909 b'(' => {
910 paren_depth += 1;
911 i += 1;
912 }
913 b')' => {
914 paren_depth = paren_depth.saturating_sub(1);
915 i += 1;
916 }
917 b'\n' | b'\r' | b';' if paren_depth == 0 => {
918 segments.push(&command[start..i]);
919 i += 1;
920 start = i;
921 }
922 b'&' if paren_depth == 0 => {
923 if i + 1 < len && bytes[i + 1] == b'&' {
924 segments.push(&command[start..i]);
926 i += 2;
927 start = i;
928 } else if (i > 0 && bytes[i - 1] == b'>') || (i + 1 < len && bytes[i + 1] == b'>') {
929 i += 1;
934 } else {
935 segments.push(&command[start..i]);
937 i += 1;
938 start = i;
939 }
940 }
941 b'|' if paren_depth == 0 => {
942 if i + 1 < len && bytes[i + 1] == b'|' {
943 segments.push(&command[start..i]);
945 i += 2;
946 start = i;
947 } else if i > 0 && bytes[i - 1] == b'>' {
948 i += 1;
954 } else {
955 segments.push(&command[start..i]);
957 i += 1;
958 start = i;
959 }
960 }
961 _ => {
962 i += 1;
963 }
964 }
965 }
966
967 if start < len {
968 segments.push(&command[start..]);
969 }
970
971 segments
972}
973
974fn extract_base_from_segment(segment: &str) -> String {
976 let trimmed = segment.trim();
977 if trimmed.is_empty() {
978 return String::new();
979 }
980
981 let cmd_part = skip_env_assignments(trimmed);
982 if cmd_part.is_empty() {
983 return String::new();
984 }
985
986 let tokens = shell_tokenize(cmd_part);
987 let first_token = tokens.first().map_or("", std::string::String::as_str);
988
989 first_token
990 .rsplit('/')
991 .next()
992 .unwrap_or(first_token)
993 .to_string()
994}
995
996fn skip_env_assignments(segment: &str) -> &str {
1000 let mut rest = segment;
1001 loop {
1002 let rest_trimmed = rest.trim_start();
1003 if rest_trimmed.is_empty() {
1004 return rest_trimmed;
1005 }
1006 let end = quote_aware_token_end(rest_trimmed);
1007 if end == 0 {
1008 return rest_trimmed;
1009 }
1010 let raw_token = &rest_trimmed[..end];
1011 let unquoted: String = raw_token
1012 .chars()
1013 .filter(|c| *c != '"' && *c != '\'')
1014 .collect();
1015 if unquoted.contains('=')
1016 && !unquoted.starts_with('-')
1017 && !unquoted.starts_with('/')
1018 && !unquoted.starts_with('.')
1019 {
1020 rest = &rest_trimmed[end..];
1021 } else {
1022 return rest_trimmed;
1023 }
1024 }
1025}
1026
1027fn effective_allowlist() -> Vec<String> {
1028 if let Ok(ov) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE") {
1030 return ov
1031 .split(',')
1032 .map(|s| s.trim().to_string())
1033 .filter(|s| !s.is_empty())
1034 .collect();
1035 }
1036 let cfg = crate::core::config::Config::load();
1037 let mut list = cfg.shell_allowlist;
1038 if !list.is_empty() {
1042 for entry in cfg.shell_allowlist_extra {
1043 if !entry.is_empty() && !list.contains(&entry) {
1044 list.push(entry);
1045 }
1046 }
1047 }
1048 if let Ok(env_val) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST") {
1049 for entry in env_val
1050 .split(',')
1051 .map(|s| s.trim().to_string())
1052 .filter(|s| !s.is_empty())
1053 {
1054 if !list.contains(&entry) {
1055 list.push(entry);
1056 }
1057 }
1058 }
1059 list
1060}
1061
1062fn allowlist_block_message(base: &str) -> String {
1069 let cfg_path = crate::core::config::Config::path().map_or_else(
1070 || "~/.lean-ctx/config.toml".to_string(),
1071 |p| p.display().to_string(),
1072 );
1073
1074 let mut msg = format!(
1075 "[BLOCKED — DO NOT RETRY] '{base}' is not in the shell allowlist. \
1076 This is a permanent restriction, not a transient error.\n\
1077 Fix (additive, keeps the defaults): run lean-ctx allow {base}\n\
1078 Config in effect: {cfg_path}\n\
1079 Or disable the allowlist entirely: set shell_allowlist = []\n\
1080 Do NOT retry this command — it will fail again with the same error."
1081 );
1082
1083 if crate::core::config::cloud_infra_commands().contains(&base) {
1084 msg.push_str(
1085 "\nNote: cloud/infra CLIs (terraform, kubectl, aws, …) are deliberately \
1086 excluded from the defaults — they mutate remote infrastructure with \
1087 ambient credentials. Opting in is a deliberate user decision.",
1088 );
1089 }
1090
1091 if let Some(parse_err) = crate::core::config::last_config_parse_error() {
1092 msg.push_str(&format!(
1093 "\n\n⚠ Your config.toml currently FAILS to parse, so lean-ctx is running on the \
1094 built-in defaults — this is almost certainly why editing the allowlist had no \
1095 effect. Fix the TOML error below, then retry:\n {parse_err}\n File: {cfg_path}"
1096 ));
1097 }
1098
1099 msg
1100}
1101
1102pub fn extract_all_commands_pub(command: &str) -> Vec<String> {
1104 extract_all_commands(command)
1105}
1106
1107#[must_use]
1112pub fn effective_allowlist_pub() -> Vec<String> {
1113 effective_allowlist()
1114}
1115
1116pub fn extract_base_command(command: &str) -> String {
1118 let first_seg = split_on_operators(command)
1119 .into_iter()
1120 .next()
1121 .unwrap_or(command);
1122 extract_base_from_segment(first_seg)
1123}