vtcode_safety/command_safety/shell_parser/
mod.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::num::NonZeroUsize;
23use std::sync::Mutex;
24use std::sync::OnceLock;
25
26use anyhow::Result;
27use lru::LruCache;
28
29static BASH_PARSER: OnceLock<Result<Mutex<tree_sitter::Parser>, String>> = OnceLock::new();
31
32const SHELL_TREE_CACHE_CAPACITY: usize = 256;
34
35static SHELL_TREE_CACHE: OnceLock<Mutex<LruCache<String, tree_sitter::Tree, rustc_hash::FxBuildHasher>>> =
49 OnceLock::new();
50
51fn shell_tree_cache() -> &'static Mutex<LruCache<String, tree_sitter::Tree, rustc_hash::FxBuildHasher>> {
57 SHELL_TREE_CACHE.get_or_init(|| {
58 let capacity = NonZeroUsize::new(SHELL_TREE_CACHE_CAPACITY).unwrap_or(NonZeroUsize::MIN);
59 Mutex::new(LruCache::with_hasher(capacity, rustc_hash::FxBuildHasher))
60 })
61}
62
63fn parse_bash_tree(script: &str) -> Result<(tree_sitter::Tree, bool), String> {
72 if let Some(tree) = {
73 let mut cache = shell_tree_cache().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
74 cache.get(script).cloned()
75 } {
76 let has_error = tree.root_node().has_error();
77 return Ok((tree, has_error));
78 }
79
80 let (tree, has_error) = {
81 let parser_guard = get_bash_parser()?;
82 let mut parser = parser_guard.lock().map_err(|e| format!("Failed to lock parser: {e}"))?;
83 let tree = parser.parse(script, None).ok_or_else(|| "Failed to parse script".to_string())?;
84 let has_error = tree.root_node().has_error();
85 (tree, has_error)
86 };
87
88 let mut cache = shell_tree_cache().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
89 drop(cache.put(script.to_string(), tree.clone()));
90 Ok((tree, has_error))
91}
92
93pub fn contains_dynamic_shell_syntax(command: &str) -> bool {
103 enum ShellQuote {
104 Single,
105 Double,
106 }
107
108 let mut quote: Option<ShellQuote> = None;
109 let mut characters = command.chars();
110
111 while let Some(character) = characters.next() {
112 match quote {
113 Some(ShellQuote::Single) => {
114 if character == '\'' {
115 quote = None;
116 }
117 }
118 Some(ShellQuote::Double) => match character {
119 '"' => quote = None,
120 '$' | '`' => return true,
121 '\\'
122 if characters.next().is_none() => {
128 return true;
129 }
130 _ => {}
131 },
132 None => match character {
133 '\'' => quote = Some(ShellQuote::Single),
134 '"' => quote = Some(ShellQuote::Double),
135 '\\' | '$' | '`' | '{' | '}' | '*' | '?' | '[' | ']' => return true,
136 _ => {}
137 },
138 }
139 }
140
141 quote.is_some()
142}
143
144pub fn contains_dynamic_find_syntax(script: &str) -> bool {
147 if let Ok(commands) = parse_shell_commands_tree_sitter(script)
148 && commands.iter().any(|command| {
149 command
150 .first()
151 .map(|program| base_command_name(program) == "find")
152 .unwrap_or(false)
153 && command.iter().any(|word| contains_dynamic_shell_syntax(word))
154 })
155 {
156 return true;
157 }
158
159 let has_find_word = script.split_whitespace().any(|word| {
163 let command = word.trim_matches(|character: char| !character.is_ascii_alphanumeric() && character != '/');
164 base_command_name(command) == "find"
165 });
166 has_find_word && contains_dynamic_shell_syntax(script)
167}
168
169fn get_bash_parser() -> Result<&'static Mutex<tree_sitter::Parser>, String> {
171 BASH_PARSER
172 .get_or_init(|| {
173 let mut parser = tree_sitter::Parser::new();
174 let lang: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
175 parser
176 .set_language(&lang)
177 .map_err(|e| format!("Failed to load bash grammar: {e}"))?;
178 Ok(Mutex::new(parser))
179 })
180 .as_ref()
181 .map_err(Clone::clone)
182}
183
184pub fn prewarm_bash_parser() -> Result<(), String> {
186 let _ = get_bash_parser()?;
187 Ok(())
188}
189
190pub fn parse_shell_commands(script: &str) -> Result<Vec<Vec<String>>, String> {
201 match parse_with_tree_sitter(script, false) {
203 Ok(commands) if !commands.is_empty() => return Ok(commands),
204 Ok(_) => {} Err(e) => {
206 tracing::debug!("Tree-sitter bash parsing failed: {}, falling back to basic tokenization", e);
207 }
208 }
209
210 parse_with_basic_tokenization(script)
212}
213
214pub fn parse_shell_commands_tree_sitter(script: &str) -> Result<Vec<Vec<String>>, String> {
218 parse_with_tree_sitter(script, true)
219}
220
221pub fn has_only_output_redirections(script: &str) -> bool {
225 if contains_dynamic_shell_syntax(script) {
226 return false;
227 }
228 if contains_background_operator(script) {
229 return false;
230 }
231
232 let Ok((tree, has_error)) = parse_bash_tree(script) else {
233 return false;
234 };
235 if has_error {
236 return false;
237 }
238
239 let mut saw_redirection = false;
240 if !collect_output_redirections(tree.root_node(), script, &mut saw_redirection) {
241 return false;
242 }
243 saw_redirection
244}
245
246pub(crate) fn validate_redirection_paths(script: &str) -> Result<()> {
249 use anyhow::{Context, anyhow, ensure};
250 if !script.contains(['<', '>']) {
251 return Ok(());
252 }
253 let (tree, has_error) =
254 parse_bash_tree(script).map_err(|error| anyhow!("failed to parse shell redirections: {error}"))?;
255 ensure!(!has_error, "cannot validate malformed shell redirections");
256 let mut pending = vec![tree.root_node()];
257 while let Some(node) = pending.pop() {
258 if node.kind() == "file_redirect" {
259 let text = node.utf8_text(script.as_bytes()).context("invalid shell redirection text")?;
260 let operator = text.trim_start_matches(|character: char| character.is_ascii_digit());
261 let descriptor_route = operator.starts_with(">&") || operator.starts_with("<&");
262 let mut cursor = node.walk();
263 let destinations = node.children_by_field_name("destination", &mut cursor);
264 for destination in destinations {
265 let raw = destination
266 .utf8_text(script.as_bytes())
267 .context("invalid redirection destination")?;
268 ensure!(!contains_dynamic_shell_syntax(raw), "dynamic redirection destination is not allowed");
269 let words = shell_words::split(raw).context("invalid quoted redirection destination")?;
270 ensure!(words.len() == 1, "redirection destination must be one literal path");
271 let path = words.first().context("missing redirection destination")?;
272 if descriptor_route && (path == "-" || path.chars().all(|character| character.is_ascii_digit())) {
273 continue;
274 }
275 ensure!(!path.starts_with('~'), "home-directory redirection destinations are not allowed");
280 if path == "/dev/null" {
282 continue;
283 }
284 vtcode_commons::paths::validate_path_safety(path)
285 .with_context(|| format!("unsafe shell redirection destination: {path}"))?;
286 }
287 }
288 let mut cursor = node.walk();
289 pending.extend(node.named_children(&mut cursor));
290 }
291 Ok(())
292}
293
294fn contains_background_operator(script: &str) -> bool {
295 let chars = script.chars().collect::<Vec<_>>();
296 let mut index = 0;
297 let mut in_single_quote = false;
298 let mut in_double_quote = false;
299
300 while index < chars.len() {
301 let character = chars[index];
302 if character == '\'' && !in_double_quote {
303 in_single_quote = !in_single_quote;
304 index += 1;
305 continue;
306 }
307 if character == '"' && !in_single_quote {
308 in_double_quote = !in_double_quote;
309 index += 1;
310 continue;
311 }
312 if in_single_quote || in_double_quote {
313 index += 1;
314 continue;
315 }
316
317 if character == '&' {
318 let previous = index.checked_sub(1).and_then(|position| chars.get(position));
319 let next = chars.get(index + 1);
320 if next == Some(&'&') {
321 index += 2;
322 continue;
323 }
324 let part_of_allowed_operator = next == Some(&'&')
325 || next == Some(&'>')
326 || previous == Some(&'>')
327 || previous == Some(&'|')
328 || previous == Some(&'<');
329 if !part_of_allowed_operator {
330 return true;
331 }
332 }
333 index += 1;
334 }
335
336 false
337}
338
339fn collect_output_redirections(node: tree_sitter::Node, source: &str, saw_redirection: &mut bool) -> bool {
340 match node.kind() {
341 "file_redirect" => {
342 *saw_redirection = true;
343 let Ok(text) = node.utf8_text(source.as_bytes()) else {
344 return false;
345 };
346 if !is_output_redirection(text) {
347 return false;
348 }
349 }
350 "heredoc_redirect" | "herestring_redirect" => return false,
351 _ => {}
352 }
353
354 let mut cursor = node.walk();
355 node.children(&mut cursor)
356 .all(|child| collect_output_redirections(child, source, saw_redirection))
357}
358
359fn is_output_redirection(text: &str) -> bool {
360 let redirect = text.trim_start_matches(|character: char| character.is_ascii_digit());
361 if redirect.starts_with("&>") {
362 return !redirect.starts_with("&>-");
363 }
364 if let Some(destination) = redirect.strip_prefix(">&") {
365 return destination.trim().chars().all(|character| character.is_ascii_digit());
366 }
367
368 redirect.starts_with('>') && !redirect.starts_with(">&-")
369}
370
371fn parse_with_tree_sitter(script: &str, reject_syntax_errors: bool) -> Result<Vec<Vec<String>>, String> {
373 let (tree, has_error) = parse_bash_tree(script)?;
374
375 let mut commands = Vec::new();
376 let root = tree.root_node();
377 if reject_syntax_errors && has_error {
378 return Err("Shell script contains syntax errors".to_string());
379 }
380
381 collect_commands_from_node(root, script, &mut commands);
385
386 Ok(commands)
387}
388
389fn collect_commands_from_node(node: tree_sitter::Node, source: &str, commands: &mut Vec<Vec<String>>) {
390 match node.kind() {
391 "command" | "simple_command" => {
392 if let Some(cmd) = extract_command_from_node(node, source)
393 && !cmd.is_empty()
394 {
395 commands.push(cmd);
396 }
397 }
398 _ => {
399 let mut cursor = node.walk();
400 for child in node.children(&mut cursor) {
401 collect_commands_from_node(child, source, commands);
402 }
403 }
404 }
405}
406
407fn extract_command_from_node(node: tree_sitter::Node, source: &str) -> Option<Vec<String>> {
409 let mut command = Vec::new();
410 let mut cursor = node.walk();
411
412 if node.kind() == "pipeline" {
414 for child in node.children(&mut cursor) {
415 if child.kind() == "command" || child.kind() == "simple_command" {
416 return extract_command_from_node(child, source);
417 }
418 }
419 }
420
421 for child in node.children(&mut cursor) {
423 if child.kind() == "command_name" {
424 if let Ok(arg) = child.utf8_text(source.as_bytes()) {
425 let trimmed = arg.trim();
426 if !trimmed.is_empty() {
427 command.push(trimmed.to_string());
428 }
429 }
430 continue;
431 }
432
433 if matches!(child.kind(), "variable_assignment" | "concatenation") {
441 if let Ok(arg) = child.utf8_text(source.as_bytes()) {
442 let trimmed = arg.trim();
443 if !trimmed.is_empty() {
444 command.push(trimmed.to_string());
445 }
446 }
447 continue;
448 }
449
450 if matches!(
451 child.kind(),
452 "word" | "string" | "raw_string" | "ansi_c_string" | "simple_expansion" | "variable_expansion"
453 ) {
454 let text = child.utf8_text(source.as_bytes());
455 if let Ok(arg) = text {
456 let trimmed = arg.trim();
457 if !trimmed.is_empty() {
458 command.push(trimmed.to_string());
459 }
460 }
461 }
462 }
463
464 if command.is_empty() { None } else { Some(command) }
465}
466
467fn parse_with_basic_tokenization(script: &str) -> Result<Vec<Vec<String>>, String> {
469 let mut commands = Vec::new();
470 let mut current_command = String::new();
471 let mut in_quotes = false;
472 let mut quote_char = ' ';
473 let mut escaped = false;
474
475 for ch in script.chars() {
476 if escaped {
477 current_command.push(ch);
478 escaped = false;
479 continue;
480 }
481
482 match ch {
483 '\\' => {
484 escaped = true;
485 }
486 '\'' | '"' if !in_quotes => {
487 in_quotes = true;
488 quote_char = ch;
489 }
490 c if c == quote_char && in_quotes => {
491 in_quotes = false;
492 }
493 '&' | '|' | ';' if !in_quotes => {
494 if !current_command.trim().is_empty()
495 && let Ok(cmd) = tokenize_command(¤t_command)
496 {
497 commands.push(cmd);
498 }
499 current_command.clear();
500 }
501 _ => current_command.push(ch),
502 }
503 }
504
505 if !current_command.trim().is_empty()
506 && let Ok(cmd) = tokenize_command(¤t_command)
507 {
508 commands.push(cmd);
509 }
510
511 Ok(commands)
512}
513
514fn tokenize_command(cmd: &str) -> Result<Vec<String>, String> {
517 shell_words::split(cmd).map_err(|err| format!("failed to tokenize command: {err}"))
518}
519
520pub fn parse_bash_lc_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
528 if command.is_empty() {
529 return None;
530 }
531
532 let cmd_name = command[0].as_str();
533 let base_cmd = std::path::Path::new(cmd_name)
534 .file_name()
535 .and_then(|osstr| osstr.to_str())
536 .unwrap_or("");
537
538 if base_cmd != "bash" && base_cmd != "zsh" && base_cmd != "sh" {
539 return None;
540 }
541
542 for window in command.windows(2) {
544 if matches!(window[0].as_str(), "-lc" | "-c" | "-il" | "-ic") {
545 let script = &window[1];
546 return parse_shell_commands(script).ok();
547 }
548 }
549
550 None
551}
552
553use anyhow::bail;
556
557pub(crate) fn quoted_heredoc_delim(rest: &str) -> Option<(String, usize)> {
563 let mut idx = 0usize;
564 let bytes = rest.as_bytes();
565 if bytes.first() == Some(&b'-') {
566 idx += 1;
567 }
568 while idx < bytes.len() && (bytes[idx] == b' ' || bytes[idx] == b'\t') {
569 idx += 1;
570 }
571 let quote = match bytes.get(idx) {
572 Some(b'\'') | Some(b'"') => bytes[idx],
573 _ => return None,
574 };
575 idx += 1;
576 let delim_start = idx;
577 while idx < bytes.len() && bytes[idx] != quote {
578 idx += 1;
579 }
580 if idx >= bytes.len() || idx == delim_start {
581 return None;
582 }
583 let delim = rest[delim_start..idx].to_string();
584 idx += 1; Some((delim, idx))
586}
587
588pub(crate) fn heredoc_body_skip_len(rest: &str, delim: &str) -> Option<usize> {
593 let bytes = rest.as_bytes();
594 let mut idx = 0usize;
595 while idx <= bytes.len() {
596 let line_end = rest[idx..].find('\n').map(|offset| idx + offset).unwrap_or(bytes.len());
597 let line = rest[idx..line_end].trim_end_matches('\r');
598 let next = if line_end < bytes.len() {
599 line_end + 1
600 } else {
601 bytes.len()
602 };
603 if line == delim {
604 return Some(next);
605 }
606 if line_end >= bytes.len() {
607 return None;
608 }
609 idx = next;
610 }
611 None
612}
613
614fn consume_bytes(
617 chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>,
618 max_bytes: usize,
619 fallback: usize,
620) -> usize {
621 let mut consumed = 0usize;
622 let mut end = fallback;
623 while consumed < max_bytes
624 && let Some((idx, ch)) = chars.next()
625 {
626 consumed += ch.len_utf8();
627 end = idx + ch.len_utf8();
628 }
629 end
630}
631
632#[derive(Clone, Copy, Eq, PartialEq)]
634enum QuoteState {
635 None,
636 Single,
637 Double,
638}
639
640pub(crate) fn split_shell_segments(command: &str) -> Result<Vec<String>> {
643 let mut segments = Vec::new();
644 let mut state = QuoteState::None;
645 let mut escaped = false;
646 let mut segment_start = 0usize;
647 let mut pending_heredoc: Option<String> = None;
651 let mut chars = command.char_indices().peekable();
652
653 while let Some((idx, ch)) = chars.next() {
654 match state {
655 QuoteState::Single => {
656 if ch == '\'' {
657 state = QuoteState::None;
658 }
659 }
660 QuoteState::Double => {
661 if escaped {
662 escaped = false;
663 continue;
664 }
665
666 match ch {
667 '\\' => escaped = true,
668 '"' => state = QuoteState::None,
669 '`' => bail!("Command injection pattern detected"),
670 '$' if matches!(chars.peek(), Some((_, '('))) => {
671 bail!("Command injection pattern detected");
672 }
673 _ => {}
674 }
675 }
676 QuoteState::None => {
677 if escaped {
678 escaped = false;
679 continue;
680 }
681
682 match ch {
683 '\\' => escaped = true,
684 '\'' => state = QuoteState::Single,
685 '"' => state = QuoteState::Double,
686 '<' if matches!(chars.peek(), Some((_, '<'))) => {
692 let _ = chars.next(); let rest: String = chars.clone().map(|(_, c)| c).collect();
694 if let Some((delim, token_len)) = quoted_heredoc_delim(&rest) {
695 let _ = consume_bytes(&mut chars, token_len, idx + ch.len_utf8());
696 pending_heredoc = Some(delim);
697 }
698 }
699 '`' => bail!("Command injection pattern detected"),
700 '$' if matches!(chars.peek(), Some((_, '('))) => {
701 bail!("Command injection pattern detected");
702 }
703 ';' => bail!("Unquoted command chaining detected"),
704 '\n' => {
705 if let Some(delim) = pending_heredoc.take() {
710 let rest: String = chars.clone().map(|(_, c)| c).collect();
711 if let Some(skip) = heredoc_body_skip_len(&rest, &delim) {
712 push_segment(command, segment_start, idx, &mut segments);
715 segment_start = consume_bytes(&mut chars, skip, command.len());
716 continue;
717 }
718 }
719 bail!(
720 "multi-line shell commands are not allowed; write the content with `apply_patch` instead of a heredoc"
721 );
722 }
723 '|' | '&' => {
724 push_segment(command, segment_start, idx, &mut segments);
725 segment_start = idx + ch.len_utf8();
726 if let Some((next_idx, next_ch)) = chars.peek().copied()
727 && next_ch == ch
728 {
729 let _next = chars.next();
730 segment_start = next_idx + next_ch.len_utf8();
731 }
732 }
733 _ => {}
734 }
735 }
736 }
737 }
738
739 push_segment(command, segment_start, command.len(), &mut segments);
740 Ok(segments)
741}
742
743fn push_segment(command: &str, start: usize, end: usize, segments: &mut Vec<String>) {
744 let segment = command[start..end].trim();
745 if !segment.is_empty() {
746 segments.push(segment.to_string());
747 }
748}
749
750pub(crate) fn additional_dangerous_pattern(segment: &str) -> Option<&'static str> {
752 let segment_lower = segment.to_ascii_lowercase();
753 if segment_lower.starts_with(":(){:|:&};:") {
754 return Some(":(){:|:&};:");
755 }
756
757 let tokens =
758 shell_words::split(segment).unwrap_or_else(|_| segment.split_whitespace().map(ToString::to_string).collect());
759 let first = tokens.first()?;
760 let command_name = base_command_name(strip_wrapping_quotes(first)).to_ascii_lowercase();
761
762 match command_name.as_str() {
763 "rmdir" => Some("rmdir"),
764 "wget" => Some("wget"),
765 "curl" => Some("curl"),
766 "chmod" if tokens.iter().skip(1).any(|arg| strip_wrapping_quotes(arg).starts_with("777")) => Some("chmod 777"),
767 "chown"
768 if tokens.iter().skip(1).any(|arg| {
769 let arg = strip_wrapping_quotes(arg).to_ascii_lowercase();
770 arg == "root" || arg.starts_with("root:")
771 }) =>
772 {
773 Some("chown root")
774 }
775 _ => None,
776 }
777}
778
779fn strip_wrapping_quotes(token: &str) -> &str {
780 token
781 .strip_prefix('\'')
782 .and_then(|token| token.strip_suffix('\''))
783 .or_else(|| token.strip_prefix('"').and_then(|token| token.strip_suffix('"')))
784 .unwrap_or(token)
785}
786
787fn base_command_name(command: &str) -> &str {
788 std::path::Path::new(command)
789 .file_name()
790 .and_then(|name| name.to_str())
791 .unwrap_or(command)
792}
793#[cfg(test)]
794mod tests;