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 quoted_stripped = strip_quoted_heredoc_bodies(&normalized);
70 let all_stripped = strip_all_heredoc_bodies(&normalized);
73 let cmd = quoted_stripped.as_str();
74 let cmd_all = all_stripped.as_str();
75
76 if has_dangerous_patterns(cmd) {
77 return Err(format!(
78 "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
79 which is blocked regardless of allowlist. \
80 This is a permanent security restriction, not a transient error.\n\
81 Command: {command}"
82 )
83 .into());
84 }
85
86 let strict = crate::core::config::Config::load().shell_strict_mode;
87 check_substitution_in_args(cmd, strict)?;
88 check_pipe_to_bare_interpreter(cmd, strict)?;
89
90 let allowlist = effective_allowlist();
91 if allowlist.is_empty() {
92 check_unconditional_blocked_only(cmd_all)?;
93 return Ok(());
94 }
95 check_all_segments(cmd_all, &allowlist)
96}
97
98fn normalize_line_continuations(command: &str) -> String {
101 command
102 .replace("\\\r\n", "")
103 .replace("\\\n", "")
104 .replace(['\u{2028}', '\u{2029}'], "\n")
105}
106
107fn strip_quoted_heredoc_bodies(command: &str) -> String {
120 if !command.contains("<<") {
121 return command.to_string();
122 }
123 let mut out: Vec<&str> = Vec::new();
124 let mut pending: Vec<String> = Vec::new();
127 for line in command.lines() {
128 if pending.is_empty() {
129 out.push(line);
130 pending = heredoc_delims(line, true);
131 } else if line.trim_start_matches('\t').trim() == pending[0] {
132 pending.remove(0);
136 }
137 }
139 out.join("\n")
140}
141
142pub fn strip_all_heredoc_bodies(command: &str) -> String {
146 if !command.contains("<<") {
147 return command.to_string();
148 }
149 let mut out: Vec<&str> = Vec::new();
150 let mut pending: Vec<String> = Vec::new();
151 for line in command.lines() {
152 if pending.is_empty() {
153 out.push(line);
154 pending = heredoc_delims(line, false);
155 } else if line.trim_start_matches('\t').trim() == pending[0] {
156 pending.remove(0);
157 }
158 }
159 out.join("\n")
160}
161
162fn heredoc_delims(line: &str, quoted_only: bool) -> Vec<String> {
166 let bytes = line.as_bytes();
167 let len = bytes.len();
168 let mut i = 0;
169 let mut in_single = false;
170 let mut in_double = false;
171 let mut delims = Vec::new();
172 while i < len {
173 let ch = bytes[i];
174 if in_single {
175 if ch == b'\'' {
176 in_single = false;
177 }
178 i += 1;
179 continue;
180 }
181 if in_double {
182 match ch {
183 b'\\' => i = (i + 2).min(len),
184 b'"' => {
185 in_double = false;
186 i += 1;
187 }
188 _ => i += 1,
189 }
190 continue;
191 }
192 match ch {
193 b'\\' => i = (i + 2).min(len),
194 b'\'' => {
195 in_single = true;
196 i += 1;
197 }
198 b'"' => {
199 in_double = true;
200 i += 1;
201 }
202 b'<' if i + 1 < len && bytes[i + 1] == b'<' => {
203 if i + 2 < len && bytes[i + 2] == b'<' {
205 i += 3;
206 continue;
207 }
208 let mut j = i + 2;
209 if j < len && bytes[j] == b'-' {
210 j += 1; }
212 while j < len && (bytes[j] == b' ' || bytes[j] == b'\t') {
213 j += 1;
214 }
215 if let Some((delim, quoted, next)) = read_heredoc_delim(bytes, j) {
216 if !quoted_only || quoted {
217 delims.push(delim);
218 }
219 i = next;
220 continue;
221 }
222 i = j;
223 }
224 _ => i += 1,
225 }
226 }
227 delims
228}
229
230fn read_heredoc_delim(bytes: &[u8], start: usize) -> Option<(String, bool, usize)> {
234 let len = bytes.len();
235 let mut i = start;
236 let mut name: Vec<u8> = Vec::new();
237 let mut quoted = false;
238 while i < len {
239 match bytes[i] {
240 b'\'' => {
241 quoted = true;
242 i += 1;
243 while i < len && bytes[i] != b'\'' {
244 name.push(bytes[i]);
245 i += 1;
246 }
247 i += usize::from(i < len); }
249 b'"' => {
250 quoted = true;
251 i += 1;
252 while i < len && bytes[i] != b'"' {
253 name.push(bytes[i]);
254 i += 1;
255 }
256 i += usize::from(i < len);
257 }
258 b'\\' => {
259 quoted = true;
260 i += 1;
261 if i < len {
262 name.push(bytes[i]);
263 i += 1;
264 }
265 }
266 b' ' | b'\t' | b'<' | b'>' | b'|' | b'&' | b';' => break,
267 c => {
268 name.push(c);
269 i += 1;
270 }
271 }
272 }
273 if name.is_empty() {
274 None
275 } else {
276 Some((String::from_utf8_lossy(&name).into_owned(), quoted, i))
277 }
278}
279
280fn check_substitution_in_args(command: &str, strict: bool) -> Result<(), ShellError> {
284 if has_expanding_substitution_in_args(command) {
285 if strict {
286 tracing::warn!(
287 "[SECURITY] Command substitution in arguments blocked (shell_strict_mode=true): {command}"
288 );
289 return Err(format!(
290 "[BLOCKED — DO NOT RETRY] Command substitution ($(), backticks, <()/>()) in \
291 arguments is blocked because shell_strict_mode = true. \
292 This is a permanent security restriction.\n\
293 Command: {command}"
294 )
295 .into());
296 }
297 tracing::warn!(
298 "[SECURITY] Command substitution in arguments detected (warn-only, set shell_strict_mode=true to block): {command}"
299 );
300 }
301 Ok(())
302}
303
304fn has_expanding_substitution_in_args(command: &str) -> bool {
308 let bytes = command.as_bytes();
309 let len = bytes.len();
310 let mut i = 0;
311 let mut in_single_quote = false;
312 let mut seen_space_after_cmd = false;
313
314 while i < len {
315 let ch = bytes[i];
316 if in_single_quote {
317 if ch == b'\'' {
318 in_single_quote = false;
319 }
320 i += 1;
321 continue;
322 }
323 if ch == b'\\' {
327 i = (i + 2).min(len);
328 continue;
329 }
330 match ch {
331 b'\'' => {
332 in_single_quote = true;
333 i += 1;
334 }
335 b' ' | b'\t' if !seen_space_after_cmd => {
336 seen_space_after_cmd = true;
337 i += 1;
338 }
339 _ if !seen_space_after_cmd => {
340 i += 1;
341 }
342 _ => {
343 if ch == b'$' && i + 1 < len && bytes[i + 1] == b'(' {
344 return true;
345 }
346 if ch == b'`' {
347 return true;
348 }
349 if (ch == b'<' || ch == b'>') && i + 1 < len && bytes[i + 1] == b'(' {
350 return true;
351 }
352 i += 1;
353 }
354 }
355 }
356 false
357}
358
359fn check_pipe_to_bare_interpreter(command: &str, strict: bool) -> Result<(), ShellError> {
362 let segments = split_on_operators(command);
363
364 for (idx, seg) in segments.iter().enumerate() {
365 if idx == 0 {
366 continue;
367 }
368 if is_bare_interpreter_stdin(seg) {
369 let base = extract_base_from_segment(seg);
370 if strict {
371 tracing::warn!(
372 "[SECURITY] Pipe to bare interpreter '{base}' blocked (shell_strict_mode=true)"
373 );
374 return Err(format!(
375 "[BLOCKED — DO NOT RETRY] Piping into bare interpreter '{base}' is blocked \
376 because shell_strict_mode = true. Run a script file instead.\n\
377 Command: {command}"
378 )
379 .into());
380 }
381 tracing::warn!("[SECURITY] Pipe to bare interpreter '{base}' detected (warn-only)");
382 }
383 }
384 Ok(())
385}
386
387fn check_unconditional_blocked_only(command: &str) -> Result<(), ShellError> {
389 let segments = extract_all_commands(command);
390 for seg in &segments {
391 let base = extract_base_from_segment(seg);
392 if !base.is_empty() && UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
393 return Err(format!(
394 "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
395 regardless of allowlist configuration.\n\
396 Command: {command}"
397 )
398 .into());
399 }
400 check_inline_env_block(seg)?;
401 check_interpreter_eval_only(seg)?;
402 check_dangerous_flags(seg)?;
403 }
404 Ok(())
405}
406
407pub fn shell_tokenize(input: &str) -> Vec<String> {
411 let mut tokens = Vec::new();
412 let mut current = String::new();
413 let mut chars = input.chars().peekable();
414 let mut in_single = false;
415 let mut in_double = false;
416
417 while let Some(c) = chars.next() {
418 match c {
419 '\'' if !in_double => in_single = !in_single,
420 '"' if !in_single => in_double = !in_double,
421 '\\' if !in_single => {
422 if let Some(next) = chars.next() {
423 current.push(next);
424 }
425 }
426 c if c.is_whitespace() && !in_single && !in_double => {
427 if !current.is_empty() {
428 tokens.push(std::mem::take(&mut current));
429 }
430 }
431 _ => current.push(c),
432 }
433 }
434 if !current.is_empty() {
435 tokens.push(current);
436 }
437 tokens
438}
439
440fn quote_aware_token_end(input: &str) -> usize {
449 let bytes = input.as_bytes();
450 let len = bytes.len();
451 let mut i = 0;
452 let mut in_single = false;
453 let mut in_double = false;
454 let mut paren_depth: u32 = 0;
455
456 while i < len {
457 let ch = bytes[i];
458 match ch {
459 b'\'' if !in_double => {
460 in_single = !in_single;
461 i += 1;
462 }
463 b'"' if !in_single => {
464 in_double = !in_double;
465 i += 1;
466 }
467 b'\\' if !in_single => {
468 i = (i + 2).min(len);
469 }
470 b'(' if !in_single && !in_double => {
471 paren_depth += 1;
472 i += 1;
473 }
474 b')' if !in_single && !in_double && paren_depth > 0 => {
475 paren_depth -= 1;
476 i += 1;
477 }
478 b if b.is_ascii_whitespace() && !in_single && !in_double && paren_depth == 0 => {
479 return i;
480 }
481 _ => i += 1,
482 }
483 }
484 len
485}
486
487fn check_interpreter_eval_only(segment: &str) -> Result<(), ShellError> {
492 let inline_ok = crate::core::config::Config::load().shell_allow_inline_scripts_effective();
493 check_interpreter_inner(segment, None, 0, inline_ok)
494}
495
496fn check_interpreter_inner(
501 segment: &str,
502 allowlist: Option<&[String]>,
503 depth: usize,
504 inline_ok: bool,
505) -> Result<(), ShellError> {
506 if depth > 3 {
507 return Ok(());
508 }
509 let trimmed = skip_env_assignments(segment.trim());
510 let tokens = shell_tokenize(trimmed);
511 if tokens.is_empty() {
512 return Ok(());
513 }
514 let base = tokens[0]
515 .rsplit('/')
516 .next()
517 .unwrap_or(&tokens[0])
518 .to_string();
519
520 if INTERPRETER_COMMANDS.contains(&base.as_str()) && !inline_ok {
522 for tok in &tokens[1..] {
523 if EVAL_FLAGS.contains(&tok.as_str()) {
524 return Err(format!(
525 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with inline code execution \
526 flag '{tok}' is blocked. Use a script file instead.\n\
527 This is a permanent security restriction."
528 )
529 .into());
530 }
531 if has_eval_flag_prefix(tok) {
532 return Err(format!(
533 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with combined flag '{tok}' \
534 containing eval flag is blocked.\n\
535 This is a permanent security restriction."
536 )
537 .into());
538 }
539 }
540 if tokens[1..].iter().any(|t| t.contains("<<")) {
541 return Err(heredoc_blocked_message(&base).into());
542 }
543 }
544
545 if DELEGATION_COMMANDS.contains(&base.as_str()) {
547 let rest_tokens = delegated_command_tokens(&tokens[1..]);
548 if let Some(&delegated_tok) = rest_tokens.first() {
549 if let Some(al) = allowlist {
551 let delegated = delegated_tok.rsplit('/').next().unwrap_or(delegated_tok);
552 if !delegated.is_empty() && !al.iter().any(|a| a == delegated) {
553 return Err(format!(
554 "[BLOCKED — DO NOT RETRY] '{base}' delegates to '{delegated}' which is not \
555 in the shell allowlist. This is a permanent restriction."
556 )
557 .into());
558 }
559 }
560 let rest_str = rest_tokens.join(" ");
561 return check_interpreter_inner(&rest_str, allowlist, depth + 1, inline_ok);
562 }
563 }
564
565 Ok(())
566}
567
568fn heredoc_blocked_message(base: &str) -> String {
574 format!(
575 "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with heredoc stdin is blocked. \
576 Inline code in the command string leaves no auditable artifact.\n\
577 Do this instead: write the code to a file, then run it —\n\
578 1. create /tmp/snippet with your code (Write/ctx_edit tool)\n\
579 2. {base} /tmp/snippet\n\
580 This is a permanent security restriction."
581 )
582}
583
584const UNCONDITIONAL_BLOCKED: &[&str] = &["eval", "exec", "source", "."];
587
588const INTERPRETER_COMMANDS: &[&str] = &[
590 "python", "python3", "python2", "node", "ruby", "perl", "lua", "php", "bash", "sh", "zsh",
591 "fish", "dash", "ksh",
592];
593
594const EVAL_FLAGS: &[&str] = &[
596 "-c", "-e", "-r", "-p", "--eval", "--exec", "-exec", "--print", "--run",
597];
598
599const SCRIPT_EXTENSIONS: &[&str] = &[
601 ".py", ".rb", ".js", ".ts", ".pl", ".lua", ".php", ".sh", ".bash", ".zsh", ".mjs", ".cjs",
602 ".tsx", ".jsx",
603];
604
605const DELEGATION_COMMANDS: &[&str] = &["env", "nice", "timeout", "sudo", "doas", "xargs", "nohup"];
609
610fn delegated_command_tokens(tokens: &[String]) -> Vec<&str> {
614 tokens
615 .iter()
616 .map(std::string::String::as_str)
617 .skip_while(|t| {
618 t.starts_with('-')
619 || t.contains('=')
620 || *t == "{}"
621 || (!t.is_empty() && t.chars().all(|c| c.is_ascii_digit()))
622 })
623 .collect()
624}
625
626fn check_interpreter_abuse(segment: &str, allowlist: &[String]) -> Result<(), ShellError> {
629 let inline_ok = crate::core::config::Config::load().shell_allow_inline_scripts_effective();
630 check_interpreter_inner(segment, Some(allowlist), 0, inline_ok)
631}
632
633fn has_eval_flag_prefix(token: &str) -> bool {
635 if !token.starts_with('-') || token.starts_with("--") || token.len() < 3 {
636 return false;
637 }
638 let flag_chars = &token[1..];
639 let eval_chars = ['c', 'e', 'r', 'p'];
640 flag_chars.chars().any(|c| eval_chars.contains(&c))
641}
642
643fn is_bare_interpreter_stdin(segment: &str) -> bool {
645 let trimmed = skip_env_assignments(segment.trim());
646 let tokens = shell_tokenize(trimmed);
647 if tokens.is_empty() {
648 return false;
649 }
650 let base = tokens[0]
651 .rsplit('/')
652 .next()
653 .unwrap_or(&tokens[0])
654 .to_string();
655 if !INTERPRETER_COMMANDS.contains(&base.as_str()) {
656 return false;
657 }
658 !tokens[1..]
659 .iter()
660 .any(|t| !t.starts_with('-') && SCRIPT_EXTENSIONS.iter().any(|ext| t.ends_with(ext)))
661}
662
663const DANGEROUS_GIT_FLAGS: &[&str] = &[
665 "--upload-pack",
666 "--receive-pack",
667 "--config=core.sshcommand",
668 "--config=core.gitproxy",
669];
670
671const DANGEROUS_TAR_FLAGS: &[&str] = &["--to-command", "--use-compress-program"];
672
673const BLOCKED_INLINE_ENV: &[&str] = &[
675 "PATH=",
676 "GIT_ASKPASS=",
677 "GIT_SSH=",
678 "GIT_SSH_COMMAND=",
679 "GIT_EDITOR=",
680 "GIT_EXTERNAL_DIFF=",
681 "SSH_ASKPASS=",
682 "LD_PRELOAD=",
683 "DYLD_INSERT_LIBRARIES=",
684];
685
686fn check_dangerous_flags(segment: &str) -> Result<(), ShellError> {
687 let trimmed = skip_env_assignments(segment.trim());
688 let tokens = shell_tokenize(trimmed);
689 if tokens.is_empty() {
690 return Ok(());
691 }
692 let base = tokens[0]
693 .rsplit('/')
694 .next()
695 .unwrap_or(&tokens[0])
696 .to_string();
697
698 match base.as_str() {
699 "git" => {
700 for tok in &tokens[1..] {
701 for flag in DANGEROUS_GIT_FLAGS {
702 if tok.starts_with(flag) {
703 return Err(format!(
704 "[BLOCKED — DO NOT RETRY] 'git' with dangerous flag '{tok}' is blocked.\n\
705 This is a permanent security restriction."
706 ).into());
707 }
708 }
709 }
710 }
711 "tar" => {
712 for tok in &tokens[1..] {
713 for flag in DANGEROUS_TAR_FLAGS {
714 if tok.starts_with(flag) {
715 return Err(format!(
716 "[BLOCKED — DO NOT RETRY] 'tar' with dangerous flag '{tok}' is blocked.\n\
717 This is a permanent security restriction."
718 ).into());
719 }
720 }
721 }
722 }
723 "find" => {
724 for tok in &tokens[1..] {
725 if tok == "-exec" || tok == "-execdir" {
726 return Err(format!(
727 "[BLOCKED — DO NOT RETRY] 'find' with '{tok}' is blocked. \
728 Use 'find ... -print' and pipe to xargs instead.\n\
729 This is a permanent security restriction."
730 )
731 .into());
732 }
733 }
734 }
735 "awk" | "gawk" | "mawk" => {
736 for tok in &tokens[1..] {
737 if tok.contains("system(") {
738 return Err(format!(
739 "[BLOCKED — DO NOT RETRY] '{base}' with 'system()' call is blocked.\n\
740 This is a permanent security restriction."
741 )
742 .into());
743 }
744 }
745 }
746 _ => {}
747 }
748 Ok(())
749}
750
751fn check_inline_env_block(segment: &str) -> Result<(), ShellError> {
752 let trimmed = segment.trim();
753 for blocked in BLOCKED_INLINE_ENV {
754 if trimmed.starts_with(blocked) {
755 return Err(format!(
756 "[BLOCKED — DO NOT RETRY] Inline environment override '{blocked}' is blocked.\n\
757 This is a permanent security restriction."
758 )
759 .into());
760 }
761 }
762 Ok(())
763}
764
765const HEADER_KEYWORDS: &[&str] = &["for", "select", "in", "done", "fi"];
770
771const BODY_INTRO_KEYWORDS: &[&str] = &[
776 "do", "then", "else", "elif", "if", "while", "until", "time", "!",
777];
778
779fn expand_to_leaf_segments(command: &str) -> Result<Vec<String>, ShellError> {
788 if has_case_construct(command) {
789 return Err(format!(
790 "[BLOCKED — DO NOT RETRY] `case`/`esac` constructs are not supported in \
791 restricted (allowlisted) shell mode — their `pattern)` arms cannot be \
792 leaf-validated safely. Run a script file or disable the allowlist instead.\n\
793 Command: {command}"
794 )
795 .into());
796 }
797 let mut leaves = Vec::new();
798 for seg in extract_all_commands(command) {
799 resolve_segment_leaves(&seg, 0, &mut leaves)?;
800 }
801 Ok(leaves)
802}
803
804fn resolve_segment_leaves(
807 segment: &str,
808 depth: usize,
809 out: &mut Vec<String>,
810) -> Result<(), ShellError> {
811 if depth > 4 {
812 return Err(format!(
813 "[BLOCKED — DO NOT RETRY] Shell command nests compound/subshell groups too \
814 deeply to validate safely.\nCommand: {segment}"
815 )
816 .into());
817 }
818 let mut s = segment.trim();
819 loop {
820 let tokens = shell_tokenize(s);
821 let Some(first) = tokens.first() else {
822 return Ok(()); };
824 let kw = first.as_str();
825 if HEADER_KEYWORDS.contains(&kw) {
826 return Ok(()); }
828 if BODY_INTRO_KEYWORDS.contains(&kw) {
829 s = remainder_after_first_token(s).trim();
830 if s.is_empty() {
831 return Ok(());
832 }
833 continue;
834 }
835 break;
836 }
837 if let Some(inner) = balanced_paren_inner(s) {
838 for inner_seg in extract_all_commands(inner) {
839 resolve_segment_leaves(&inner_seg, depth + 1, out)?;
840 }
841 return Ok(());
842 }
843 for inner in assignment_substitution_leaves(s) {
853 for inner_seg in extract_all_commands(inner) {
854 resolve_segment_leaves(&inner_seg, depth + 1, out)?;
855 }
856 }
857 out.push(s.to_string());
862 Ok(())
863}
864
865fn balanced_paren_at(s: &str, open: usize) -> Option<(&str, usize)> {
871 let bytes = s.as_bytes();
872 let len = bytes.len();
873 let mut depth: i32 = 0;
874 let mut in_single_quote = false;
875 let mut in_double_quote = false;
876 let mut i = open;
877 while i < len {
878 let ch = bytes[i];
879 if in_single_quote {
880 if ch == b'\'' {
881 in_single_quote = false;
882 }
883 i += 1;
884 continue;
885 }
886 if in_double_quote {
887 match ch {
888 b'\\' => i = (i + 2).min(len),
889 b'"' => {
890 in_double_quote = false;
891 i += 1;
892 }
893 _ => i += 1,
894 }
895 continue;
896 }
897 match ch {
898 b'\\' => i = (i + 2).min(len),
899 b'\'' => {
900 in_single_quote = true;
901 i += 1;
902 }
903 b'"' => {
904 in_double_quote = true;
905 i += 1;
906 }
907 b'(' => {
908 depth += 1;
909 i += 1;
910 }
911 b')' => {
912 depth -= 1;
913 i += 1;
914 if depth == 0 {
915 return Some((&s[open + 1..i - 1], i));
916 }
917 }
918 _ => i += 1,
919 }
920 }
921 None
922}
923
924fn leading_assignment_prefix(s: &str) -> &str {
929 let rest = skip_env_assignments(s);
930 let offset = (rest.as_ptr() as usize).saturating_sub(s.as_ptr() as usize);
931 &s[..offset.min(s.len())]
932}
933
934fn assignment_substitution_leaves(s: &str) -> Vec<&str> {
942 let prefix = leading_assignment_prefix(s);
943 if prefix.is_empty() {
944 return Vec::new();
945 }
946 let mut found = Vec::new();
947 let bytes = prefix.as_bytes();
948 let len = bytes.len();
949 let mut in_single_quote = false;
950 let mut in_double_quote = false;
951 let mut i = 0;
952 while i < len {
953 let ch = bytes[i];
954 if in_single_quote {
955 if ch == b'\'' {
956 in_single_quote = false;
957 }
958 i += 1;
959 continue;
960 }
961 if in_double_quote {
962 match ch {
963 b'\\' => {
964 i = (i + 2).min(len);
965 continue;
966 }
967 b'"' => in_double_quote = false,
968 _ => {}
969 }
970 i += 1;
971 continue;
972 }
973 match ch {
974 b'\\' => {
975 i = (i + 2).min(len);
976 continue;
977 }
978 b'\'' => in_single_quote = true,
979 b'"' => in_double_quote = true,
980 b'$' if i + 1 < len && bytes[i + 1] == b'(' => {
981 if let Some((inner, end)) = balanced_paren_at(prefix, i + 1) {
982 found.push(inner);
983 i = end;
984 continue;
985 }
986 }
987 _ => {}
988 }
989 i += 1;
990 }
991 found
992}
993
994fn remainder_after_first_token(s: &str) -> &str {
996 let trimmed = s.trim_start();
997 let end = quote_aware_token_end(trimmed);
998 &trimmed[end..]
999}
1000
1001fn balanced_paren_inner(segment: &str) -> Option<&str> {
1005 let trimmed = segment.trim();
1006 let bytes = trimmed.as_bytes();
1007 if bytes.first() != Some(&b'(') {
1008 return None;
1009 }
1010 let len = bytes.len();
1011 let mut depth: i32 = 0;
1012 let mut in_single_quote = false;
1013 let mut in_double_quote = false;
1014 let mut i = 0;
1015 while i < len {
1016 let ch = bytes[i];
1017 if in_single_quote {
1018 if ch == b'\'' {
1019 in_single_quote = false;
1020 }
1021 i += 1;
1022 continue;
1023 }
1024 if in_double_quote {
1025 match ch {
1026 b'\\' => i += 1, b'"' => in_double_quote = false,
1028 _ => {}
1029 }
1030 i += 1;
1031 continue;
1032 }
1033 match ch {
1034 b'\\' => i += 1,
1037 b'\'' => in_single_quote = true,
1038 b'"' => in_double_quote = true,
1039 b'(' => depth += 1,
1040 b')' => {
1041 depth -= 1;
1042 if depth == 0 {
1043 return if i == len - 1 {
1044 Some(trimmed[1..i].trim())
1045 } else {
1046 None
1047 };
1048 }
1049 }
1050 _ => {}
1051 }
1052 i += 1;
1053 }
1054 None
1055}
1056
1057fn has_case_construct(command: &str) -> bool {
1061 for seg in split_on_operators(command) {
1062 if shell_tokenize(seg.trim())
1063 .iter()
1064 .any(|t| t == "case" || t == "esac")
1065 {
1066 return true;
1067 }
1068 }
1069 contains_double_semicolon(command)
1070}
1071
1072fn contains_double_semicolon(command: &str) -> bool {
1074 let bytes = command.as_bytes();
1075 let len = bytes.len();
1076 let mut in_single_quote = false;
1077 let mut in_double_quote = false;
1078 let mut i = 0;
1079 while i < len {
1080 let ch = bytes[i];
1081 if in_single_quote {
1082 if ch == b'\'' {
1083 in_single_quote = false;
1084 }
1085 i += 1;
1086 continue;
1087 }
1088 if in_double_quote {
1089 if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
1090 in_double_quote = false;
1091 }
1092 i += 1;
1093 continue;
1094 }
1095 match ch {
1096 b'\'' => in_single_quote = true,
1097 b'"' => in_double_quote = true,
1098 b';' if i + 1 < len && bytes[i + 1] == b';' => return true,
1099 _ => {}
1100 }
1101 i += 1;
1102 }
1103 false
1104}
1105
1106fn is_project_root_binary(token: &str) -> bool {
1117 if !token.contains('/') {
1118 return false;
1119 }
1120 let path = std::path::Path::new(token);
1121 let resolved = if path.is_relative() {
1122 match std::env::current_dir() {
1123 Ok(cwd) => cwd.join(path),
1124 Err(_) => return false,
1125 }
1126 } else {
1127 path.to_path_buf()
1128 };
1129 let Ok(canonical) = resolved.canonicalize() else {
1130 return false;
1131 };
1132 if !canonical.is_file() {
1133 return false;
1134 }
1135 let Some(root) = crate::server::derive_project_root_from_cwd() else {
1136 return false;
1137 };
1138 let root_path = std::path::Path::new(&root);
1139 let canonical_root = root_path
1140 .canonicalize()
1141 .unwrap_or_else(|_| root_path.to_path_buf());
1142 canonical.starts_with(&canonical_root)
1143}
1144
1145fn check_all_segments(command: &str, allowlist: &[String]) -> Result<(), ShellError> {
1146 if allowlist.is_empty() {
1147 return Ok(());
1148 }
1149
1150 if has_dangerous_patterns(command) {
1151 return Err(format!(
1152 "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
1153 which is blocked in restricted mode. \
1154 This is a permanent security restriction, not a transient error.\n\
1155 Command: {command}"
1156 )
1157 .into());
1158 }
1159
1160 let segments = expand_to_leaf_segments(command)?;
1161 if segments.is_empty() {
1162 return Err("[BLOCKED — DO NOT RETRY] Empty command".into());
1163 }
1164
1165 let total = segments.len();
1166 for (idx, seg) in segments.iter().enumerate() {
1167 check_inline_env_block(seg)?;
1168 let base = extract_base_from_segment(seg);
1169 if base.is_empty() {
1170 continue;
1171 }
1172 if UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
1173 return Err(format!(
1174 "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
1175 regardless of allowlist membership. \
1176 This is a permanent security restriction.\n\
1177 Command: {command}"
1178 )
1179 .into());
1180 }
1181 check_interpreter_abuse(seg, allowlist)?;
1182 check_dangerous_flags(seg)?;
1183 if !allowlist.iter().any(|a| a == &base) {
1184 let first_token = shell_tokenize(skip_env_assignments(seg.trim()))
1188 .into_iter()
1189 .next()
1190 .unwrap_or_default();
1191 if is_project_root_binary(&first_token) {
1192 tracing::info!(
1193 "[shell_allowlist] auto-allowing project-root binary: {first_token}"
1194 );
1195 continue;
1196 }
1197
1198 let mut msg = allowlist_block_message(&base);
1202 if total > 1 {
1203 msg.push_str(&format!(
1204 "\n\n[pipeline: segment {}/{total} blocked — \
1205 the entire command was rejected before execution, \
1206 no part of the pipeline ran]",
1207 idx + 1,
1208 ));
1209 }
1210 return Err(msg.into());
1211 }
1212 }
1213 Ok(())
1214}
1215
1216fn has_dangerous_patterns(command: &str) -> bool {
1224 let trimmed = command.trim();
1225
1226 for blocked in UNCONDITIONAL_BLOCKED {
1227 let with_space = format!("{blocked} ");
1228 if trimmed.starts_with(&with_space) {
1229 return true;
1230 }
1231 for sep in ["; ", "&& ", "|| ", "| ", "\n"] {
1232 if trimmed.contains(&format!("{sep}{blocked} ")) {
1233 return true;
1234 }
1235 }
1236 }
1237
1238 if has_substitution_at_command_pos(trimmed) {
1239 return true;
1240 }
1241
1242 false
1243}
1244
1245fn has_substitution_at_command_pos(command: &str) -> bool {
1249 let segments = split_on_operators(command);
1250 for seg in segments {
1251 let trimmed = seg.trim();
1252 let cmd_start = skip_env_assignments(trimmed);
1253
1254 if cmd_start.starts_with("$(") {
1255 return true;
1256 }
1257
1258 let tokens = shell_tokenize(cmd_start);
1259 let first_token = tokens.first().map_or("", std::string::String::as_str);
1260 if first_token.starts_with('`') || first_token == "`" {
1261 return true;
1262 }
1263 }
1264 false
1265}
1266
1267fn extract_all_commands(command: &str) -> Vec<String> {
1270 split_on_operators(command)
1271 .into_iter()
1272 .map(|s| s.trim().to_string())
1273 .filter(|s| !s.is_empty())
1274 .collect()
1275}
1276
1277fn split_on_operators(command: &str) -> Vec<&str> {
1284 let mut segments = Vec::new();
1285 let mut start = 0;
1286 let bytes = command.as_bytes();
1287 let len = bytes.len();
1288 let mut i = 0;
1289 let mut in_single_quote = false;
1290 let mut in_double_quote = false;
1291 let mut paren_depth: u32 = 0;
1292
1293 while i < len {
1294 let ch = bytes[i];
1295
1296 if in_single_quote {
1297 if ch == b'\'' {
1298 in_single_quote = false;
1299 }
1300 i += 1;
1301 continue;
1302 }
1303
1304 if in_double_quote {
1305 match ch {
1306 b'\\' => i = (i + 2).min(len),
1308 b'"' => {
1309 in_double_quote = false;
1310 i += 1;
1311 }
1312 _ => i += 1,
1313 }
1314 continue;
1315 }
1316
1317 match ch {
1318 b'\\' => {
1319 i = (i + 2).min(len);
1322 }
1323 b'\'' => {
1324 in_single_quote = true;
1325 i += 1;
1326 }
1327 b'"' => {
1328 in_double_quote = true;
1329 i += 1;
1330 }
1331 b'(' => {
1332 paren_depth += 1;
1333 i += 1;
1334 }
1335 b')' => {
1336 paren_depth = paren_depth.saturating_sub(1);
1337 i += 1;
1338 }
1339 b'\n' | b'\r' | b';' if paren_depth == 0 => {
1340 segments.push(&command[start..i]);
1341 i += 1;
1342 start = i;
1343 }
1344 b'&' if paren_depth == 0 => {
1345 if i + 1 < len && bytes[i + 1] == b'&' {
1346 segments.push(&command[start..i]);
1348 i += 2;
1349 start = i;
1350 } else if (i > 0 && bytes[i - 1] == b'>') || (i + 1 < len && bytes[i + 1] == b'>') {
1351 i += 1;
1356 } else {
1357 segments.push(&command[start..i]);
1359 i += 1;
1360 start = i;
1361 }
1362 }
1363 b'|' if paren_depth == 0 => {
1364 if i + 1 < len && bytes[i + 1] == b'|' {
1365 segments.push(&command[start..i]);
1367 i += 2;
1368 start = i;
1369 } else if i > 0 && bytes[i - 1] == b'>' {
1370 i += 1;
1376 } else {
1377 segments.push(&command[start..i]);
1379 i += 1;
1380 start = i;
1381 }
1382 }
1383 _ => {
1384 i += 1;
1385 }
1386 }
1387 }
1388
1389 if start < len {
1390 segments.push(&command[start..]);
1391 }
1392
1393 segments
1394}
1395
1396fn extract_base_from_segment(segment: &str) -> String {
1398 let trimmed = segment.trim();
1399 if trimmed.is_empty() {
1400 return String::new();
1401 }
1402
1403 let cmd_part = skip_env_assignments(trimmed);
1404 if cmd_part.is_empty() {
1405 return String::new();
1406 }
1407
1408 let tokens = shell_tokenize(cmd_part);
1409 let first_token = tokens.first().map_or("", std::string::String::as_str);
1410
1411 first_token
1412 .rsplit('/')
1413 .next()
1414 .unwrap_or(first_token)
1415 .to_string()
1416}
1417
1418fn skip_env_assignments(segment: &str) -> &str {
1422 let mut rest = segment;
1423 loop {
1424 let rest_trimmed = rest.trim_start();
1425 if rest_trimmed.is_empty() {
1426 return rest_trimmed;
1427 }
1428 let end = quote_aware_token_end(rest_trimmed);
1429 if end == 0 {
1430 return rest_trimmed;
1431 }
1432 let raw_token = &rest_trimmed[..end];
1433 let unquoted: String = raw_token
1434 .chars()
1435 .filter(|c| *c != '"' && *c != '\'')
1436 .collect();
1437 if unquoted.contains('=')
1438 && !unquoted.starts_with('-')
1439 && !unquoted.starts_with('/')
1440 && !unquoted.starts_with('.')
1441 {
1442 rest = &rest_trimmed[end..];
1443 } else {
1444 return rest_trimmed;
1445 }
1446 }
1447}
1448
1449fn effective_allowlist() -> Vec<String> {
1450 if let Ok(ov) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE") {
1452 return ov
1453 .split(',')
1454 .map(|s| s.trim().to_string())
1455 .filter(|s| !s.is_empty())
1456 .collect();
1457 }
1458 let cfg = crate::core::config::Config::load();
1459 let mut list = cfg.shell_allowlist;
1460 if !list.is_empty() {
1464 for entry in cfg.shell_allowlist_extra {
1465 if !entry.is_empty() && !list.contains(&entry) {
1466 list.push(entry);
1467 }
1468 }
1469 }
1470 if let Ok(env_val) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST") {
1471 for entry in env_val
1472 .split(',')
1473 .map(|s| s.trim().to_string())
1474 .filter(|s| !s.is_empty())
1475 {
1476 if !list.contains(&entry) {
1477 list.push(entry);
1478 }
1479 }
1480 }
1481 list
1482}
1483
1484fn allowlist_block_message(base: &str) -> String {
1491 let cfg_path = crate::core::config::Config::path().map_or_else(
1492 || "~/.lean-ctx/config.toml".to_string(),
1493 |p| p.display().to_string(),
1494 );
1495
1496 let mut msg = format!(
1497 "[BLOCKED — DO NOT RETRY] '{base}' is not in the shell allowlist. \
1498 This is a permanent restriction, not a transient error.\n\
1499 Fix (additive, keeps the defaults): run lean-ctx allow {base}\n\
1500 Config in effect: {cfg_path}\n\
1501 Or disable the allowlist entirely: set shell_allowlist = []\n\
1502 Or turn off all shell gating (you own the risk): set shell_security = \"off\" \
1503 (or env LEAN_CTX_SHELL_SECURITY=off) — compression still applies.\n\
1504 Do NOT retry this command — it will fail again with the same error.\n For multi-line scripts or complex pipelines: use ctx_execute(language=\"shell\") instead — \n it is the sanctioned path for script execution without allowlist restrictions."
1505 );
1506
1507 if crate::core::config::cloud_infra_commands().contains(&base) {
1508 msg.push_str(
1509 "\nNote: cloud/infra CLIs (terraform, kubectl, aws, …) are deliberately \
1510 excluded from the defaults — they mutate remote infrastructure with \
1511 ambient credentials. Opting in is a deliberate user decision.",
1512 );
1513 }
1514
1515 if let Some(parse_err) = crate::core::config::last_config_parse_error() {
1516 msg.push_str(&format!(
1517 "\n\n⚠ Your config.toml currently FAILS to parse, so lean-ctx is running on the \
1518 built-in defaults — this is almost certainly why editing the allowlist had no \
1519 effect. Fix the TOML error below, then retry:\n {parse_err}\n File: {cfg_path}"
1520 ));
1521 } else if let Some(missing) = crate::core::config::Config::missing_config_path() {
1522 msg.push_str(&format!(
1526 "\n\n⚠ No config file exists at {} — lean-ctx is running on built-in defaults. \
1527 If you added the command to a config.toml in a DIFFERENT location (XDG \
1528 ~/.config/lean-ctx vs legacy ~/.lean-ctx, or your MCP client launches lean-ctx \
1529 in a sandbox/container with a different HOME), the runtime never reads it. \
1530 `lean-ctx doctor` prints the path actually in effect; pin it with \
1531 LEAN_CTX_CONFIG_DIR.",
1532 missing.display()
1533 ));
1534 }
1535
1536 if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
1540 msg.push_str("\n\n⚠ ");
1541 msg.push_str(¬ice);
1542 }
1543
1544 msg
1545}
1546
1547pub fn extract_all_commands_pub(command: &str) -> Vec<String> {
1549 extract_all_commands(command)
1550}
1551
1552#[must_use]
1557pub fn effective_allowlist_pub() -> Vec<String> {
1558 effective_allowlist()
1559}
1560
1561pub fn extract_base_command(command: &str) -> String {
1563 let first_seg = split_on_operators(command)
1564 .into_iter()
1565 .next()
1566 .unwrap_or(command);
1567 extract_base_from_segment(first_seg)
1568}