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