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
25static BASH_PARSER: OnceLock<Result<Mutex<tree_sitter::Parser>, String>> = OnceLock::new();
27
28pub fn contains_dynamic_shell_syntax(command: &str) -> bool {
38 enum ShellQuote {
39 Single,
40 Double,
41 }
42
43 let mut quote: Option<ShellQuote> = None;
44 let mut characters = command.chars();
45
46 while let Some(character) = characters.next() {
47 match quote {
48 Some(ShellQuote::Single) => {
49 if character == '\'' {
50 quote = None;
51 }
52 }
53 Some(ShellQuote::Double) => match character {
54 '"' => quote = None,
55 '$' | '`' => return true,
56 '\\' => {
57 if characters.next().is_none() {
63 return true;
64 }
65 }
66 _ => {}
67 },
68 None => match character {
69 '\'' => quote = Some(ShellQuote::Single),
70 '"' => quote = Some(ShellQuote::Double),
71 '\\' | '$' | '`' | '{' | '}' | '*' | '?' | '[' | ']' => return true,
72 _ => {}
73 },
74 }
75 }
76
77 quote.is_some()
78}
79
80pub fn contains_dynamic_find_syntax(script: &str) -> bool {
83 if let Ok(commands) = parse_shell_commands_tree_sitter(script)
84 && commands.iter().any(|command| {
85 command
86 .first()
87 .map(|program| base_command_name(program) == "find")
88 .unwrap_or(false)
89 && command.iter().any(|word| contains_dynamic_shell_syntax(word))
90 })
91 {
92 return true;
93 }
94
95 let has_find_word = script.split_whitespace().any(|word| {
99 let command = word.trim_matches(|character: char| !character.is_ascii_alphanumeric() && character != '/');
100 base_command_name(command) == "find"
101 });
102 has_find_word && contains_dynamic_shell_syntax(script)
103}
104
105fn get_bash_parser() -> Result<&'static Mutex<tree_sitter::Parser>, String> {
107 BASH_PARSER
108 .get_or_init(|| {
109 let mut parser = tree_sitter::Parser::new();
110 let lang: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
111 parser
112 .set_language(&lang)
113 .map_err(|e| format!("Failed to load bash grammar: {e}"))?;
114 Ok(Mutex::new(parser))
115 })
116 .as_ref()
117 .map_err(Clone::clone)
118}
119
120pub fn prewarm_bash_parser() -> Result<(), String> {
122 let _ = get_bash_parser()?;
123 Ok(())
124}
125
126pub fn parse_shell_commands(script: &str) -> Result<Vec<Vec<String>>, String> {
137 match parse_with_tree_sitter(script, false) {
139 Ok(commands) if !commands.is_empty() => return Ok(commands),
140 Ok(_) => {} Err(e) => {
142 tracing::debug!("Tree-sitter bash parsing failed: {}, falling back to basic tokenization", e);
143 }
144 }
145
146 parse_with_basic_tokenization(script)
148}
149
150pub fn parse_shell_commands_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
154 parse_with_tree_sitter(script, true)
155}
156
157pub fn has_only_output_redirections(script: &str) -> bool {
161 if contains_dynamic_shell_syntax(script) {
162 return false;
163 }
164 if contains_background_operator(script) {
165 return false;
166 }
167
168 let Ok(parser) = get_bash_parser() else {
169 return false;
170 };
171 let Ok(mut parser) = parser.lock() else {
172 return false;
173 };
174 let Some(tree) = parser.parse(script, None) else {
175 return false;
176 };
177 if tree.root_node().has_error() {
178 return false;
179 }
180
181 let mut saw_redirection = false;
182 if !collect_output_redirections(tree.root_node(), script, &mut saw_redirection) {
183 return false;
184 }
185 saw_redirection
186}
187
188fn contains_background_operator(script: &str) -> bool {
189 let chars = script.chars().collect::<Vec<_>>();
190 let mut index = 0;
191 let mut in_single_quote = false;
192 let mut in_double_quote = false;
193
194 while index < chars.len() {
195 let character = chars[index];
196 if character == '\'' && !in_double_quote {
197 in_single_quote = !in_single_quote;
198 index += 1;
199 continue;
200 }
201 if character == '"' && !in_single_quote {
202 in_double_quote = !in_double_quote;
203 index += 1;
204 continue;
205 }
206 if in_single_quote || in_double_quote {
207 index += 1;
208 continue;
209 }
210
211 if character == '&' {
212 let previous = index.checked_sub(1).and_then(|position| chars.get(position));
213 let next = chars.get(index + 1);
214 if next == Some(&'&') {
215 index += 2;
216 continue;
217 }
218 let part_of_allowed_operator = next == Some(&'&')
219 || next == Some(&'>')
220 || previous == Some(&'>')
221 || previous == Some(&'|')
222 || previous == Some(&'<');
223 if !part_of_allowed_operator {
224 return true;
225 }
226 }
227 index += 1;
228 }
229
230 false
231}
232
233fn collect_output_redirections(node: tree_sitter::Node, source: &str, saw_redirection: &mut bool) -> bool {
234 match node.kind() {
235 "file_redirect" => {
236 *saw_redirection = true;
237 let Ok(text) = node.utf8_text(source.as_bytes()) else {
238 return false;
239 };
240 if !is_output_redirection(text) {
241 return false;
242 }
243 }
244 "heredoc_redirect" | "herestring_redirect" => return false,
245 _ => {}
246 }
247
248 let mut cursor = node.walk();
249 node.children(&mut cursor)
250 .all(|child| collect_output_redirections(child, source, saw_redirection))
251}
252
253fn is_output_redirection(text: &str) -> bool {
254 let redirect = text.trim_start_matches(|character: char| character.is_ascii_digit());
255 if redirect.starts_with("&>") {
256 return !redirect.starts_with("&>-");
257 }
258 if let Some(destination) = redirect.strip_prefix(">&") {
259 return destination.trim().chars().all(|character| character.is_ascii_digit());
260 }
261
262 redirect.starts_with('>') && !redirect.starts_with(">&-")
263}
264
265fn parse_with_tree_sitter(script: &str, reject_syntax_errors: bool) -> Result<Vec<Vec<String>>, String> {
267 let parser_guard = get_bash_parser()?;
268 let mut parser = parser_guard.lock().map_err(|e| format!("Failed to lock parser: {e}"))?;
269
270 let tree = parser.parse(script, None).ok_or_else(|| "Failed to parse script".to_string())?;
271
272 let mut commands = Vec::new();
273 let root = tree.root_node();
274 if reject_syntax_errors && root.has_error() {
275 return Err("Shell script contains syntax errors".to_string());
276 }
277
278 collect_commands_from_node(root, script, &mut commands);
282
283 Ok(commands)
284}
285
286fn collect_commands_from_node(node: tree_sitter::Node, source: &str, commands: &mut Vec<Vec<String>>) {
287 match node.kind() {
288 "command" | "simple_command" => {
289 if let Some(cmd) = extract_command_from_node(node, source)
290 && !cmd.is_empty()
291 {
292 commands.push(cmd);
293 }
294 }
295 _ => {
296 let mut cursor = node.walk();
297 for child in node.children(&mut cursor) {
298 collect_commands_from_node(child, source, commands);
299 }
300 }
301 }
302}
303
304fn extract_command_from_node(node: tree_sitter::Node, source: &str) -> Option<Vec<String>> {
306 let mut command = Vec::new();
307 let mut cursor = node.walk();
308
309 if node.kind() == "pipeline" {
311 for child in node.children(&mut cursor) {
312 if child.kind() == "command" || child.kind() == "simple_command" {
313 return extract_command_from_node(child, source);
314 }
315 }
316 }
317
318 for child in node.children(&mut cursor) {
320 if child.kind() == "command_name" {
321 if let Ok(arg) = child.utf8_text(source.as_bytes()) {
322 let trimmed = arg.trim();
323 if !trimmed.is_empty() {
324 command.push(trimmed.to_string());
325 }
326 }
327 continue;
328 }
329
330 if matches!(
331 child.kind(),
332 "word" | "string" | "raw_string" | "ansi_c_string" | "simple_expansion" | "variable_expansion"
333 ) {
334 let text = child.utf8_text(source.as_bytes());
335 if let Ok(arg) = text {
336 let trimmed = arg.trim();
337 if !trimmed.is_empty() {
338 command.push(trimmed.to_string());
339 }
340 }
341 }
342 }
343
344 if command.is_empty() { None } else { Some(command) }
345}
346
347fn parse_with_basic_tokenization(script: &str) -> Result<Vec<Vec<String>>, String> {
349 let mut commands = Vec::new();
350 let mut current_command = String::new();
351 let mut in_quotes = false;
352 let mut quote_char = ' ';
353 let mut escaped = false;
354
355 for ch in script.chars() {
356 if escaped {
357 current_command.push(ch);
358 escaped = false;
359 continue;
360 }
361
362 match ch {
363 '\\' => {
364 escaped = true;
365 }
366 '\'' | '"' if !in_quotes => {
367 in_quotes = true;
368 quote_char = ch;
369 }
370 c if c == quote_char && in_quotes => {
371 in_quotes = false;
372 }
373 '&' | '|' | ';' if !in_quotes => {
374 if !current_command.trim().is_empty()
375 && let Ok(cmd) = tokenize_command(¤t_command)
376 {
377 commands.push(cmd);
378 }
379 current_command.clear();
380 }
381 _ => current_command.push(ch),
382 }
383 }
384
385 if !current_command.trim().is_empty()
386 && let Ok(cmd) = tokenize_command(¤t_command)
387 {
388 commands.push(cmd);
389 }
390
391 Ok(commands)
392}
393
394fn tokenize_command(cmd: &str) -> Result<Vec<String>, String> {
397 shell_words::split(cmd).map_err(|err| format!("failed to tokenize command: {err}"))
398}
399
400pub fn parse_bash_lc_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
408 if command.is_empty() {
409 return None;
410 }
411
412 let cmd_name = command[0].as_str();
413 let base_cmd = std::path::Path::new(cmd_name)
414 .file_name()
415 .and_then(|osstr| osstr.to_str())
416 .unwrap_or("");
417
418 if base_cmd != "bash" && base_cmd != "zsh" && base_cmd != "sh" {
419 return None;
420 }
421
422 for window in command.windows(2) {
424 if matches!(window[0].as_str(), "-lc" | "-c" | "-il" | "-ic") {
425 let script = &window[1];
426 return parse_shell_commands(script).ok();
427 }
428 }
429
430 None
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[test]
438 fn tokenize_simple_command() {
439 let cmd = "git status";
440 let tokens = tokenize_command(cmd).unwrap();
441 assert_eq!(tokens, vec!["git", "status"]);
442 }
443
444 #[test]
445 fn tokenize_quoted_arguments() {
446 let cmd = r#"echo "hello world""#;
447 let tokens = tokenize_command(cmd).unwrap();
448 assert_eq!(tokens, vec!["echo", "hello world"]);
449 }
450
451 #[test]
452 fn parse_single_command() {
453 let script = "git status";
454 let commands = parse_shell_commands(script).unwrap();
455 assert_eq!(commands.len(), 1);
456 assert_eq!(commands[0][0], "git");
457 }
458
459 #[test]
460 fn parse_chained_commands_with_and() {
461 let script = "git status && cargo check";
462 let commands = parse_shell_commands(script).unwrap();
463 assert_eq!(commands.len(), 2);
464 assert_eq!(commands[0][0], "git");
465 assert_eq!(commands[1][0], "cargo");
466 }
467
468 #[test]
469 fn parse_loop_body_commands() {
470 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";
471 let commands = parse_shell_commands(script).unwrap();
472
473 assert_eq!(commands[0], vec!["cd", "crates/codegen/vtcode-core/src/tools/registry"]);
474 assert!(
475 commands
476 .iter()
477 .any(|command| command.first().is_some_and(|name| name == "echo"))
478 );
479 assert!(
480 commands
481 .iter()
482 .any(|command| command.first().is_some_and(|name| name == "grep"))
483 );
484 assert!(
485 commands
486 .iter()
487 .any(|command| command.first().is_some_and(|name| name == "head"))
488 );
489 }
490
491 #[test]
492 fn parse_chained_commands_with_semicolon() {
493 let script = "git status; cargo check";
494 let commands = parse_shell_commands(script).unwrap();
495 assert_eq!(commands.len(), 2);
496 }
497
498 #[test]
499 fn parse_bash_lc_git_status() {
500 let cmd = vec!["bash".to_string(), "-lc".to_string(), "git status".to_string()];
501 let commands = parse_bash_lc_commands(&cmd);
502 assert!(commands.is_some());
503 let commands = commands.unwrap();
504 assert_eq!(commands.len(), 1);
505 assert_eq!(commands[0][0], "git");
506 }
507
508 #[test]
509 fn parse_bash_lc_chained() {
510 let cmd = vec![
511 "bash".to_string(),
512 "-lc".to_string(),
513 "git status && cargo check".to_string(),
514 ];
515 let commands = parse_bash_lc_commands(&cmd);
516 assert!(commands.is_some());
517 let commands = commands.unwrap();
518 assert_eq!(commands.len(), 2);
519 }
520
521 #[test]
522 fn parse_non_bash_command_returns_none() {
523 let cmd = vec!["echo".to_string(), "hello".to_string()];
524 let commands = parse_bash_lc_commands(&cmd);
525 assert!(commands.is_none());
526 }
527
528 #[test]
529 fn parse_bash_without_lc_returns_none() {
530 let cmd = vec!["bash".to_string(), "script.sh".to_string()];
531 let commands = parse_bash_lc_commands(&cmd);
532 assert!(commands.is_none());
533 }
534
535 #[test]
538 fn parse_complex_pipeline() {
539 let script = "cat file.txt | grep -i pattern | sort";
540 let commands = parse_shell_commands(script).unwrap();
541 assert!(!commands.is_empty());
542 }
543
544 #[test]
545 fn parse_with_pipes_and_redirects() {
546 let script = "ls -la | grep file > output.txt";
547 let commands = parse_shell_commands(script).unwrap();
548 assert!(!commands.is_empty());
549 }
550
551 #[test]
552 fn parse_command_substitution_fallback() {
553 let script = "echo $(git status)";
554 let commands = parse_shell_commands(script).unwrap();
555 assert!(!commands.is_empty());
556 }
557
558 #[test]
559 fn parse_escaped_quotes() {
560 let script = r#"echo "hello \"world\"""#;
561 let commands = parse_shell_commands(script).unwrap();
562 assert!(!commands.is_empty());
563 }
564
565 #[test]
566 fn parse_tree_sitter_preserves_command_name_with_quoted_args() {
567 let script = r#"echo "fish and chips""#;
568 let commands = parse_shell_commands_tree_sitter(script).unwrap();
569 assert!(!commands.is_empty());
570 assert_eq!(commands[0][0], "echo");
571 }
572
573 #[test]
574 fn parse_tree_sitter_preserves_single_and_ansi_quoted_args() {
575 let script = r#"printf '\n' && git diff '--output=out.txt' && printf $'\n'"#;
576 let commands = parse_shell_commands_tree_sitter(script).unwrap();
577 assert!(
578 commands
579 .iter()
580 .any(|command| command.iter().any(|word| word.contains("--output=out.txt")))
581 );
582 assert!(commands.iter().any(|command| command.iter().any(|word| word.contains("\\n"))));
583 }
584
585 #[test]
586 fn dynamic_syntax_allows_literal_escapes_inside_double_quoted_arguments() {
587 assert!(!contains_dynamic_shell_syntax(r#"rg -n "\[profile|lto|codegen-units|strip" Cargo.toml"#));
588 assert!(!contains_dynamic_shell_syntax(r#"printf "\nTop-level:\n""#));
589 assert!(!contains_dynamic_shell_syntax(r#"printf "quoted: \"value\"""#));
590 assert!(contains_dynamic_shell_syntax(r#"echo "safe\"$(id)""#));
591 }
592
593 #[test]
594 fn dynamic_syntax_rejects_unquoted_escapes() {
595 assert!(contains_dynamic_shell_syntax(r"rg -n \[profile Cargo.toml"));
596 }
597
598 #[test]
599 fn output_redirection_guard_rejects_input_and_heredoc_shapes() {
600 assert!(has_only_output_redirections("cargo check > build.log 2>&1"));
601 assert!(has_only_output_redirections("cargo check | head -40 > build.log"));
602 assert!(has_only_output_redirections("cargo check &> build.log"));
603 assert!(has_only_output_redirections("cargo check &>> build.log"));
604 assert!(!has_only_output_redirections("cargo check < build-input.log"));
605 assert!(!has_only_output_redirections("cargo check <<'EOF'\ninput\nEOF"));
606 assert!(!has_only_output_redirections("cargo check > $(printf build.log)"));
607 assert!(!has_only_output_redirections("cargo check > build.log &"));
608 assert!(!has_only_output_redirections("cargo check 2>&-"));
609 }
610
611 #[test]
612 fn strict_tree_sitter_parser_rejects_incomplete_shell_syntax() {
613 assert!(parse_shell_commands_tree_sitter("cargo check &&").is_err());
614 assert!(parse_shell_commands_tree_sitter("echo '").is_err());
615 }
616
617 #[test]
618 fn parse_bash_lc_with_pipe() {
619 let cmd = vec!["bash".to_string(), "-lc".to_string(), "ls -la | head -5".to_string()];
620 let commands = parse_bash_lc_commands(&cmd);
621 assert!(commands.is_some());
622 let cmds = commands.unwrap();
623 assert!(!cmds.is_empty());
624 }
625
626 #[test]
627 fn parse_dangerous_shell_command() {
628 let script = "rm -rf /; echo done";
629 let commands = parse_shell_commands(script).unwrap();
630 assert_eq!(commands.len(), 2);
631 assert_eq!(commands[0][0], "rm");
632 }
633
634 #[test]
635 fn prewarm_bash_parser_initializes_successfully() {
636 prewarm_bash_parser().expect("bash parser should initialize");
637 }
638
639 #[test]
640 fn dynamic_find_syntax_is_detected_without_rejecting_quoted_globs() {
641 assert!(contains_dynamic_find_syntax("find src -maxdepth 0 -exe$''c touch /tmp/VT_BYPASS_POC {} +"));
642 assert!(!contains_dynamic_find_syntax("find src -type f -name '*.rs'"));
643 }
644}
645
646use anyhow::{Result, bail};
649
650#[derive(Clone, Copy, Eq, PartialEq)]
652enum QuoteState {
653 None,
654 Single,
655 Double,
656}
657
658pub(crate) fn split_shell_segments(command: &str) -> Result<Vec<String>> {
661 let mut segments = Vec::new();
662 let mut state = QuoteState::None;
663 let mut escaped = false;
664 let mut segment_start = 0usize;
665 let mut chars = command.char_indices().peekable();
666
667 while let Some((idx, ch)) = chars.next() {
668 match state {
669 QuoteState::Single => {
670 if ch == '\'' {
671 state = QuoteState::None;
672 }
673 }
674 QuoteState::Double => {
675 if escaped {
676 escaped = false;
677 continue;
678 }
679
680 match ch {
681 '\\' => escaped = true,
682 '"' => state = QuoteState::None,
683 '`' => bail!("Command injection pattern detected"),
684 '$' if matches!(chars.peek(), Some((_, '('))) => {
685 bail!("Command injection pattern detected");
686 }
687 _ => {}
688 }
689 }
690 QuoteState::None => {
691 if escaped {
692 escaped = false;
693 continue;
694 }
695
696 match ch {
697 '\\' => escaped = true,
698 '\'' => state = QuoteState::Single,
699 '"' => state = QuoteState::Double,
700 '`' => bail!("Command injection pattern detected"),
701 '$' if matches!(chars.peek(), Some((_, '('))) => {
702 bail!("Command injection pattern detected");
703 }
704 ';' => bail!("Unquoted command chaining detected"),
705 '\n' => bail!("Command injection pattern detected"),
706 '|' | '&' => {
707 push_segment(command, segment_start, idx, &mut segments);
708 segment_start = idx + ch.len_utf8();
709 if let Some((next_idx, next_ch)) = chars.peek().copied()
710 && next_ch == ch
711 {
712 let _next = chars.next();
713 segment_start = next_idx + next_ch.len_utf8();
714 }
715 }
716 _ => {}
717 }
718 }
719 }
720 }
721
722 push_segment(command, segment_start, command.len(), &mut segments);
723 Ok(segments)
724}
725
726fn push_segment(command: &str, start: usize, end: usize, segments: &mut Vec<String>) {
727 let segment = command[start..end].trim();
728 if !segment.is_empty() {
729 segments.push(segment.to_string());
730 }
731}
732
733pub(crate) fn additional_dangerous_pattern(segment: &str) -> Option<&'static str> {
735 let segment_lower = segment.to_ascii_lowercase();
736 if segment_lower.starts_with(":(){:|:&};:") {
737 return Some(":(){:|:&};:");
738 }
739
740 let tokens =
741 shell_words::split(segment).unwrap_or_else(|_| segment.split_whitespace().map(ToString::to_string).collect());
742 let first = tokens.first()?;
743 let command_name = base_command_name(strip_wrapping_quotes(first)).to_ascii_lowercase();
744
745 match command_name.as_str() {
746 "rmdir" => Some("rmdir"),
747 "wget" => Some("wget"),
748 "curl" => Some("curl"),
749 "chmod" if tokens.iter().skip(1).any(|arg| strip_wrapping_quotes(arg).starts_with("777")) => Some("chmod 777"),
750 "chown"
751 if tokens.iter().skip(1).any(|arg| {
752 let arg = strip_wrapping_quotes(arg).to_ascii_lowercase();
753 arg == "root" || arg.starts_with("root:")
754 }) =>
755 {
756 Some("chown root")
757 }
758 _ => None,
759 }
760}
761
762fn strip_wrapping_quotes(token: &str) -> &str {
763 token
764 .strip_prefix('\'')
765 .and_then(|token| token.strip_suffix('\''))
766 .or_else(|| token.strip_prefix('"').and_then(|token| token.strip_suffix('"')))
767 .unwrap_or(token)
768}
769
770fn base_command_name(command: &str) -> &str {
771 std::path::Path::new(command)
772 .file_name()
773 .and_then(|name| name.to_str())
774 .unwrap_or(command)
775}