vtcode_safety/command_safety/
shell_parser.rs1#![expect(
2 clippy::indexing_slicing,
3 clippy::string_slice,
4 reason = "Shell parsing tracks byte offsets at character boundaries while validating operator pairs."
5)]
6
7use std::sync::Mutex;
23use std::sync::OnceLock;
24
25use anyhow::Result;
26
27static BASH_PARSER: OnceLock<Result<Mutex<tree_sitter::Parser>, String>> = OnceLock::new();
29
30pub fn contains_dynamic_shell_syntax(command: &str) -> bool {
40 enum ShellQuote {
41 Single,
42 Double,
43 }
44
45 let mut quote: Option<ShellQuote> = None;
46 let mut characters = command.chars();
47
48 while let Some(character) = characters.next() {
49 match quote {
50 Some(ShellQuote::Single) => {
51 if character == '\'' {
52 quote = None;
53 }
54 }
55 Some(ShellQuote::Double) => match character {
56 '"' => quote = None,
57 '$' | '`' => return true,
58 '\\'
59 if characters.next().is_none() => {
65 return true;
66 }
67 _ => {}
68 },
69 None => match character {
70 '\'' => quote = Some(ShellQuote::Single),
71 '"' => quote = Some(ShellQuote::Double),
72 '\\' | '$' | '`' | '{' | '}' | '*' | '?' | '[' | ']' => return true,
73 _ => {}
74 },
75 }
76 }
77
78 quote.is_some()
79}
80
81pub fn contains_dynamic_find_syntax(script: &str) -> bool {
84 if let Ok(commands) = parse_shell_commands_tree_sitter(script)
85 && commands.iter().any(|command| {
86 command
87 .first()
88 .map(|program| base_command_name(program) == "find")
89 .unwrap_or(false)
90 && command.iter().any(|word| contains_dynamic_shell_syntax(word))
91 })
92 {
93 return true;
94 }
95
96 let has_find_word = script.split_whitespace().any(|word| {
100 let command = word.trim_matches(|character: char| !character.is_ascii_alphanumeric() && character != '/');
101 base_command_name(command) == "find"
102 });
103 has_find_word && contains_dynamic_shell_syntax(script)
104}
105
106fn get_bash_parser() -> Result<&'static Mutex<tree_sitter::Parser>, String> {
108 BASH_PARSER
109 .get_or_init(|| {
110 let mut parser = tree_sitter::Parser::new();
111 let lang: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
112 parser
113 .set_language(&lang)
114 .map_err(|e| format!("Failed to load bash grammar: {e}"))?;
115 Ok(Mutex::new(parser))
116 })
117 .as_ref()
118 .map_err(Clone::clone)
119}
120
121pub fn prewarm_bash_parser() -> Result<(), String> {
123 let _ = get_bash_parser()?;
124 Ok(())
125}
126
127pub fn parse_shell_commands(script: &str) -> Result<Vec<Vec<String>>, String> {
138 match parse_with_tree_sitter(script, false) {
140 Ok(commands) if !commands.is_empty() => return Ok(commands),
141 Ok(_) => {} Err(e) => {
143 tracing::debug!("Tree-sitter bash parsing failed: {}, falling back to basic tokenization", e);
144 }
145 }
146
147 parse_with_basic_tokenization(script)
149}
150
151pub fn parse_shell_commands_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
155 parse_with_tree_sitter(script, true)
156}
157
158pub fn has_only_output_redirections(script: &str) -> bool {
162 if contains_dynamic_shell_syntax(script) {
163 return false;
164 }
165 if contains_background_operator(script) {
166 return false;
167 }
168
169 let Ok(parser) = get_bash_parser() else {
170 return false;
171 };
172 let Ok(mut parser) = parser.lock() else {
173 return false;
174 };
175 let Some(tree) = parser.parse(script, None) else {
176 return false;
177 };
178 if tree.root_node().has_error() {
179 return false;
180 }
181
182 let mut saw_redirection = false;
183 if !collect_output_redirections(tree.root_node(), script, &mut saw_redirection) {
184 return false;
185 }
186 saw_redirection
187}
188
189pub(crate) fn validate_redirection_paths(script: &str) -> Result<()> {
192 use anyhow::{Context, anyhow, ensure};
193 if !script.contains(['<', '>']) {
194 return Ok(());
195 }
196 let parser = get_bash_parser().map_err(anyhow::Error::msg)?;
197 let mut parser = parser.lock().map_err(|error| anyhow!("shell parser lock poisoned: {error}"))?;
198 let tree = parser.parse(script, None).context("failed to parse shell redirections")?;
199 ensure!(!tree.root_node().has_error(), "cannot validate malformed shell redirections");
200 let mut pending = vec![tree.root_node()];
201 while let Some(node) = pending.pop() {
202 if node.kind() == "file_redirect" {
203 let text = node.utf8_text(script.as_bytes()).context("invalid shell redirection text")?;
204 let operator = text.trim_start_matches(|character: char| character.is_ascii_digit());
205 let descriptor_route = operator.starts_with(">&") || operator.starts_with("<&");
206 let mut cursor = node.walk();
207 let destinations = node.children_by_field_name("destination", &mut cursor);
208 for destination in destinations {
209 let raw = destination
210 .utf8_text(script.as_bytes())
211 .context("invalid redirection destination")?;
212 ensure!(!contains_dynamic_shell_syntax(raw), "dynamic redirection destination is not allowed");
213 let words = shell_words::split(raw).context("invalid quoted redirection destination")?;
214 ensure!(words.len() == 1, "redirection destination must be one literal path");
215 let path = words.first().context("missing redirection destination")?;
216 if descriptor_route && (path == "-" || path.chars().all(|character| character.is_ascii_digit())) {
217 continue;
218 }
219 ensure!(!path.starts_with('~'), "home-directory redirection destinations are not allowed");
224 if path == "/dev/null" {
226 continue;
227 }
228 vtcode_commons::paths::validate_path_safety(path)
229 .with_context(|| format!("unsafe shell redirection destination: {path}"))?;
230 }
231 }
232 let mut cursor = node.walk();
233 pending.extend(node.named_children(&mut cursor));
234 }
235 Ok(())
236}
237
238fn contains_background_operator(script: &str) -> bool {
239 let chars = script.chars().collect::<Vec<_>>();
240 let mut index = 0;
241 let mut in_single_quote = false;
242 let mut in_double_quote = false;
243
244 while index < chars.len() {
245 let character = chars[index];
246 if character == '\'' && !in_double_quote {
247 in_single_quote = !in_single_quote;
248 index += 1;
249 continue;
250 }
251 if character == '"' && !in_single_quote {
252 in_double_quote = !in_double_quote;
253 index += 1;
254 continue;
255 }
256 if in_single_quote || in_double_quote {
257 index += 1;
258 continue;
259 }
260
261 if character == '&' {
262 let previous = index.checked_sub(1).and_then(|position| chars.get(position));
263 let next = chars.get(index + 1);
264 if next == Some(&'&') {
265 index += 2;
266 continue;
267 }
268 let part_of_allowed_operator = next == Some(&'&')
269 || next == Some(&'>')
270 || previous == Some(&'>')
271 || previous == Some(&'|')
272 || previous == Some(&'<');
273 if !part_of_allowed_operator {
274 return true;
275 }
276 }
277 index += 1;
278 }
279
280 false
281}
282
283fn collect_output_redirections(node: tree_sitter::Node, source: &str, saw_redirection: &mut bool) -> bool {
284 match node.kind() {
285 "file_redirect" => {
286 *saw_redirection = true;
287 let Ok(text) = node.utf8_text(source.as_bytes()) else {
288 return false;
289 };
290 if !is_output_redirection(text) {
291 return false;
292 }
293 }
294 "heredoc_redirect" | "herestring_redirect" => return false,
295 _ => {}
296 }
297
298 let mut cursor = node.walk();
299 node.children(&mut cursor)
300 .all(|child| collect_output_redirections(child, source, saw_redirection))
301}
302
303fn is_output_redirection(text: &str) -> bool {
304 let redirect = text.trim_start_matches(|character: char| character.is_ascii_digit());
305 if redirect.starts_with("&>") {
306 return !redirect.starts_with("&>-");
307 }
308 if let Some(destination) = redirect.strip_prefix(">&") {
309 return destination.trim().chars().all(|character| character.is_ascii_digit());
310 }
311
312 redirect.starts_with('>') && !redirect.starts_with(">&-")
313}
314
315fn parse_with_tree_sitter(script: &str, reject_syntax_errors: bool) -> Result<Vec<Vec<String>>, String> {
317 let parser_guard = get_bash_parser()?;
318 let mut parser = parser_guard.lock().map_err(|e| format!("Failed to lock parser: {e}"))?;
319
320 let tree = parser.parse(script, None).ok_or_else(|| "Failed to parse script".to_string())?;
321
322 let mut commands = Vec::new();
323 let root = tree.root_node();
324 if reject_syntax_errors && root.has_error() {
325 return Err("Shell script contains syntax errors".to_string());
326 }
327
328 collect_commands_from_node(root, script, &mut commands);
332
333 Ok(commands)
334}
335
336fn collect_commands_from_node(node: tree_sitter::Node, source: &str, commands: &mut Vec<Vec<String>>) {
337 match node.kind() {
338 "command" | "simple_command" => {
339 if let Some(cmd) = extract_command_from_node(node, source)
340 && !cmd.is_empty()
341 {
342 commands.push(cmd);
343 }
344 }
345 _ => {
346 let mut cursor = node.walk();
347 for child in node.children(&mut cursor) {
348 collect_commands_from_node(child, source, commands);
349 }
350 }
351 }
352}
353
354fn extract_command_from_node(node: tree_sitter::Node, source: &str) -> Option<Vec<String>> {
356 let mut command = Vec::new();
357 let mut cursor = node.walk();
358
359 if node.kind() == "pipeline" {
361 for child in node.children(&mut cursor) {
362 if child.kind() == "command" || child.kind() == "simple_command" {
363 return extract_command_from_node(child, source);
364 }
365 }
366 }
367
368 for child in node.children(&mut cursor) {
370 if child.kind() == "command_name" {
371 if let Ok(arg) = child.utf8_text(source.as_bytes()) {
372 let trimmed = arg.trim();
373 if !trimmed.is_empty() {
374 command.push(trimmed.to_string());
375 }
376 }
377 continue;
378 }
379
380 if matches!(child.kind(), "variable_assignment" | "concatenation") {
388 if let Ok(arg) = child.utf8_text(source.as_bytes()) {
389 let trimmed = arg.trim();
390 if !trimmed.is_empty() {
391 command.push(trimmed.to_string());
392 }
393 }
394 continue;
395 }
396
397 if matches!(
398 child.kind(),
399 "word" | "string" | "raw_string" | "ansi_c_string" | "simple_expansion" | "variable_expansion"
400 ) {
401 let text = child.utf8_text(source.as_bytes());
402 if let Ok(arg) = text {
403 let trimmed = arg.trim();
404 if !trimmed.is_empty() {
405 command.push(trimmed.to_string());
406 }
407 }
408 }
409 }
410
411 if command.is_empty() { None } else { Some(command) }
412}
413
414fn parse_with_basic_tokenization(script: &str) -> Result<Vec<Vec<String>>, String> {
416 let mut commands = Vec::new();
417 let mut current_command = String::new();
418 let mut in_quotes = false;
419 let mut quote_char = ' ';
420 let mut escaped = false;
421
422 for ch in script.chars() {
423 if escaped {
424 current_command.push(ch);
425 escaped = false;
426 continue;
427 }
428
429 match ch {
430 '\\' => {
431 escaped = true;
432 }
433 '\'' | '"' if !in_quotes => {
434 in_quotes = true;
435 quote_char = ch;
436 }
437 c if c == quote_char && in_quotes => {
438 in_quotes = false;
439 }
440 '&' | '|' | ';' if !in_quotes => {
441 if !current_command.trim().is_empty()
442 && let Ok(cmd) = tokenize_command(¤t_command)
443 {
444 commands.push(cmd);
445 }
446 current_command.clear();
447 }
448 _ => current_command.push(ch),
449 }
450 }
451
452 if !current_command.trim().is_empty()
453 && let Ok(cmd) = tokenize_command(¤t_command)
454 {
455 commands.push(cmd);
456 }
457
458 Ok(commands)
459}
460
461fn tokenize_command(cmd: &str) -> Result<Vec<String>, String> {
464 shell_words::split(cmd).map_err(|err| format!("failed to tokenize command: {err}"))
465}
466
467pub fn parse_bash_lc_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
475 if command.is_empty() {
476 return None;
477 }
478
479 let cmd_name = command[0].as_str();
480 let base_cmd = std::path::Path::new(cmd_name)
481 .file_name()
482 .and_then(|osstr| osstr.to_str())
483 .unwrap_or("");
484
485 if base_cmd != "bash" && base_cmd != "zsh" && base_cmd != "sh" {
486 return None;
487 }
488
489 for window in command.windows(2) {
491 if matches!(window[0].as_str(), "-lc" | "-c" | "-il" | "-ic") {
492 let script = &window[1];
493 return parse_shell_commands(script).ok();
494 }
495 }
496
497 None
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 #[test]
505 fn tokenize_simple_command() {
506 let cmd = "git status";
507 let tokens = tokenize_command(cmd).unwrap();
508 assert_eq!(tokens, vec!["git", "status"]);
509 }
510
511 #[test]
512 fn tokenize_quoted_arguments() {
513 let cmd = r#"echo "hello world""#;
514 let tokens = tokenize_command(cmd).unwrap();
515 assert_eq!(tokens, vec!["echo", "hello world"]);
516 }
517
518 #[test]
519 fn parse_single_command() {
520 let script = "git status";
521 let commands = parse_shell_commands(script).unwrap();
522 assert_eq!(commands.len(), 1);
523 assert_eq!(commands[0][0], "git");
524 }
525
526 #[test]
527 fn parse_chained_commands_with_and() {
528 let script = "git status && cargo check";
529 let commands = parse_shell_commands(script).unwrap();
530 assert_eq!(commands.len(), 2);
531 assert_eq!(commands[0][0], "git");
532 assert_eq!(commands[1][0], "cargo");
533 }
534
535 #[test]
536 fn parse_loop_body_commands() {
537 let script = "cd crates/codegen/vtcode-core/src/tools/registry && for f in *.rs; do echo \"=== $f ===\"; grep -nE '^(pub )?(struct|enum|fn)' \"$f\" | head -50; done";
538 let commands = parse_shell_commands(script).unwrap();
539
540 assert_eq!(commands[0], vec!["cd", "crates/codegen/vtcode-core/src/tools/registry"]);
541 assert!(
542 commands
543 .iter()
544 .any(|command| command.first().is_some_and(|name| name == "echo"))
545 );
546 assert!(
547 commands
548 .iter()
549 .any(|command| command.first().is_some_and(|name| name == "grep"))
550 );
551 assert!(
552 commands
553 .iter()
554 .any(|command| command.first().is_some_and(|name| name == "head"))
555 );
556 }
557
558 #[test]
559 fn parse_chained_commands_with_semicolon() {
560 let script = "git status; cargo check";
561 let commands = parse_shell_commands(script).unwrap();
562 assert_eq!(commands.len(), 2);
563 }
564
565 #[test]
566 fn parse_bash_lc_git_status() {
567 let cmd = vec!["bash".to_string(), "-lc".to_string(), "git status".to_string()];
568 let commands = parse_bash_lc_commands(&cmd);
569 assert!(commands.is_some());
570 let commands = commands.unwrap();
571 assert_eq!(commands.len(), 1);
572 assert_eq!(commands[0][0], "git");
573 }
574
575 #[test]
576 fn parse_bash_lc_chained() {
577 let cmd = vec![
578 "bash".to_string(),
579 "-lc".to_string(),
580 "git status && cargo check".to_string(),
581 ];
582 let commands = parse_bash_lc_commands(&cmd);
583 assert!(commands.is_some());
584 let commands = commands.unwrap();
585 assert_eq!(commands.len(), 2);
586 }
587
588 #[test]
589 fn parse_non_bash_command_returns_none() {
590 let cmd = vec!["echo".to_string(), "hello".to_string()];
591 let commands = parse_bash_lc_commands(&cmd);
592 assert!(commands.is_none());
593 }
594
595 #[test]
596 fn parse_bash_without_lc_returns_none() {
597 let cmd = vec!["bash".to_string(), "script.sh".to_string()];
598 let commands = parse_bash_lc_commands(&cmd);
599 assert!(commands.is_none());
600 }
601
602 #[test]
605 fn parse_complex_pipeline() {
606 let script = "cat file.txt | grep -i pattern | sort";
607 let commands = parse_shell_commands(script).unwrap();
608 assert!(!commands.is_empty());
609 }
610
611 #[test]
612 fn parse_with_pipes_and_redirects() {
613 let script = "ls -la | grep file > output.txt";
614 let commands = parse_shell_commands(script).unwrap();
615 assert!(!commands.is_empty());
616 }
617
618 #[test]
619 fn parse_command_substitution_fallback() {
620 let script = "echo $(git status)";
621 let commands = parse_shell_commands(script).unwrap();
622 assert!(!commands.is_empty());
623 }
624
625 #[test]
626 fn parse_escaped_quotes() {
627 let script = r#"echo "hello \"world\"""#;
628 let commands = parse_shell_commands(script).unwrap();
629 assert!(!commands.is_empty());
630 }
631
632 #[test]
633 fn parse_tree_sitter_preserves_command_name_with_quoted_args() {
634 let script = r#"echo "fish and chips""#;
635 let commands = parse_shell_commands_tree_sitter(script).unwrap();
636 assert!(!commands.is_empty());
637 assert_eq!(commands[0][0], "echo");
638 }
639
640 #[test]
641 fn parse_tree_sitter_preserves_single_and_ansi_quoted_args() {
642 let script = r#"printf '\n' && git diff '--output=out.txt' && printf $'\n'"#;
643 let commands = parse_shell_commands_tree_sitter(script).unwrap();
644 assert!(
645 commands
646 .iter()
647 .any(|command| command.iter().any(|word| word.contains("--output=out.txt")))
648 );
649 assert!(commands.iter().any(|command| command.iter().any(|word| word.contains("\\n"))));
650 }
651
652 #[test]
653 fn dynamic_syntax_allows_literal_escapes_inside_double_quoted_arguments() {
654 assert!(!contains_dynamic_shell_syntax(r#"rg -n "\[profile|lto|codegen-units|strip" Cargo.toml"#));
655 assert!(!contains_dynamic_shell_syntax(r#"printf "\nTop-level:\n""#));
656 assert!(!contains_dynamic_shell_syntax(r#"printf "quoted: \"value\"""#));
657 assert!(contains_dynamic_shell_syntax(r#"echo "safe\"$(id)""#));
658 }
659
660 #[test]
661 fn dynamic_syntax_rejects_unquoted_escapes() {
662 assert!(contains_dynamic_shell_syntax(r"rg -n \[profile Cargo.toml"));
663 }
664
665 #[test]
666 fn output_redirection_guard_rejects_input_and_heredoc_shapes() {
667 assert!(has_only_output_redirections("cargo check > build.log 2>&1"));
668 assert!(has_only_output_redirections("cargo check | head -40 > build.log"));
669 assert!(has_only_output_redirections("cargo check &> build.log"));
670 assert!(has_only_output_redirections("cargo check &>> build.log"));
671 assert!(!has_only_output_redirections("cargo check < build-input.log"));
672 assert!(!has_only_output_redirections("cargo check <<'EOF'\ninput\nEOF"));
673 assert!(!has_only_output_redirections("cargo check > $(printf build.log)"));
674 assert!(!has_only_output_redirections("cargo check > build.log &"));
675 assert!(!has_only_output_redirections("cargo check 2>&-"));
676 }
677
678 #[test]
679 fn strict_tree_sitter_parser_rejects_incomplete_shell_syntax() {
680 assert!(parse_shell_commands_tree_sitter("cargo check &&").is_err());
681 assert!(parse_shell_commands_tree_sitter("echo '").is_err());
682 }
683
684 #[test]
685 fn parse_bash_lc_with_pipe() {
686 let cmd = vec!["bash".to_string(), "-lc".to_string(), "ls -la | head -5".to_string()];
687 let commands = parse_bash_lc_commands(&cmd);
688 assert!(commands.is_some());
689 let cmds = commands.unwrap();
690 assert!(!cmds.is_empty());
691 }
692
693 #[test]
694 fn parse_dangerous_shell_command() {
695 let script = "rm -rf /; echo done";
696 let commands = parse_shell_commands(script).unwrap();
697 assert_eq!(commands.len(), 2);
698 assert_eq!(commands[0][0], "rm");
699 }
700
701 #[test]
702 fn prewarm_bash_parser_initializes_successfully() {
703 prewarm_bash_parser().expect("bash parser should initialize");
704 }
705
706 #[test]
707 fn dynamic_find_syntax_is_detected_without_rejecting_quoted_globs() {
708 assert!(contains_dynamic_find_syntax("find src -maxdepth 0 -exe$''c touch /tmp/VT_BYPASS_POC {} +"));
709 assert!(!contains_dynamic_find_syntax("find src -type f -name '*.rs'"));
710 }
711}
712
713use anyhow::bail;
716
717pub(crate) fn quoted_heredoc_delim(rest: &str) -> Option<(String, usize)> {
723 let mut idx = 0usize;
724 let bytes = rest.as_bytes();
725 if bytes.first() == Some(&b'-') {
726 idx += 1;
727 }
728 while idx < bytes.len() && (bytes[idx] == b' ' || bytes[idx] == b'\t') {
729 idx += 1;
730 }
731 let quote = match bytes.get(idx) {
732 Some(b'\'') | Some(b'"') => bytes[idx],
733 _ => return None,
734 };
735 idx += 1;
736 let delim_start = idx;
737 while idx < bytes.len() && bytes[idx] != quote {
738 idx += 1;
739 }
740 if idx >= bytes.len() || idx == delim_start {
741 return None;
742 }
743 let delim = rest[delim_start..idx].to_string();
744 idx += 1; Some((delim, idx))
746}
747
748pub(crate) fn heredoc_body_skip_len(rest: &str, delim: &str) -> Option<usize> {
753 let bytes = rest.as_bytes();
754 let mut idx = 0usize;
755 while idx <= bytes.len() {
756 let line_end = rest[idx..].find('\n').map(|offset| idx + offset).unwrap_or(bytes.len());
757 let line = rest[idx..line_end].trim_end_matches('\r');
758 let next = if line_end < bytes.len() {
759 line_end + 1
760 } else {
761 bytes.len()
762 };
763 if line == delim {
764 return Some(next);
765 }
766 if line_end >= bytes.len() {
767 return None;
768 }
769 idx = next;
770 }
771 None
772}
773
774fn consume_bytes(
777 chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>,
778 max_bytes: usize,
779 fallback: usize,
780) -> usize {
781 let mut consumed = 0usize;
782 let mut end = fallback;
783 while consumed < max_bytes
784 && let Some((idx, ch)) = chars.next()
785 {
786 consumed += ch.len_utf8();
787 end = idx + ch.len_utf8();
788 }
789 end
790}
791
792#[derive(Clone, Copy, Eq, PartialEq)]
794enum QuoteState {
795 None,
796 Single,
797 Double,
798}
799
800pub(crate) fn split_shell_segments(command: &str) -> Result<Vec<String>> {
803 let mut segments = Vec::new();
804 let mut state = QuoteState::None;
805 let mut escaped = false;
806 let mut segment_start = 0usize;
807 let mut pending_heredoc: Option<String> = None;
811 let mut chars = command.char_indices().peekable();
812
813 while let Some((idx, ch)) = chars.next() {
814 match state {
815 QuoteState::Single => {
816 if ch == '\'' {
817 state = QuoteState::None;
818 }
819 }
820 QuoteState::Double => {
821 if escaped {
822 escaped = false;
823 continue;
824 }
825
826 match ch {
827 '\\' => escaped = true,
828 '"' => state = QuoteState::None,
829 '`' => bail!("Command injection pattern detected"),
830 '$' if matches!(chars.peek(), Some((_, '('))) => {
831 bail!("Command injection pattern detected");
832 }
833 _ => {}
834 }
835 }
836 QuoteState::None => {
837 if escaped {
838 escaped = false;
839 continue;
840 }
841
842 match ch {
843 '\\' => escaped = true,
844 '\'' => state = QuoteState::Single,
845 '"' => state = QuoteState::Double,
846 '<' if matches!(chars.peek(), Some((_, '<'))) => {
852 let _ = chars.next(); let rest: String = chars.clone().map(|(_, c)| c).collect();
854 if let Some((delim, token_len)) = quoted_heredoc_delim(&rest) {
855 let _ = consume_bytes(&mut chars, token_len, idx + ch.len_utf8());
856 pending_heredoc = Some(delim);
857 }
858 }
859 '`' => bail!("Command injection pattern detected"),
860 '$' if matches!(chars.peek(), Some((_, '('))) => {
861 bail!("Command injection pattern detected");
862 }
863 ';' => bail!("Unquoted command chaining detected"),
864 '\n' => {
865 if let Some(delim) = pending_heredoc.take() {
870 let rest: String = chars.clone().map(|(_, c)| c).collect();
871 if let Some(skip) = heredoc_body_skip_len(&rest, &delim) {
872 push_segment(command, segment_start, idx, &mut segments);
875 segment_start = consume_bytes(&mut chars, skip, command.len());
876 continue;
877 }
878 }
879 bail!(
880 "multi-line shell commands are not allowed; write the content with `apply_patch` instead of a heredoc"
881 );
882 }
883 '|' | '&' => {
884 push_segment(command, segment_start, idx, &mut segments);
885 segment_start = idx + ch.len_utf8();
886 if let Some((next_idx, next_ch)) = chars.peek().copied()
887 && next_ch == ch
888 {
889 let _next = chars.next();
890 segment_start = next_idx + next_ch.len_utf8();
891 }
892 }
893 _ => {}
894 }
895 }
896 }
897 }
898
899 push_segment(command, segment_start, command.len(), &mut segments);
900 Ok(segments)
901}
902
903fn push_segment(command: &str, start: usize, end: usize, segments: &mut Vec<String>) {
904 let segment = command[start..end].trim();
905 if !segment.is_empty() {
906 segments.push(segment.to_string());
907 }
908}
909
910pub(crate) fn additional_dangerous_pattern(segment: &str) -> Option<&'static str> {
912 let segment_lower = segment.to_ascii_lowercase();
913 if segment_lower.starts_with(":(){:|:&};:") {
914 return Some(":(){:|:&};:");
915 }
916
917 let tokens =
918 shell_words::split(segment).unwrap_or_else(|_| segment.split_whitespace().map(ToString::to_string).collect());
919 let first = tokens.first()?;
920 let command_name = base_command_name(strip_wrapping_quotes(first)).to_ascii_lowercase();
921
922 match command_name.as_str() {
923 "rmdir" => Some("rmdir"),
924 "wget" => Some("wget"),
925 "curl" => Some("curl"),
926 "chmod" if tokens.iter().skip(1).any(|arg| strip_wrapping_quotes(arg).starts_with("777")) => Some("chmod 777"),
927 "chown"
928 if tokens.iter().skip(1).any(|arg| {
929 let arg = strip_wrapping_quotes(arg).to_ascii_lowercase();
930 arg == "root" || arg.starts_with("root:")
931 }) =>
932 {
933 Some("chown root")
934 }
935 _ => None,
936 }
937}
938
939fn strip_wrapping_quotes(token: &str) -> &str {
940 token
941 .strip_prefix('\'')
942 .and_then(|token| token.strip_suffix('\''))
943 .or_else(|| token.strip_prefix('"').and_then(|token| token.strip_suffix('"')))
944 .unwrap_or(token)
945}
946
947fn base_command_name(command: &str) -> &str {
948 std::path::Path::new(command)
949 .file_name()
950 .and_then(|name| name.to_str())
951 .unwrap_or(command)
952}