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