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 if let Some(inner) = balanced_brace_inner(s) {
855 for inner_seg in extract_all_commands(inner) {
856 resolve_segment_leaves(&inner_seg, depth + 1, out)?;
857 }
858 return Ok(());
859 }
860 for inner in assignment_substitution_leaves(s) {
870 for inner_seg in extract_all_commands(inner) {
871 resolve_segment_leaves(&inner_seg, depth + 1, out)?;
872 }
873 }
874 out.push(s.to_string());
886 Ok(())
887}
888
889fn balanced_paren_at(s: &str, open: usize) -> Option<(&str, usize)> {
895 let bytes = s.as_bytes();
896 let len = bytes.len();
897 let mut depth: i32 = 0;
898 let mut in_single_quote = false;
899 let mut in_double_quote = false;
900 let mut i = open;
901 while i < len {
902 let ch = bytes[i];
903 if in_single_quote {
904 if ch == b'\'' {
905 in_single_quote = false;
906 }
907 i += 1;
908 continue;
909 }
910 if in_double_quote {
911 match ch {
912 b'\\' => i = (i + 2).min(len),
913 b'"' => {
914 in_double_quote = false;
915 i += 1;
916 }
917 _ => i += 1,
918 }
919 continue;
920 }
921 match ch {
922 b'\\' => i = (i + 2).min(len),
923 b'\'' => {
924 in_single_quote = true;
925 i += 1;
926 }
927 b'"' => {
928 in_double_quote = true;
929 i += 1;
930 }
931 b'(' => {
932 depth += 1;
933 i += 1;
934 }
935 b')' => {
936 depth -= 1;
937 i += 1;
938 if depth == 0 {
939 return Some((&s[open + 1..i - 1], i));
940 }
941 }
942 _ => i += 1,
943 }
944 }
945 None
946}
947
948fn leading_assignment_prefix(s: &str) -> &str {
953 let rest = skip_env_assignments(s);
954 let offset = (rest.as_ptr() as usize).saturating_sub(s.as_ptr() as usize);
955 &s[..offset.min(s.len())]
956}
957
958fn assignment_substitution_leaves(s: &str) -> Vec<&str> {
966 let prefix = leading_assignment_prefix(s);
967 if prefix.is_empty() {
968 return Vec::new();
969 }
970 let mut found = Vec::new();
971 let bytes = prefix.as_bytes();
972 let len = bytes.len();
973 let mut in_single_quote = false;
974 let mut in_double_quote = false;
975 let mut i = 0;
976 while i < len {
977 let ch = bytes[i];
978 if in_single_quote {
979 if ch == b'\'' {
980 in_single_quote = false;
981 }
982 i += 1;
983 continue;
984 }
985 if in_double_quote {
986 match ch {
987 b'\\' => {
988 i = (i + 2).min(len);
989 continue;
990 }
991 b'"' => in_double_quote = false,
992 _ => {}
993 }
994 i += 1;
995 continue;
996 }
997 match ch {
998 b'\\' => {
999 i = (i + 2).min(len);
1000 continue;
1001 }
1002 b'\'' => in_single_quote = true,
1003 b'"' => in_double_quote = true,
1004 b'$' if i + 1 < len && bytes[i + 1] == b'(' => {
1005 if let Some((inner, end)) = balanced_paren_at(prefix, i + 1) {
1006 found.push(inner);
1007 i = end;
1008 continue;
1009 }
1010 }
1011 _ => {}
1012 }
1013 i += 1;
1014 }
1015 found
1016}
1017
1018fn remainder_after_first_token(s: &str) -> &str {
1020 let trimmed = s.trim_start();
1021 let end = quote_aware_token_end(trimmed);
1022 &trimmed[end..]
1023}
1024
1025fn balanced_paren_inner(segment: &str) -> Option<&str> {
1029 let trimmed = segment.trim();
1030 let bytes = trimmed.as_bytes();
1031 if bytes.first() != Some(&b'(') {
1032 return None;
1033 }
1034 let len = bytes.len();
1035 let mut depth: i32 = 0;
1036 let mut in_single_quote = false;
1037 let mut in_double_quote = false;
1038 let mut i = 0;
1039 while i < len {
1040 let ch = bytes[i];
1041 if in_single_quote {
1042 if ch == b'\'' {
1043 in_single_quote = false;
1044 }
1045 i += 1;
1046 continue;
1047 }
1048 if in_double_quote {
1049 match ch {
1050 b'\\' => i += 1, b'"' => in_double_quote = false,
1052 _ => {}
1053 }
1054 i += 1;
1055 continue;
1056 }
1057 match ch {
1058 b'\\' => i += 1,
1061 b'\'' => in_single_quote = true,
1062 b'"' => in_double_quote = true,
1063 b'(' => depth += 1,
1064 b')' => {
1065 depth -= 1;
1066 if depth == 0 {
1067 return if i == len - 1 {
1068 Some(trimmed[1..i].trim())
1069 } else {
1070 None
1071 };
1072 }
1073 }
1074 _ => {}
1075 }
1076 i += 1;
1077 }
1078 None
1079}
1080
1081fn balanced_brace_inner(segment: &str) -> Option<&str> {
1092 let trimmed = segment.trim();
1093 let bytes = trimmed.as_bytes();
1094 if bytes.first() != Some(&b'{') {
1095 return None;
1096 }
1097 match bytes.get(1) {
1100 Some(&(b' ' | b'\t' | b'\n' | b'\r')) => {}
1101 _ => return None,
1102 }
1103 let len = bytes.len();
1104 let mut depth: i32 = 0;
1105 let mut in_single_quote = false;
1106 let mut in_double_quote = false;
1107 let mut i = 0;
1108 while i < len {
1109 let ch = bytes[i];
1110 if in_single_quote {
1111 if ch == b'\'' {
1112 in_single_quote = false;
1113 }
1114 i += 1;
1115 continue;
1116 }
1117 if in_double_quote {
1118 match ch {
1119 b'\\' => i += 1, b'"' => in_double_quote = false,
1121 _ => {}
1122 }
1123 i += 1;
1124 continue;
1125 }
1126 match ch {
1127 b'\\' => i += 1, b'\'' => in_single_quote = true,
1129 b'"' => in_double_quote = true,
1130 b'{' => depth += 1,
1131 b'}' => {
1132 depth -= 1;
1133 if depth == 0 {
1134 return if i == len - 1 {
1135 Some(trimmed[1..i].trim())
1136 } else {
1137 None
1138 };
1139 }
1140 }
1141 _ => {}
1142 }
1143 i += 1;
1144 }
1145 None
1146}
1147
1148fn has_case_construct(command: &str) -> bool {
1152 for seg in split_on_operators(command) {
1153 if shell_tokenize(seg.trim())
1154 .iter()
1155 .any(|t| t == "case" || t == "esac")
1156 {
1157 return true;
1158 }
1159 }
1160 contains_double_semicolon(command)
1161}
1162
1163fn contains_double_semicolon(command: &str) -> bool {
1165 let bytes = command.as_bytes();
1166 let len = bytes.len();
1167 let mut in_single_quote = false;
1168 let mut in_double_quote = false;
1169 let mut i = 0;
1170 while i < len {
1171 let ch = bytes[i];
1172 if in_single_quote {
1173 if ch == b'\'' {
1174 in_single_quote = false;
1175 }
1176 i += 1;
1177 continue;
1178 }
1179 if in_double_quote {
1180 if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
1181 in_double_quote = false;
1182 }
1183 i += 1;
1184 continue;
1185 }
1186 match ch {
1187 b'\'' => in_single_quote = true,
1188 b'"' => in_double_quote = true,
1189 b';' if i + 1 < len && bytes[i + 1] == b';' => return true,
1190 _ => {}
1191 }
1192 i += 1;
1193 }
1194 false
1195}
1196
1197fn is_project_root_binary(token: &str) -> bool {
1208 if !token.contains('/') {
1209 return false;
1210 }
1211 let path = std::path::Path::new(token);
1212 let resolved = if path.is_relative() {
1213 match std::env::current_dir() {
1214 Ok(cwd) => cwd.join(path),
1215 Err(_) => return false,
1216 }
1217 } else {
1218 path.to_path_buf()
1219 };
1220 let Ok(canonical) = resolved.canonicalize() else {
1221 return false;
1222 };
1223 if !canonical.is_file() {
1224 return false;
1225 }
1226 let Some(root) = crate::server::derive_project_root_from_cwd() else {
1227 return false;
1228 };
1229 let root_path = std::path::Path::new(&root);
1230 let canonical_root = root_path
1231 .canonicalize()
1232 .unwrap_or_else(|_| root_path.to_path_buf());
1233 canonical.starts_with(&canonical_root)
1234}
1235
1236fn check_all_segments(command: &str, allowlist: &[String]) -> Result<(), ShellError> {
1237 if allowlist.is_empty() {
1238 return Ok(());
1239 }
1240
1241 if has_dangerous_patterns(command) {
1242 return Err(format!(
1243 "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
1244 which is blocked in restricted mode. \
1245 This is a permanent security restriction, not a transient error.\n\
1246 Command: {command}"
1247 )
1248 .into());
1249 }
1250
1251 let segments = expand_to_leaf_segments(command)?;
1252 if segments.is_empty() {
1253 return Err("[BLOCKED — DO NOT RETRY] Empty command".into());
1254 }
1255
1256 let total = segments.len();
1257 for (idx, seg) in segments.iter().enumerate() {
1258 check_inline_env_block(seg)?;
1259 let base = extract_base_from_segment(seg);
1260 if base.is_empty() {
1261 continue;
1262 }
1263 if UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
1264 return Err(format!(
1265 "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
1266 regardless of allowlist membership. \
1267 This is a permanent security restriction.\n\
1268 Command: {command}"
1269 )
1270 .into());
1271 }
1272 check_interpreter_abuse(seg, allowlist)?;
1273 check_dangerous_flags(seg)?;
1274 if !allowlist.iter().any(|a| a == &base) {
1275 let first_token = shell_tokenize(skip_env_assignments(seg.trim()))
1279 .into_iter()
1280 .next()
1281 .unwrap_or_default();
1282 if is_project_root_binary(&first_token) {
1283 tracing::info!(
1284 "[shell_allowlist] auto-allowing project-root binary: {first_token}"
1285 );
1286 continue;
1287 }
1288
1289 let mut msg = allowlist_block_message(&base);
1293 if total > 1 {
1294 msg.push_str(&format!(
1295 "\n\n[pipeline: segment {}/{total} blocked — \
1296 the entire command was rejected before execution, \
1297 no part of the pipeline ran]",
1298 idx + 1,
1299 ));
1300 }
1301 return Err(msg.into());
1302 }
1303 }
1304 Ok(())
1305}
1306
1307fn has_dangerous_patterns(command: &str) -> bool {
1315 let trimmed = command.trim();
1316
1317 for blocked in UNCONDITIONAL_BLOCKED {
1318 let with_space = format!("{blocked} ");
1319 if trimmed.starts_with(&with_space) {
1320 return true;
1321 }
1322 for sep in ["; ", "&& ", "|| ", "| ", "\n"] {
1323 if trimmed.contains(&format!("{sep}{blocked} ")) {
1324 return true;
1325 }
1326 }
1327 }
1328
1329 if has_substitution_at_command_pos(trimmed) {
1330 return true;
1331 }
1332
1333 false
1334}
1335
1336fn has_substitution_at_command_pos(command: &str) -> bool {
1340 let segments = split_on_operators(command);
1341 for seg in segments {
1342 let trimmed = seg.trim();
1343 let cmd_start = skip_env_assignments(trimmed);
1344
1345 if cmd_start.starts_with("$(") {
1346 return true;
1347 }
1348
1349 let tokens = shell_tokenize(cmd_start);
1350 let first_token = tokens.first().map_or("", std::string::String::as_str);
1351 if first_token.starts_with('`') || first_token == "`" {
1352 return true;
1353 }
1354 }
1355 false
1356}
1357
1358fn extract_all_commands(command: &str) -> Vec<String> {
1361 split_on_operators(command)
1362 .into_iter()
1363 .map(|s| s.trim().to_string())
1364 .filter(|s| !s.is_empty())
1365 .collect()
1366}
1367
1368fn split_on_operators(command: &str) -> Vec<&str> {
1375 let mut segments = Vec::new();
1376 let mut start = 0;
1377 let bytes = command.as_bytes();
1378 let len = bytes.len();
1379 let mut i = 0;
1380 let mut in_single_quote = false;
1381 let mut in_double_quote = false;
1382 let mut paren_depth: u32 = 0;
1383 let mut brace_depth: u32 = 0;
1388
1389 while i < len {
1390 let ch = bytes[i];
1391
1392 if in_single_quote {
1393 if ch == b'\'' {
1394 in_single_quote = false;
1395 }
1396 i += 1;
1397 continue;
1398 }
1399
1400 if in_double_quote {
1401 match ch {
1402 b'\\' => i = (i + 2).min(len),
1404 b'"' => {
1405 in_double_quote = false;
1406 i += 1;
1407 }
1408 _ => i += 1,
1409 }
1410 continue;
1411 }
1412
1413 match ch {
1414 b'\\' => {
1415 i = (i + 2).min(len);
1418 }
1419 b'\'' => {
1420 in_single_quote = true;
1421 i += 1;
1422 }
1423 b'"' => {
1424 in_double_quote = true;
1425 i += 1;
1426 }
1427 b'(' => {
1428 paren_depth += 1;
1429 i += 1;
1430 }
1431 b')' => {
1432 paren_depth = paren_depth.saturating_sub(1);
1433 i += 1;
1434 }
1435 b'{' => {
1436 brace_depth += 1;
1437 i += 1;
1438 }
1439 b'}' => {
1440 brace_depth = brace_depth.saturating_sub(1);
1441 i += 1;
1442 }
1443 b'\n' | b'\r' | b';' if paren_depth == 0 && brace_depth == 0 => {
1444 segments.push(&command[start..i]);
1445 i += 1;
1446 start = i;
1447 }
1448 b'&' if paren_depth == 0 && brace_depth == 0 => {
1449 if i + 1 < len && bytes[i + 1] == b'&' {
1450 segments.push(&command[start..i]);
1452 i += 2;
1453 start = i;
1454 } else if (i > 0 && bytes[i - 1] == b'>') || (i + 1 < len && bytes[i + 1] == b'>') {
1455 i += 1;
1460 } else {
1461 segments.push(&command[start..i]);
1463 i += 1;
1464 start = i;
1465 }
1466 }
1467 b'|' if paren_depth == 0 && brace_depth == 0 => {
1468 if i + 1 < len && bytes[i + 1] == b'|' {
1469 segments.push(&command[start..i]);
1471 i += 2;
1472 start = i;
1473 } else if i > 0 && bytes[i - 1] == b'>' {
1474 i += 1;
1480 } else {
1481 segments.push(&command[start..i]);
1483 i += 1;
1484 start = i;
1485 }
1486 }
1487 _ => {
1488 i += 1;
1489 }
1490 }
1491 }
1492
1493 if start < len {
1494 segments.push(&command[start..]);
1495 }
1496
1497 segments
1498}
1499
1500fn extract_base_from_segment(segment: &str) -> String {
1502 let trimmed = segment.trim();
1503 if trimmed.is_empty() {
1504 return String::new();
1505 }
1506
1507 let cmd_part = skip_env_assignments(trimmed);
1508 if cmd_part.is_empty() {
1509 return String::new();
1510 }
1511
1512 let tokens = shell_tokenize(cmd_part);
1513 let mut token_iter = tokens.iter();
1518 let first_token = match token_iter.next().map(String::as_str) {
1519 Some("{") => token_iter.next().map_or("", String::as_str),
1520 other => other.unwrap_or(""),
1521 };
1522
1523 first_token
1524 .rsplit('/')
1525 .next()
1526 .unwrap_or(first_token)
1527 .to_string()
1528}
1529
1530fn skip_env_assignments(segment: &str) -> &str {
1534 let mut rest = segment;
1535 loop {
1536 let rest_trimmed = rest.trim_start();
1537 if rest_trimmed.is_empty() {
1538 return rest_trimmed;
1539 }
1540 let end = quote_aware_token_end(rest_trimmed);
1541 if end == 0 {
1542 return rest_trimmed;
1543 }
1544 let raw_token = &rest_trimmed[..end];
1545 let unquoted: String = raw_token
1546 .chars()
1547 .filter(|c| *c != '"' && *c != '\'')
1548 .collect();
1549 if unquoted.contains('=')
1550 && !unquoted.starts_with('-')
1551 && !unquoted.starts_with('/')
1552 && !unquoted.starts_with('.')
1553 {
1554 rest = &rest_trimmed[end..];
1555 } else {
1556 return rest_trimmed;
1557 }
1558 }
1559}
1560
1561fn effective_allowlist() -> Vec<String> {
1562 if let Ok(ov) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE") {
1564 return ov
1565 .split(',')
1566 .map(|s| s.trim().to_string())
1567 .filter(|s| !s.is_empty())
1568 .collect();
1569 }
1570 let cfg = crate::core::config::Config::load();
1571 let mut list = cfg.shell_allowlist;
1572 if !list.is_empty() {
1576 for entry in cfg.shell_allowlist_extra {
1577 if !entry.is_empty() && !list.contains(&entry) {
1578 list.push(entry);
1579 }
1580 }
1581 }
1582 if let Ok(env_val) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST") {
1583 for entry in env_val
1584 .split(',')
1585 .map(|s| s.trim().to_string())
1586 .filter(|s| !s.is_empty())
1587 {
1588 if !list.contains(&entry) {
1589 list.push(entry);
1590 }
1591 }
1592 }
1593 list
1594}
1595
1596fn allowlist_block_message(base: &str) -> String {
1603 let cfg_path = crate::core::config::Config::path().map_or_else(
1604 || "~/.lean-ctx/config.toml".to_string(),
1605 |p| p.display().to_string(),
1606 );
1607
1608 let mut msg = format!(
1609 "[BLOCKED — DO NOT RETRY] '{base}' is not in the shell allowlist. \
1610 This is a permanent restriction, not a transient error.\n\
1611 Fix (additive, keeps the defaults): run lean-ctx allow {base}\n\
1612 Config in effect: {cfg_path}\n\
1613 Or disable the allowlist entirely: set shell_allowlist = []\n\
1614 Or turn off all shell gating (you own the risk): set shell_security = \"off\" \
1615 (or env LEAN_CTX_SHELL_SECURITY=off) — compression still applies.\n\
1616 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."
1617 );
1618
1619 if crate::core::config::cloud_infra_commands().contains(&base) {
1620 msg.push_str(
1621 "\nNote: cloud/infra CLIs (terraform, kubectl, aws, …) are deliberately \
1622 excluded from the defaults — they mutate remote infrastructure with \
1623 ambient credentials. Opting in is a deliberate user decision.",
1624 );
1625 }
1626
1627 if let Some(parse_err) = crate::core::config::last_config_parse_error() {
1628 msg.push_str(&format!(
1629 "\n\n⚠ Your config.toml currently FAILS to parse, so lean-ctx is running on the \
1630 built-in defaults — this is almost certainly why editing the allowlist had no \
1631 effect. Fix the TOML error below, then retry:\n {parse_err}\n File: {cfg_path}"
1632 ));
1633 } else if let Some(missing) = crate::core::config::Config::missing_config_path() {
1634 msg.push_str(&format!(
1638 "\n\n⚠ No config file exists at {} — lean-ctx is running on built-in defaults. \
1639 If you added the command to a config.toml in a DIFFERENT location (XDG \
1640 ~/.config/lean-ctx vs legacy ~/.lean-ctx, or your MCP client launches lean-ctx \
1641 in a sandbox/container with a different HOME), the runtime never reads it. \
1642 `lean-ctx doctor` prints the path actually in effect; pin it with \
1643 LEAN_CTX_CONFIG_DIR.",
1644 missing.display()
1645 ));
1646 }
1647
1648 if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
1652 msg.push_str("\n\n⚠ ");
1653 msg.push_str(¬ice);
1654 }
1655
1656 msg
1657}
1658
1659pub fn extract_all_commands_pub(command: &str) -> Vec<String> {
1661 extract_all_commands(command)
1662}
1663
1664#[must_use]
1669pub fn effective_allowlist_pub() -> Vec<String> {
1670 effective_allowlist()
1671}
1672
1673pub fn extract_base_command(command: &str) -> String {
1675 let first_seg = split_on_operators(command)
1676 .into_iter()
1677 .next()
1678 .unwrap_or(command);
1679 extract_base_from_segment(first_seg)
1680}