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