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
575fn check_all_segments(command: &str, allowlist: &[String]) -> Result<(), String> {
576 if allowlist.is_empty() {
577 return Ok(());
578 }
579
580 if has_dangerous_patterns(command) {
581 return Err(format!(
582 "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
583 which is blocked in restricted mode. \
584 This is a permanent security restriction, not a transient error.\n\
585 Command: {command}"
586 ));
587 }
588
589 let segments = extract_all_commands(command);
590 if segments.is_empty() {
591 return Err("[BLOCKED — DO NOT RETRY] Empty command".to_string());
592 }
593
594 for seg in &segments {
595 check_inline_env_block(seg)?;
596 let base = extract_base_from_segment(seg);
597 if base.is_empty() {
598 continue;
599 }
600 if UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
601 return Err(format!(
602 "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
603 regardless of allowlist membership. \
604 This is a permanent security restriction.\n\
605 Command: {command}"
606 ));
607 }
608 check_interpreter_abuse(seg, allowlist)?;
609 check_dangerous_flags(seg)?;
610 if !allowlist.iter().any(|a| a == &base) {
611 return Err(allowlist_block_message(&base));
612 }
613 }
614 Ok(())
615}
616
617fn has_dangerous_patterns(command: &str) -> bool {
625 let trimmed = command.trim();
626
627 for blocked in UNCONDITIONAL_BLOCKED {
628 let with_space = format!("{blocked} ");
629 if trimmed.starts_with(&with_space) {
630 return true;
631 }
632 for sep in ["; ", "&& ", "|| ", "| ", "\n"] {
633 if trimmed.contains(&format!("{sep}{blocked} ")) {
634 return true;
635 }
636 }
637 }
638
639 if has_substitution_at_command_pos(trimmed) {
640 return true;
641 }
642
643 false
644}
645
646fn has_substitution_at_command_pos(command: &str) -> bool {
650 let segments = split_on_operators(command);
651 for seg in segments {
652 let trimmed = seg.trim();
653 let cmd_start = skip_env_assignments(trimmed);
654
655 if cmd_start.starts_with("$(") {
656 return true;
657 }
658
659 let tokens = shell_tokenize(cmd_start);
660 let first_token = tokens.first().map_or("", std::string::String::as_str);
661 if first_token.starts_with('`') || first_token == "`" {
662 return true;
663 }
664 }
665 false
666}
667
668fn extract_all_commands(command: &str) -> Vec<String> {
671 split_on_operators(command)
672 .into_iter()
673 .map(|s| s.trim().to_string())
674 .filter(|s| !s.is_empty())
675 .collect()
676}
677
678fn split_on_operators(command: &str) -> Vec<&str> {
681 let mut segments = Vec::new();
682 let mut start = 0;
683 let bytes = command.as_bytes();
684 let len = bytes.len();
685 let mut i = 0;
686 let mut in_single_quote = false;
687 let mut in_double_quote = false;
688 let mut paren_depth: u32 = 0;
689
690 while i < len {
691 let ch = bytes[i];
692
693 if in_single_quote {
694 if ch == b'\'' {
695 in_single_quote = false;
696 }
697 i += 1;
698 continue;
699 }
700
701 if in_double_quote {
702 if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
703 in_double_quote = false;
704 }
705 i += 1;
706 continue;
707 }
708
709 match ch {
710 b'\'' => {
711 in_single_quote = true;
712 i += 1;
713 }
714 b'"' => {
715 in_double_quote = true;
716 i += 1;
717 }
718 b'(' => {
719 paren_depth += 1;
720 i += 1;
721 }
722 b')' => {
723 paren_depth = paren_depth.saturating_sub(1);
724 i += 1;
725 }
726 b'\n' | b'\r' | b';' if paren_depth == 0 => {
727 segments.push(&command[start..i]);
728 i += 1;
729 start = i;
730 }
731 b'&' if paren_depth == 0 => {
732 if i + 1 < len && bytes[i + 1] == b'&' {
733 segments.push(&command[start..i]);
735 i += 2;
736 start = i;
737 } else if (i > 0 && bytes[i - 1] == b'>') || (i + 1 < len && bytes[i + 1] == b'>') {
738 i += 1;
743 } else {
744 segments.push(&command[start..i]);
746 i += 1;
747 start = i;
748 }
749 }
750 b'|' if paren_depth == 0 => {
751 if i + 1 < len && bytes[i + 1] == b'|' {
752 segments.push(&command[start..i]);
754 i += 2;
755 start = i;
756 } else if i > 0 && bytes[i - 1] == b'>' {
757 i += 1;
763 } else {
764 segments.push(&command[start..i]);
766 i += 1;
767 start = i;
768 }
769 }
770 _ => {
771 i += 1;
772 }
773 }
774 }
775
776 if start < len {
777 segments.push(&command[start..]);
778 }
779
780 segments
781}
782
783fn extract_base_from_segment(segment: &str) -> String {
785 let trimmed = segment.trim();
786 if trimmed.is_empty() {
787 return String::new();
788 }
789
790 let cmd_part = skip_env_assignments(trimmed);
791 if cmd_part.is_empty() {
792 return String::new();
793 }
794
795 let tokens = shell_tokenize(cmd_part);
796 let first_token = tokens.first().map_or("", std::string::String::as_str);
797
798 first_token
799 .rsplit('/')
800 .next()
801 .unwrap_or(first_token)
802 .to_string()
803}
804
805fn skip_env_assignments(segment: &str) -> &str {
809 let mut rest = segment;
810 loop {
811 let rest_trimmed = rest.trim_start();
812 if rest_trimmed.is_empty() {
813 return rest_trimmed;
814 }
815 let end = quote_aware_token_end(rest_trimmed);
816 if end == 0 {
817 return rest_trimmed;
818 }
819 let raw_token = &rest_trimmed[..end];
820 let unquoted: String = raw_token
821 .chars()
822 .filter(|c| *c != '"' && *c != '\'')
823 .collect();
824 if unquoted.contains('=')
825 && !unquoted.starts_with('-')
826 && !unquoted.starts_with('/')
827 && !unquoted.starts_with('.')
828 {
829 rest = &rest_trimmed[end..];
830 } else {
831 return rest_trimmed;
832 }
833 }
834}
835
836fn effective_allowlist() -> Vec<String> {
837 if let Ok(ov) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE") {
839 return ov
840 .split(',')
841 .map(|s| s.trim().to_string())
842 .filter(|s| !s.is_empty())
843 .collect();
844 }
845 let cfg = crate::core::config::Config::load();
846 let mut list = cfg.shell_allowlist;
847 if !list.is_empty() {
851 for entry in cfg.shell_allowlist_extra {
852 if !entry.is_empty() && !list.contains(&entry) {
853 list.push(entry);
854 }
855 }
856 }
857 if let Ok(env_val) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST") {
858 for entry in env_val
859 .split(',')
860 .map(|s| s.trim().to_string())
861 .filter(|s| !s.is_empty())
862 {
863 if !list.contains(&entry) {
864 list.push(entry);
865 }
866 }
867 }
868 list
869}
870
871fn allowlist_block_message(base: &str) -> String {
878 let cfg_path = crate::core::config::Config::path().map_or_else(
879 || "~/.lean-ctx/config.toml".to_string(),
880 |p| p.display().to_string(),
881 );
882
883 let mut msg = format!(
884 "[BLOCKED — DO NOT RETRY] '{base}' is not in the shell allowlist. \
885 This is a permanent restriction, not a transient error.\n\
886 Fix (additive, keeps the defaults): run lean-ctx allow {base}\n\
887 Config in effect: {cfg_path}\n\
888 Or disable the allowlist entirely: set shell_allowlist = []\n\
889 Do NOT retry this command — it will fail again with the same error."
890 );
891
892 if crate::core::config::cloud_infra_commands().contains(&base) {
893 msg.push_str(
894 "\nNote: cloud/infra CLIs (terraform, kubectl, aws, …) are deliberately \
895 excluded from the defaults — they mutate remote infrastructure with \
896 ambient credentials. Opting in is a deliberate user decision.",
897 );
898 }
899
900 if let Some(parse_err) = crate::core::config::last_config_parse_error() {
901 msg.push_str(&format!(
902 "\n\n⚠ Your config.toml currently FAILS to parse, so lean-ctx is running on the \
903 built-in defaults — this is almost certainly why editing the allowlist had no \
904 effect. Fix the TOML error below, then retry:\n {parse_err}\n File: {cfg_path}"
905 ));
906 }
907
908 msg
909}
910
911pub fn extract_all_commands_pub(command: &str) -> Vec<String> {
913 extract_all_commands(command)
914}
915
916#[must_use]
921pub fn effective_allowlist_pub() -> Vec<String> {
922 effective_allowlist()
923}
924
925pub fn extract_base_command(command: &str) -> String {
927 let first_seg = split_on_operators(command)
928 .into_iter()
929 .next()
930 .unwrap_or(command);
931 extract_base_from_segment(first_seg)
932}